AspNetCore.Docs/aspnetcore/mvc/controllers/testing.md

29 KiB

title author description monikerRange ms.author ms.custom ms.date uid
Test controller logic in ASP.NET Core ardalis Learn how to test controller logic in ASP.NET Core with Moq and xUnit. >= aspnetcore-2.1 tdykstra mvc 7/22/2020 mvc/controllers/testing

Unit test controller logic in ASP.NET Core

By Steve Smith

:::moniker range=">= aspnetcore-3.0"

Unit tests involve testing a part of an app in isolation from its infrastructure and dependencies. When unit testing controller logic, only the contents of a single action are tested, not the behavior of its dependencies or of the framework itself.

Unit testing controllers

Set up unit tests of controller actions to focus on the controller's behavior. A controller unit test avoids scenarios such as filters, routing, and model binding. Tests that cover the interactions among components that collectively respond to a request are handled by integration tests. For more information on integration tests, see xref:test/integration-tests.

If you're writing custom filters and routes, unit test them in isolation, not as part of tests on a particular controller action.

To demonstrate controller unit tests, review the following controller in the sample app.

View or download sample code (how to download)

The Home controller displays a list of brainstorming sessions and allows the creation of new brainstorming sessions with a POST request:

[!code-csharp]

The preceding controller:

The HTTP GET Index method has no looping or branching and only calls one method. The unit test for this action:

  • Mocks the IBrainstormSessionRepository service using the GetTestSessions method. GetTestSessions creates two mock brainstorm sessions with dates and session names.
  • Executes the Index method.
  • Makes assertions on the result returned by the method:

[!code-csharp]

[!code-csharp]

The Home controller's HTTP POST Index method tests verifies that:

An invalid model state is tested by adding errors using xref:Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary.AddModelError* as shown in the first test below:

[!code-csharp]

When ModelState isn't valid, the same ViewResult is returned as for a GET request. The test doesn't attempt to pass in an invalid model. Passing an invalid model isn't a valid approach, since model binding isn't running (although an integration test does use model binding). In this case, model binding isn't tested. These unit tests are only testing the code in the action method.

The second test verifies that when the ModelState is valid:

  • A new BrainstormSession is added (via the repository).
  • The method returns a RedirectToActionResult with the expected properties.

Mocked calls that aren't called are normally ignored, but calling Verifiable at the end of the setup call allows mock validation in the test. This is performed with the call to mockRepo.Verify, which fails the test if the expected method wasn't called.

[!NOTE] The Moq library used in this sample makes it possible to mix verifiable, or "strict", mocks with non-verifiable mocks (also called "loose" mocks or stubs). Learn more about customizing Mock behavior with Moq.

SessionController in the sample app displays information related to a particular brainstorming session. The controller includes logic to deal with invalid id values (there are two return scenarios in the following example to cover these scenarios). The final return statement returns a new StormSessionViewModel to the view (Controllers/SessionController.cs):

[!code-csharp]

The unit tests include one test for each return scenario in the Session controller Index action:

[!code-csharp]

Moving to the Ideas controller, the app exposes functionality as a web API on the api/ideas route:

  • A list of ideas (IdeaDTO) associated with a brainstorming session is returned by the ForSession method.
  • The Create method adds new ideas to a session.

[!code-csharp]

Avoid returning business domain entities directly via API calls. Domain entities:

  • Often include more data than the client requires.
  • Unnecessarily couple the app's internal domain model with the publicly exposed API.

Mapping between domain entities and the types returned to the client can be performed:

Next, the sample app demonstrates unit tests for the Create and ForSession API methods of the Ideas controller.

The sample app contains two ForSession tests. The first test determines if ForSession returns a xref:Microsoft.AspNetCore.Mvc.NotFoundObjectResult (HTTP Not Found) for an invalid session:

[!code-csharp]

The second ForSession test determines if ForSession returns a list of session ideas (<List<IdeaDTO>>) for a valid session. The checks also examine the first idea to confirm its Name property is correct:

[!code-csharp]

To test the behavior of the Create method when the ModelState is invalid, the sample app adds a model error to the controller as part of the test. Don't try to test model validation or model binding in unit tests—just test the action method's behavior when confronted with an invalid ModelState:

[!code-csharp]

The second test of Create depends on the repository returning null, so the mock repository is configured to return null. There's no need to create a test database (in memory or otherwise) and construct a query that returns this result. The test can be accomplished in a single statement, as the sample code illustrates:

[!code-csharp]

The third Create test, Create_ReturnsNewlyCreatedIdeaForSession, verifies that the repository's UpdateAsync method is called. The mock is called with Verifiable, and the mocked repository's Verify method is called to confirm the verifiable method is executed. It's not the unit test's responsibility to ensure that the UpdateAsync method saved the data—that can be performed with an integration test.

[!code-csharp]

Test ActionResult<T>

ActionResult<T> (xref:Microsoft.AspNetCore.Mvc.ActionResult%601) can return a type deriving from ActionResult or return a specific type.

The sample app includes a method that returns a List<IdeaDTO> for a given session id. If the session id doesn't exist, the controller returns xref:Microsoft.AspNetCore.Mvc.ControllerBase.NotFound*:

[!code-csharp]

Two tests of the ForSessionActionResult controller are included in the ApiIdeasControllerTests.

The first test confirms that the controller returns an ActionResult but not a nonexistent list of ideas for a nonexistent session id:

[!code-csharp]

For a valid session id, the second test confirms that the method returns:

  • An ActionResult with a List<IdeaDTO> type.
  • The ActionResult<T>.Value is a List<IdeaDTO> type.
  • The first item in the list is a valid idea matching the idea stored in the mock session (obtained by calling GetTestSession).

[!code-csharp]

The sample app also includes a method to create a new Idea for a given session. The controller returns:

[!code-csharp]

Three tests of CreateActionResult are included in the ApiIdeasControllerTests.

The first text confirms that a xref:Microsoft.AspNetCore.Mvc.ControllerBase.BadRequest* is returned for an invalid model.

[!code-csharp]

The second test checks that a xref:Microsoft.AspNetCore.Mvc.ControllerBase.NotFound* is returned if the session doesn't exist.

[!code-csharp]

For a valid session id, the final test confirms that:

  • The method returns an ActionResult with a BrainstormSession type.
  • The ActionResult<T>.Result is a xref:Microsoft.AspNetCore.Mvc.CreatedAtActionResult. CreatedAtActionResult is analogous to a 201 Created response with a Location header.
  • The ActionResult<T>.Value is a BrainstormSession type.
  • The mock call to update the session, UpdateAsync(testSession), was invoked. The Verifiable method call is checked by executing mockRepo.Verify() in the assertions.
  • Two Idea objects are returned for the session.
  • The last item (the Idea added by the mock call to UpdateAsync) matches the newIdea added to the session in the test.

[!code-csharp]

:::moniker-end

:::moniker range="< aspnetcore-3.0"

Controllers play a central role in any ASP.NET Core MVC app. As such, you should have confidence that controllers behave as intended. Automated tests can detect errors before the app is deployed to a production environment.

View or download sample code (how to download)

Unit tests of controller logic

Unit tests involve testing a part of an app in isolation from its infrastructure and dependencies. When unit testing controller logic, only the contents of a single action are tested, not the behavior of its dependencies or of the framework itself.

Set up unit tests of controller actions to focus on the controller's behavior. A controller unit test avoids scenarios such as filters, routing, and model binding. Tests that cover the interactions among components that collectively respond to a request are handled by integration tests. For more information on integration tests, see xref:test/integration-tests.

If you're writing custom filters and routes, unit test them in isolation, not as part of tests on a particular controller action.

To demonstrate controller unit tests, review the following controller in the sample app. The Home controller displays a list of brainstorming sessions and allows the creation of new brainstorming sessions with a POST request:

[!code-csharp]

The preceding controller:

The HTTP GET Index method has no looping or branching and only calls one method. The unit test for this action:

  • Mocks the IBrainstormSessionRepository service using the GetTestSessions method. GetTestSessions creates two mock brainstorm sessions with dates and session names.
  • Executes the Index method.
  • Makes assertions on the result returned by the method:

[!code-csharp]

[!code-csharp]

The Home controller's HTTP POST Index method tests verifies that:

An invalid model state is tested by adding errors using xref:Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary.AddModelError* as shown in the first test below:

[!code-csharp]

When ModelState isn't valid, the same ViewResult is returned as for a GET request. The test doesn't attempt to pass in an invalid model. Passing an invalid model isn't a valid approach, since model binding isn't running (although an integration test does use model binding). In this case, model binding isn't tested. These unit tests are only testing the code in the action method.

The second test verifies that when the ModelState is valid:

  • A new BrainstormSession is added (via the repository).
  • The method returns a RedirectToActionResult with the expected properties.

Mocked calls that aren't called are normally ignored, but calling Verifiable at the end of the setup call allows mock validation in the test. This is performed with the call to mockRepo.Verify, which fails the test if the expected method wasn't called.

[!NOTE] The Moq library used in this sample makes it possible to mix verifiable, or "strict", mocks with non-verifiable mocks (also called "loose" mocks or stubs). Learn more about customizing Mock behavior with Moq.

SessionController in the sample app displays information related to a particular brainstorming session. The controller includes logic to deal with invalid id values (there are two return scenarios in the following example to cover these scenarios). The final return statement returns a new StormSessionViewModel to the view (Controllers/SessionController.cs):

[!code-csharp]

The unit tests include one test for each return scenario in the Session controller Index action:

[!code-csharp]

Moving to the Ideas controller, the app exposes functionality as a web API on the api/ideas route:

  • A list of ideas (IdeaDTO) associated with a brainstorming session is returned by the ForSession method.
  • The Create method adds new ideas to a session.

[!code-csharp]

Avoid returning business domain entities directly via API calls. Domain entities:

  • Often include more data than the client requires.
  • Unnecessarily couple the app's internal domain model with the publicly exposed API.

Mapping between domain entities and the types returned to the client can be performed:

Next, the sample app demonstrates unit tests for the Create and ForSession API methods of the Ideas controller.

The sample app contains two ForSession tests. The first test determines if ForSession returns a xref:Microsoft.AspNetCore.Mvc.NotFoundObjectResult (HTTP Not Found) for an invalid session:

[!code-csharp]

The second ForSession test determines if ForSession returns a list of session ideas (<List<IdeaDTO>>) for a valid session. The checks also examine the first idea to confirm its Name property is correct:

[!code-csharp]

To test the behavior of the Create method when the ModelState is invalid, the sample app adds a model error to the controller as part of the test. Don't try to test model validation or model binding in unit tests—just test the action method's behavior when confronted with an invalid ModelState:

[!code-csharp]

The second test of Create depends on the repository returning null, so the mock repository is configured to return null. There's no need to create a test database (in memory or otherwise) and construct a query that returns this result. The test can be accomplished in a single statement, as the sample code illustrates:

[!code-csharp]

The third Create test, Create_ReturnsNewlyCreatedIdeaForSession, verifies that the repository's UpdateAsync method is called. The mock is called with Verifiable, and the mocked repository's Verify method is called to confirm the verifiable method is executed. It's not the unit test's responsibility to ensure that the UpdateAsync method saved the data—that can be performed with an integration test.

[!code-csharp]

Test ActionResult<T>

In ASP.NET Core 2.1 or later, ActionResult<T> (xref:Microsoft.AspNetCore.Mvc.ActionResult%601) enables you to return a type deriving from ActionResult or return a specific type.

The sample app includes a method that returns a List<IdeaDTO> for a given session id. If the session id doesn't exist, the controller returns xref:Microsoft.AspNetCore.Mvc.ControllerBase.NotFound*:

[!code-csharp]

Two tests of the ForSessionActionResult controller are included in the ApiIdeasControllerTests.

The first test confirms that the controller returns an ActionResult but not a nonexistent list of ideas for a nonexistent session id:

[!code-csharp]

For a valid session id, the second test confirms that the method returns:

  • An ActionResult with a List<IdeaDTO> type.
  • The ActionResult<T>.Value is a List<IdeaDTO> type.
  • The first item in the list is a valid idea matching the idea stored in the mock session (obtained by calling GetTestSession).

[!code-csharp]

The sample app also includes a method to create a new Idea for a given session. The controller returns:

[!code-csharp]

Three tests of CreateActionResult are included in the ApiIdeasControllerTests.

The first text confirms that a xref:Microsoft.AspNetCore.Mvc.ControllerBase.BadRequest* is returned for an invalid model.

[!code-csharp]

The second test checks that a xref:Microsoft.AspNetCore.Mvc.ControllerBase.NotFound* is returned if the session doesn't exist.

[!code-csharp]

For a valid session id, the final test confirms that:

  • The method returns an ActionResult with a BrainstormSession type.
  • The ActionResult<T>.Result is a xref:Microsoft.AspNetCore.Mvc.CreatedAtActionResult. CreatedAtActionResult is analogous to a 201 Created response with a Location header.
  • The ActionResult<T>.Value is a BrainstormSession type.
  • The mock call to update the session, UpdateAsync(testSession), was invoked. The Verifiable method call is checked by executing mockRepo.Verify() in the assertions.
  • Two Idea objects are returned for the session.
  • The last item (the Idea added by the mock call to UpdateAsync) matches the newIdea added to the session in the test.

[!code-csharp]

:::moniker-end

Additional resources