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:
The preceding controller:
- Follows the Explicit Dependencies Principle.
- Expects dependency injection (DI) to provide an instance of
IBrainstormSessionRepository
. - Can be tested with a mocked
IBrainstormSessionRepository
service using a mock object framework, such as Moq. A mocked object is a fabricated object with a predetermined set of property and method behaviors used for testing. For more information, see Introduction to integration tests.
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 theGetTestSessions
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:
- A xref:Microsoft.AspNetCore.Mvc.ViewResult is returned.
- The ViewDataDictionary.Model is a
StormSessionViewModel
. - There are two brainstorming sessions stored in the
ViewDataDictionary.Model
.
The Home controller's HTTP POST Index
method tests verifies that:
- When ModelState.IsValid is
false
, the action method returns a 400 Bad Request xref:Microsoft.AspNetCore.Mvc.ViewResult with the appropriate data. - When
ModelState.IsValid
istrue
:- The
Add
method on the repository is called. - A xref:Microsoft.AspNetCore.Mvc.RedirectToActionResult is returned with the correct arguments.
- The
An invalid model state is tested by adding errors using xref:Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary.AddModelError* as shown in the first test below:
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
):
The unit tests include one test for each return
scenario in the Session controller Index
action:
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 theForSession
method. - The
Create
method adds new ideas to a session.
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:
- Manually with a LINQ
Select
, as the sample app uses. For more information, see LINQ (Language Integrated Query). - Automatically with a library, such as AutoMapper.
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:
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:
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
:
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:
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.
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*:
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
:
- The
ActionResult
type isActionResult<List<IdeaDTO>>
. - The xref:Microsoft.AspNetCore.Mvc.ActionResult`1.Result* is a xref:Microsoft.AspNetCore.Mvc.NotFoundObjectResult.
For a valid session id
, the second test confirms that the method returns:
- An
ActionResult
with aList<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
).
The sample app also includes a method to create a new Idea
for a given session. The controller returns:
- xref:Microsoft.AspNetCore.Mvc.ControllerBase.BadRequest* for an invalid model.
- xref:Microsoft.AspNetCore.Mvc.ControllerBase.NotFound* if the session doesn't exist.
- xref:Microsoft.AspNetCore.Mvc.ControllerBase.CreatedAtAction* when the session is updated with the new idea.
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.
The second test checks that a xref:Microsoft.AspNetCore.Mvc.ControllerBase.NotFound* is returned if the session doesn't exist.
For a valid session id
, the final test confirms that:
- The method returns an
ActionResult
with aBrainstormSession
type. - The ActionResult<T>.Result is a xref:Microsoft.AspNetCore.Mvc.CreatedAtActionResult.
CreatedAtActionResult
is analogous to a 201 Created response with aLocation
header. - The ActionResult<T>.Value is a
BrainstormSession
type. - The mock call to update the session,
UpdateAsync(testSession)
, was invoked. TheVerifiable
method call is checked by executingmockRepo.Verify()
in the assertions. - Two
Idea
objects are returned for the session. - The last item (the
Idea
added by the mock call toUpdateAsync
) matches thenewIdea
added to the session in the test.
:::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:
The preceding controller:
- Follows the Explicit Dependencies Principle.
- Expects dependency injection (DI) to provide an instance of
IBrainstormSessionRepository
. - Can be tested with a mocked
IBrainstormSessionRepository
service using a mock object framework, such as Moq. A mocked object is a fabricated object with a predetermined set of property and method behaviors used for testing. For more information, see Introduction to integration tests.
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 theGetTestSessions
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:
- A xref:Microsoft.AspNetCore.Mvc.ViewResult is returned.
- The ViewDataDictionary.Model is a
StormSessionViewModel
. - There are two brainstorming sessions stored in the
ViewDataDictionary.Model
.
The Home controller's HTTP POST Index
method tests verifies that:
- When ModelState.IsValid is
false
, the action method returns a 400 Bad Request xref:Microsoft.AspNetCore.Mvc.ViewResult with the appropriate data. - When
ModelState.IsValid
istrue
:- The
Add
method on the repository is called. - A xref:Microsoft.AspNetCore.Mvc.RedirectToActionResult is returned with the correct arguments.
- The
An invalid model state is tested by adding errors using xref:Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary.AddModelError* as shown in the first test below:
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
):
The unit tests include one test for each return
scenario in the Session controller Index
action:
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 theForSession
method. - The
Create
method adds new ideas to a session.
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:
- Manually with a LINQ
Select
, as the sample app uses. For more information, see LINQ (Language Integrated Query). - Automatically with a library, such as AutoMapper.
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:
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:
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
:
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:
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.
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*:
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
:
- The
ActionResult
type isActionResult<List<IdeaDTO>>
. - The xref:Microsoft.AspNetCore.Mvc.ActionResult`1.Result* is a xref:Microsoft.AspNetCore.Mvc.NotFoundObjectResult.
For a valid session id
, the second test confirms that the method returns:
- An
ActionResult
with aList<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
).
The sample app also includes a method to create a new Idea
for a given session. The controller returns:
- xref:Microsoft.AspNetCore.Mvc.ControllerBase.BadRequest* for an invalid model.
- xref:Microsoft.AspNetCore.Mvc.ControllerBase.NotFound* if the session doesn't exist.
- xref:Microsoft.AspNetCore.Mvc.ControllerBase.CreatedAtAction* when the session is updated with the new idea.
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.
The second test checks that a xref:Microsoft.AspNetCore.Mvc.ControllerBase.NotFound* is returned if the session doesn't exist.
For a valid session id
, the final test confirms that:
- The method returns an
ActionResult
with aBrainstormSession
type. - The ActionResult<T>.Result is a xref:Microsoft.AspNetCore.Mvc.CreatedAtActionResult.
CreatedAtActionResult
is analogous to a 201 Created response with aLocation
header. - The ActionResult<T>.Value is a
BrainstormSession
type. - The mock call to update the session,
UpdateAsync(testSession)
, was invoked. TheVerifiable
method call is checked by executingmockRepo.Verify()
in the assertions. - Two
Idea
objects are returned for the session. - The last item (the
Idea
added by the mock call toUpdateAsync
) matches thenewIdea
added to the session in the test.
:::moniker-end
Additional resources
- xref:test/integration-tests
- Create and run unit tests with Visual Studio
- MyTested.AspNetCore.Mvc - Fluent Testing Library for ASP.NET Core MVC: Strongly-typed unit testing library, providing a fluent interface for testing MVC and web API apps. (Not maintained or supported by Microsoft.)
- JustMockLite: A mocking framework for .NET developers. (Not maintained or supported by Microsoft.)