Testing controller logic in ASP.NET Core
By Steve Smith
Controllers in ASP.NET MVC apps should be small and focused on user-interface concerns. Large controllers that deal with non-UI concerns are more difficult to test and maintain.
View or download sample from GitHub
Testing controllers
Controllers are a central part of any ASP.NET Core MVC application. As such, you should have confidence they behave as intended for your app. Automated tests can provide you with this confidence and can detect errors before they reach production. It’s important to avoid placing unnecessary responsibilities within your controllers and ensure your tests focus only on controller responsibilities.
Controller logic should be minimal and not be focused on business logic or infrastructure concerns (for example, data access). Test controller logic, not the framework. Test how the controller behaves based on valid or invalid inputs. Test controller responses based on the result of the business operation it performs.
Typical controller responsibilities:
- Verify
ModelState.IsValid
. - Return an error response if
ModelState
is invalid. - Retrieve a business entity from persistence.
- Perform an action on the business entity.
- Save the business entity to persistence.
- Return an appropriate
IActionResult
.
Unit testing
Unit testing involves 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 is tested, not the behavior of its dependencies or of the framework itself. As you unit test your controller actions, make sure you focus only on its behavior. A controller unit test avoids things like filters, routing, or model binding. By focusing on testing just one thing, unit tests are generally simple to write and quick to run. A well-written set of unit tests can be run frequently without much overhead. However, unit tests do not detect issues in the interaction between components, which is the purpose of (xref:)integration testing.
If you’re writing custom filters, routes, etc, you should unit test them, but not as part of your tests on a particular controller action. They should be tested in isolation.
To demonstrate unit testing, review the following controller. It displays a list of brainstorming sessions and allows new brainstorming sessions to be created with a POST:
[!code-csharpMain]
1: using System;
2: using System.ComponentModel.DataAnnotations;
3: using System.Linq;
4: using System.Threading.Tasks;
5: using Microsoft.AspNetCore.Mvc;
6: using TestingControllersSample.Core.Interfaces;
7: using TestingControllersSample.Core.Model;
8: using TestingControllersSample.ViewModels;
9:
10: namespace TestingControllersSample.Controllers
11: {
12: public class HomeController : Controller
13: {
14: private readonly IBrainstormSessionRepository _sessionRepository;
15:
16: public HomeController(IBrainstormSessionRepository sessionRepository)
17: {
18: _sessionRepository = sessionRepository;
19: }
20:
21: public async Task<IActionResult> Index()
22: {
23: var sessionList = await _sessionRepository.ListAsync();
24:
25: var model = sessionList.Select(session => new StormSessionViewModel()
26: {
27: Id = session.Id,
28: DateCreated = session.DateCreated,
29: Name = session.Name,
30: IdeaCount = session.Ideas.Count
31: });
32:
33: return View(model);
34: }
35:
36: public class NewSessionModel
37: {
38: [Required]
39: public string SessionName { get; set; }
40: }
41:
42: [HttpPost]
43: public async Task<IActionResult> Index(NewSessionModel model)
44: {
45: if (!ModelState.IsValid)
46: {
47: return BadRequest(ModelState);
48: }
49: else
50: {
51: await _sessionRepository.AddAsync(new BrainstormSession()
52: {
53: DateCreated = DateTimeOffset.Now,
54: Name = model.SessionName
55: });
56: }
57:
58: return RedirectToAction(actionName: nameof(Index));
59: }
60: }
61: }
The controller is following the explicit dependencies principle, expecting dependency injection to provide it with an instance of IBrainstormSessionRepository
. This makes it fairly easy to test using a mock object framework, like Moq. The HTTP GET Index
method has no looping or branching and only calls one method. To test this Index
method, we need to verify that a ViewResult
is returned, with a ViewModel
from the repository’s List
method.
[!code-csharpMain]
1: using System;
2: using System.Collections.Generic;
3: using System.Linq;
4: using System.Threading.Tasks;
5: using Microsoft.AspNetCore.Mvc;
6: using Moq;
7: using TestingControllersSample.Controllers;
8: using TestingControllersSample.Core.Interfaces;
9: using TestingControllersSample.Core.Model;
10: using TestingControllersSample.ViewModels;
11: using Xunit;
12:
13: namespace TestingControllersSample.Tests.UnitTests
14: {
15: public class HomeControllerTests
16: {
17: [Fact]
18: public async Task Index_ReturnsAViewResult_WithAListOfBrainstormSessions()
19: {
20: // Arrange
21: var mockRepo = new Mock<IBrainstormSessionRepository>();
22: mockRepo.Setup(repo => repo.ListAsync()).Returns(Task.FromResult(GetTestSessions()));
23: var controller = new HomeController(mockRepo.Object);
24:
25: // Act
26: var result = await controller.Index();
27:
28: // Assert
29: var viewResult = Assert.IsType<ViewResult>(result);
30: var model = Assert.IsAssignableFrom<IEnumerable<StormSessionViewModel>>(
31: viewResult.ViewData.Model);
32: Assert.Equal(2, model.Count());
33: }
34:
35: [Fact]
36: public async Task IndexPost_ReturnsBadRequestResult_WhenModelStateIsInvalid()
37: {
38: // Arrange
39: var mockRepo = new Mock<IBrainstormSessionRepository>();
40: mockRepo.Setup(repo => repo.ListAsync()).Returns(Task.FromResult(GetTestSessions()));
41: var controller = new HomeController(mockRepo.Object);
42: controller.ModelState.AddModelError("SessionName", "Required");
43: var newSession = new HomeController.NewSessionModel();
44:
45: // Act
46: var result = await controller.Index(newSession);
47:
48: // Assert
49: var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
50: Assert.IsType<SerializableError>(badRequestResult.Value);
51: }
52:
53: [Fact]
54: public async Task IndexPost_ReturnsARedirectAndAddsSession_WhenModelStateIsValid()
55: {
56: // Arrange
57: var mockRepo = new Mock<IBrainstormSessionRepository>();
58: mockRepo.Setup(repo => repo.AddAsync(It.IsAny<BrainstormSession>()))
59: .Returns(Task.CompletedTask)
60: .Verifiable();
61: var controller = new HomeController(mockRepo.Object);
62: var newSession = new HomeController.NewSessionModel()
63: {
64: SessionName = "Test Name"
65: };
66:
67: // Act
68: var result = await controller.Index(newSession);
69:
70: // Assert
71: var redirectToActionResult = Assert.IsType<RedirectToActionResult>(result);
72: Assert.Null(redirectToActionResult.ControllerName);
73: Assert.Equal("Index", redirectToActionResult.ActionName);
74: mockRepo.Verify();
75: }
76:
77: private List<BrainstormSession> GetTestSessions()
78: {
79: var sessions = new List<BrainstormSession>();
80: sessions.Add(new BrainstormSession()
81: {
82: DateCreated = new DateTime(2016, 7, 2),
83: Id = 1,
84: Name = "Test One"
85: });
86: sessions.Add(new BrainstormSession()
87: {
88: DateCreated = new DateTime(2016, 7, 1),
89: Id = 2,
90: Name = "Test Two"
91: });
92: return sessions;
93: }
94: }
95: }
The HomeController
HTTP POST Index
method (shown above) should verify:
The action method returns a Bad Request
ViewResult
with the appropriate data whenModelState.IsValid
isfalse
The
Add
method on the repository is called and aRedirectToActionResult
is returned with the correct arguments whenModelState.IsValid
is true.
Invalid model state can be tested by adding errors using AddModelError
as shown in the first test below.
[!code-csharpMain]
1: using System;
2: using System.Collections.Generic;
3: using System.Linq;
4: using System.Threading.Tasks;
5: using Microsoft.AspNetCore.Mvc;
6: using Moq;
7: using TestingControllersSample.Controllers;
8: using TestingControllersSample.Core.Interfaces;
9: using TestingControllersSample.Core.Model;
10: using TestingControllersSample.ViewModels;
11: using Xunit;
12:
13: namespace TestingControllersSample.Tests.UnitTests
14: {
15: public class HomeControllerTests
16: {
17: [Fact]
18: public async Task Index_ReturnsAViewResult_WithAListOfBrainstormSessions()
19: {
20: // Arrange
21: var mockRepo = new Mock<IBrainstormSessionRepository>();
22: mockRepo.Setup(repo => repo.ListAsync()).Returns(Task.FromResult(GetTestSessions()));
23: var controller = new HomeController(mockRepo.Object);
24:
25: // Act
26: var result = await controller.Index();
27:
28: // Assert
29: var viewResult = Assert.IsType<ViewResult>(result);
30: var model = Assert.IsAssignableFrom<IEnumerable<StormSessionViewModel>>(
31: viewResult.ViewData.Model);
32: Assert.Equal(2, model.Count());
33: }
34:
35: [Fact]
36: public async Task IndexPost_ReturnsBadRequestResult_WhenModelStateIsInvalid()
37: {
38: // Arrange
39: var mockRepo = new Mock<IBrainstormSessionRepository>();
40: mockRepo.Setup(repo => repo.ListAsync()).Returns(Task.FromResult(GetTestSessions()));
41: var controller = new HomeController(mockRepo.Object);
42: controller.ModelState.AddModelError("SessionName", "Required");
43: var newSession = new HomeController.NewSessionModel();
44:
45: // Act
46: var result = await controller.Index(newSession);
47:
48: // Assert
49: var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
50: Assert.IsType<SerializableError>(badRequestResult.Value);
51: }
52:
53: [Fact]
54: public async Task IndexPost_ReturnsARedirectAndAddsSession_WhenModelStateIsValid()
55: {
56: // Arrange
57: var mockRepo = new Mock<IBrainstormSessionRepository>();
58: mockRepo.Setup(repo => repo.AddAsync(It.IsAny<BrainstormSession>()))
59: .Returns(Task.CompletedTask)
60: .Verifiable();
61: var controller = new HomeController(mockRepo.Object);
62: var newSession = new HomeController.NewSessionModel()
63: {
64: SessionName = "Test Name"
65: };
66:
67: // Act
68: var result = await controller.Index(newSession);
69:
70: // Assert
71: var redirectToActionResult = Assert.IsType<RedirectToActionResult>(result);
72: Assert.Null(redirectToActionResult.ControllerName);
73: Assert.Equal("Index", redirectToActionResult.ActionName);
74: mockRepo.Verify();
75: }
76:
77: private List<BrainstormSession> GetTestSessions()
78: {
79: var sessions = new List<BrainstormSession>();
80: sessions.Add(new BrainstormSession()
81: {
82: DateCreated = new DateTime(2016, 7, 2),
83: Id = 1,
84: Name = "Test One"
85: });
86: sessions.Add(new BrainstormSession()
87: {
88: DateCreated = new DateTime(2016, 7, 1),
89: Id = 2,
90: Name = "Test Two"
91: });
92: return sessions;
93: }
94: }
95: }
The first test confirms when ModelState
is not valid, the same ViewResult
is returned as for a GET
request. Note that the test doesn’t attempt to pass in an invalid model. That wouldn’t work anyway since model binding isn’t running (though an (xref:)integration test would use exercise model binding). In this case, model binding is not being tested. These unit tests are only testing what the code in the action method does.
The second test verifies that when ModelState
is valid, a new BrainstormSession
is added (via the repository), and 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 it to be verified in the test. This is done with the call to mockRepo.Verify
, which will fail the test if the expected method was not called.
[!NOTE] The Moq library used in this sample makes it easy to mix verifiable, or “strict”, mocks with non-verifiable mocks (also called “loose” mocks or stubs). Learn more about customizing Mock behavior with Moq.
Another controller in the app displays information related to a particular brainstorming session. It includes some logic to deal with invalid id values:
[!code-csharpMain]
1: using System.Threading.Tasks;
2: using Microsoft.AspNetCore.Mvc;
3: using TestingControllersSample.Core.Interfaces;
4: using TestingControllersSample.ViewModels;
5:
6: namespace TestingControllersSample.Controllers
7: {
8: public class SessionController : Controller
9: {
10: private readonly IBrainstormSessionRepository _sessionRepository;
11:
12: public SessionController(IBrainstormSessionRepository sessionRepository)
13: {
14: _sessionRepository = sessionRepository;
15: }
16:
17: public async Task<IActionResult> Index(int? id)
18: {
19: if (!id.HasValue)
20: {
21: return RedirectToAction(actionName: nameof(Index), controllerName: "Home");
22: }
23:
24: var session = await _sessionRepository.GetByIdAsync(id.Value);
25: if (session == null)
26: {
27: return Content("Session not found.");
28: }
29:
30: var viewModel = new StormSessionViewModel()
31: {
32: DateCreated = session.DateCreated,
33: Name = session.Name,
34: Id = session.Id
35: };
36:
37: return View(viewModel);
38: }
39: }
40: }
The controller action has three cases to test, one for each return
statement:
[!code-csharpMain]
1: using System;
2: using System.Collections.Generic;
3: using System.Linq;
4: using System.Threading.Tasks;
5: using Microsoft.AspNetCore.Mvc;
6: using Moq;
7: using TestingControllersSample.Controllers;
8: using TestingControllersSample.Core.Interfaces;
9: using TestingControllersSample.Core.Model;
10: using TestingControllersSample.ViewModels;
11: using Xunit;
12:
13: namespace TestingControllersSample.Tests.UnitTests
14: {
15: public class SessionControllerTests
16: {
17: [Fact]
18: public async Task IndexReturnsARedirectToIndexHomeWhenIdIsNull()
19: {
20: // Arrange
21: var controller = new SessionController(sessionRepository: null);
22:
23: // Act
24: var result = await controller.Index(id: null);
25:
26: // Assert
27: var redirectToActionResult = Assert.IsType<RedirectToActionResult>(result);
28: Assert.Equal("Home", redirectToActionResult.ControllerName);
29: Assert.Equal("Index", redirectToActionResult.ActionName);
30: }
31:
32: [Fact]
33: public async Task IndexReturnsContentWithSessionNotFoundWhenSessionNotFound()
34: {
35: // Arrange
36: int testSessionId = 1;
37: var mockRepo = new Mock<IBrainstormSessionRepository>();
38: mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
39: .Returns(Task.FromResult((BrainstormSession)null));
40: var controller = new SessionController(mockRepo.Object);
41:
42: // Act
43: var result = await controller.Index(testSessionId);
44:
45: // Assert
46: var contentResult = Assert.IsType<ContentResult>(result);
47: Assert.Equal("Session not found.", contentResult.Content);
48: }
49:
50: [Fact]
51: public async Task IndexReturnsViewResultWithStormSessionViewModel()
52: {
53: // Arrange
54: int testSessionId = 1;
55: var mockRepo = new Mock<IBrainstormSessionRepository>();
56: mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
57: .Returns(Task.FromResult(GetTestSessions().FirstOrDefault(s => s.Id == testSessionId)));
58: var controller = new SessionController(mockRepo.Object);
59:
60: // Act
61: var result = await controller.Index(testSessionId);
62:
63: // Assert
64: var viewResult = Assert.IsType<ViewResult>(result);
65: var model = Assert.IsType<StormSessionViewModel>(viewResult.ViewData.Model);
66: Assert.Equal("Test One", model.Name);
67: Assert.Equal(2, model.DateCreated.Day);
68: Assert.Equal(testSessionId, model.Id);
69: }
70:
71: private List<BrainstormSession> GetTestSessions()
72: {
73: var sessions = new List<BrainstormSession>();
74: sessions.Add(new BrainstormSession()
75: {
76: DateCreated = new DateTime(2016, 7, 2),
77: Id = 1,
78: Name = "Test One"
79: });
80: sessions.Add(new BrainstormSession()
81: {
82: DateCreated = new DateTime(2016, 7, 1),
83: Id = 2,
84: Name = "Test Two"
85: });
86: return sessions;
87: }
88: }
89: }
The app exposes functionality as a web API (a list of ideas associated with a brainstorming session and a method for adding new ideas to a session):
[!code-csharpMain]
1: using System;
2: using System.Linq;
3: using System.Threading.Tasks;
4: using Microsoft.AspNetCore.Mvc;
5: using TestingControllersSample.ClientModels;
6: using TestingControllersSample.Core.Interfaces;
7: using TestingControllersSample.Core.Model;
8:
9: namespace TestingControllersSample.Api
10: {
11: [Route("api/ideas")]
12: public class IdeasController : Controller
13: {
14: private readonly IBrainstormSessionRepository _sessionRepository;
15:
16: public IdeasController(IBrainstormSessionRepository sessionRepository)
17: {
18: _sessionRepository = sessionRepository;
19: }
20:
21: [HttpGet("forsession/{sessionId}")]
22: public async Task<IActionResult> ForSession(int sessionId)
23: {
24: var session = await _sessionRepository.GetByIdAsync(sessionId);
25: if (session == null)
26: {
27: return NotFound(sessionId);
28: }
29:
30: var result = session.Ideas.Select(idea => new IdeaDTO()
31: {
32: Id = idea.Id,
33: Name = idea.Name,
34: Description = idea.Description,
35: DateCreated = idea.DateCreated
36: }).ToList();
37:
38: return Ok(result);
39: }
40:
41: [HttpPost("create")]
42: public async Task<IActionResult> Create([FromBody]NewIdeaModel model)
43: {
44: if (!ModelState.IsValid)
45: {
46: return BadRequest(ModelState);
47: }
48:
49: var session = await _sessionRepository.GetByIdAsync(model.SessionId);
50: if (session == null)
51: {
52: return NotFound(model.SessionId);
53: }
54:
55: var idea = new Idea()
56: {
57: DateCreated = DateTimeOffset.Now,
58: Description = model.Description,
59: Name = model.Name
60: };
61: session.AddIdea(idea);
62:
63: await _sessionRepository.UpdateAsync(session);
64:
65: return Ok(session);
66: }
67: }
68: }
The ForSession
method returns a list of IdeaDTO
types. Avoid returning your business domain entities directly via API calls, since frequently they include more data than the API client requires, and they unnecessarily couple your app’s internal domain model with the API you expose externally. Mapping between domain entities and the types you will return over the wire can be done manually (using a LINQ Select
as shown here) or using a library like AutoMapper
The unit tests for the Create
and ForSession
API methods:
[!code-csharpMain]
1: using System;
2: using System.Collections.Generic;
3: using System.Linq;
4: using System.Threading.Tasks;
5: using Microsoft.AspNetCore.Mvc;
6: using Moq;
7: using TestingControllersSample.Api;
8: using TestingControllersSample.ClientModels;
9: using TestingControllersSample.Core.Interfaces;
10: using TestingControllersSample.Core.Model;
11: using Xunit;
12:
13: namespace TestingControllersSample.Tests.UnitTests
14: {
15: public class ApiIdeasControllerTests
16: {
17: [Fact]
18: public async Task Create_ReturnsBadRequest_GivenInvalidModel()
19: {
20: // Arrange & Act
21: var mockRepo = new Mock<IBrainstormSessionRepository>();
22: var controller = new IdeasController(mockRepo.Object);
23: controller.ModelState.AddModelError("error","some error");
24:
25: // Act
26: var result = await controller.Create(model: null);
27:
28: // Assert
29: Assert.IsType<BadRequestObjectResult>(result);
30: }
31:
32: [Fact]
33: public async Task Create_ReturnsHttpNotFound_ForInvalidSession()
34: {
35: // Arrange
36: int testSessionId = 123;
37: var mockRepo = new Mock<IBrainstormSessionRepository>();
38: mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
39: .Returns(Task.FromResult((BrainstormSession)null));
40: var controller = new IdeasController(mockRepo.Object);
41:
42: // Act
43: var result = await controller.Create(new NewIdeaModel());
44:
45: // Assert
46: Assert.IsType<NotFoundObjectResult>(result);
47: }
48:
49: [Fact]
50: public async Task Create_ReturnsNewlyCreatedIdeaForSession()
51: {
52: // Arrange
53: int testSessionId = 123;
54: string testName = "test name";
55: string testDescription = "test description";
56: var testSession = GetTestSession();
57: var mockRepo = new Mock<IBrainstormSessionRepository>();
58: mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
59: .Returns(Task.FromResult(testSession));
60: var controller = new IdeasController(mockRepo.Object);
61:
62: var newIdea = new NewIdeaModel()
63: {
64: Description = testDescription,
65: Name = testName,
66: SessionId = testSessionId
67: };
68: mockRepo.Setup(repo => repo.UpdateAsync(testSession))
69: .Returns(Task.CompletedTask)
70: .Verifiable();
71:
72: // Act
73: var result = await controller.Create(newIdea);
74:
75: // Assert
76: var okResult = Assert.IsType<OkObjectResult>(result);
77: var returnSession = Assert.IsType<BrainstormSession>(okResult.Value);
78: mockRepo.Verify();
79: Assert.Equal(2, returnSession.Ideas.Count());
80: Assert.Equal(testName, returnSession.Ideas.LastOrDefault().Name);
81: Assert.Equal(testDescription, returnSession.Ideas.LastOrDefault().Description);
82: }
83:
84: [Fact]
85: public async Task ForSession_ReturnsHttpNotFound_ForInvalidSession()
86: {
87: // Arrange
88: int testSessionId = 123;
89: var mockRepo = new Mock<IBrainstormSessionRepository>();
90: mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
91: .Returns(Task.FromResult((BrainstormSession)null));
92: var controller = new IdeasController(mockRepo.Object);
93:
94: // Act
95: var result = await controller.ForSession(testSessionId);
96:
97: // Assert
98: var notFoundObjectResult = Assert.IsType<NotFoundObjectResult>(result);
99: Assert.Equal(testSessionId, notFoundObjectResult.Value);
100: }
101:
102: [Fact]
103: public async Task ForSession_ReturnsIdeasForSession()
104: {
105: // Arrange
106: int testSessionId = 123;
107: var mockRepo = new Mock<IBrainstormSessionRepository>();
108: mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId)).Returns(Task.FromResult(GetTestSession()));
109: var controller = new IdeasController(mockRepo.Object);
110:
111: // Act
112: var result = await controller.ForSession(testSessionId);
113:
114: // Assert
115: var okResult = Assert.IsType<OkObjectResult>(result);
116: var returnValue = Assert.IsType<List<IdeaDTO>>(okResult.Value);
117: var idea = returnValue.FirstOrDefault();
118: Assert.Equal("One", idea.Name);
119: }
120:
121: private BrainstormSession GetTestSession()
122: {
123: var session = new BrainstormSession()
124: {
125: DateCreated = new DateTime(2016, 7, 2),
126: Id = 1,
127: Name = "Test One"
128: };
129:
130: var idea = new Idea() { Name = "One" };
131: session.AddIdea(idea);
132: return session;
133: }
134: }
135: }
As stated previously, to test the behavior of the method when ModelState
is invalid, add a model error to the controller as part of the test. Don’t try to test model validation or model binding in your unit tests - just test your action method’s behavior when confronted with a particular ModelState
value.
The second test 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 will return this result - it can be done in a single statement as shown.
The last test verifies that the repository’s Update
method is called. As we did previously, the mock is called with Verifiable
and then the mocked repository’s Verify
method is called to confirm the verifiable method was executed. It’s not a unit test responsibility to ensure that the Update
method saved the data; that can be done with an integration test.
Integration testing
Integration testing is done to ensure separate modules within your app work correctly together. Generally, anything you can test with a unit test, you can also test with an integration test, but the reverse isn’t true. However, integration tests tend to be much slower than unit tests. Thus, it’s best to test whatever you can with unit tests, and use integration tests for scenarios that involve multiple collaborators.
Although they may still be useful, mock objects are rarely used in integration tests. In unit testing, mock objects are an effective way to control how collaborators outside of the unit being tested should behave for the purposes of the test. In an integration test, real collaborators are used to confirm the whole subsystem works together correctly.
Application state
One important consideration when performing integration testing is how to set your app’s state. Tests need to run independent of one another, and so each test should start with the app in a known state. If your app doesn’t use a database or have any persistence, this may not be an issue. However, most real-world apps persist their state to some kind of data store, so any modifications made by one test could impact another test unless the data store is reset. Using the built-in TestServer
, it’s very straightforward to host ASP.NET Core apps within our integration tests, but that doesn’t necessarily grant access to the data it will use. If you’re using an actual database, one approach is to have the app connect to a test database, which your tests can access and ensure is reset to a known state before each test executes.
In this sample application, I’m using Entity Framework Core’s InMemoryDatabase support, so I can’t just connect to it from my test project. Instead, I expose an InitializeDatabase
method from the app’s Startup
class, which I call when the app starts up if it’s in the Development
environment. My integration tests automatically benefit from this as long as they set the environment to Development
. I don’t have to worry about resetting the database, since the InMemoryDatabase is reset each time the app restarts.
The Startup
class:
[!code-csharpMain]
1: using System;
2: using System.Linq;
3: using System.Threading.Tasks;
4: using Microsoft.AspNetCore.Builder;
5: using Microsoft.AspNetCore.Hosting;
6: using Microsoft.EntityFrameworkCore;
7: using Microsoft.Extensions.DependencyInjection;
8: using Microsoft.Extensions.Logging;
9: using TestingControllersSample.Core.Interfaces;
10: using TestingControllersSample.Core.Model;
11: using TestingControllersSample.Infrastructure;
12:
13: namespace TestingControllersSample
14: {
15: public class Startup
16: {
17: public void ConfigureServices(IServiceCollection services)
18: {
19: services.AddDbContext<AppDbContext>(
20: optionsBuilder => optionsBuilder.UseInMemoryDatabase("InMemoryDb"));
21:
22: services.AddMvc();
23:
24: services.AddScoped<IBrainstormSessionRepository,
25: EFStormSessionRepository>();
26: }
27:
28: public void Configure(IApplicationBuilder app,
29: IHostingEnvironment env,
30: ILoggerFactory loggerFactory)
31: {
32: if (env.IsDevelopment())
33: {
34: var repository = app.ApplicationServices.GetService<IBrainstormSessionRepository>();
35: InitializeDatabaseAsync(repository).Wait();
36: }
37:
38: app.UseStaticFiles();
39:
40: app.UseMvcWithDefaultRoute();
41: }
42:
43: public async Task InitializeDatabaseAsync(IBrainstormSessionRepository repo)
44: {
45: var sessionList = await repo.ListAsync();
46: if (!sessionList.Any())
47: {
48: await repo.AddAsync(GetTestSession());
49: }
50: }
51:
52: public static BrainstormSession GetTestSession()
53: {
54: var session = new BrainstormSession()
55: {
56: Name = "Test Session 1",
57: DateCreated = new DateTime(2016, 8, 1)
58: };
59: var idea = new Idea()
60: {
61: DateCreated = new DateTime(2016, 8, 1),
62: Description = "Totally awesome idea",
63: Name = "Awesome idea"
64: };
65: session.AddIdea(idea);
66: return session;
67: }
68: }
69: }
You’ll see the GetTestSession
method used frequently in the integration tests below.
Accessing views
Each integration test class configures the TestServer
that will run the ASP.NET Core app. By default, TestServer
hosts the web app in the folder where it’s running - in this case, the test project folder. Thus, when you attempt to test controller actions that return ViewResult
, you may see this error:
The view 'Index' was not found. The following locations were searched:
(list of locations)
To correct this issue, you need to configure the server’s content root, so it can locate the views for the project being tested. This is done by a call to UseContentRoot
in the TestFixture
class, shown below:
[!code-csharpMain]
1: using System;
2: using System.IO;
3: using System.Net.Http;
4: using System.Reflection;
5: using Microsoft.AspNetCore.Hosting;
6: using Microsoft.AspNetCore.Mvc.ApplicationParts;
7: using Microsoft.AspNetCore.Mvc.Controllers;
8: using Microsoft.AspNetCore.Mvc.ViewComponents;
9: using Microsoft.AspNetCore.TestHost;
10: using Microsoft.Extensions.DependencyInjection;
11:
12: namespace TestingControllersSample.Tests.IntegrationTests
13: {
14: /// <summary>
15: /// A test fixture which hosts the target project (project we wish to test) in an in-memory server.
16: /// </summary>
17: /// <typeparam name="TStartup">Target project's startup type</typeparam>
18: public class TestFixture<TStartup> : IDisposable
19: {
20: private readonly TestServer _server;
21:
22: public TestFixture()
23: : this(Path.Combine("src"))
24: {
25: }
26:
27: protected TestFixture(string relativeTargetProjectParentDir)
28: {
29: var startupAssembly = typeof(TStartup).GetTypeInfo().Assembly;
30: var contentRoot = GetProjectPath(relativeTargetProjectParentDir, startupAssembly);
31:
32: var builder = new WebHostBuilder()
33: .UseContentRoot(contentRoot)
34: .ConfigureServices(InitializeServices)
35: .UseEnvironment("Development")
36: .UseStartup(typeof(TStartup));
37:
38: _server = new TestServer(builder);
39:
40: Client = _server.CreateClient();
41: Client.BaseAddress = new Uri("http://localhost");
42: }
43:
44: public HttpClient Client { get; }
45:
46: public void Dispose()
47: {
48: Client.Dispose();
49: _server.Dispose();
50: }
51:
52: protected virtual void InitializeServices(IServiceCollection services)
53: {
54: var startupAssembly = typeof(TStartup).GetTypeInfo().Assembly;
55:
56: // Inject a custom application part manager.
57: // Overrides AddMvcCore() because it uses TryAdd().
58: var manager = new ApplicationPartManager();
59: manager.ApplicationParts.Add(new AssemblyPart(startupAssembly));
60: manager.FeatureProviders.Add(new ControllerFeatureProvider());
61: manager.FeatureProviders.Add(new ViewComponentFeatureProvider());
62:
63: services.AddSingleton(manager);
64: }
65:
66: /// <summary>
67: /// Gets the full path to the target project that we wish to test
68: /// </summary>
69: /// <param name="projectRelativePath">
70: /// The parent directory of the target project.
71: /// e.g. src, samples, test, or test/Websites
72: /// </param>
73: /// <param name="startupAssembly">The target project's assembly.</param>
74: /// <returns>The full path to the target project.</returns>
75: private static string GetProjectPath(string projectRelativePath, Assembly startupAssembly)
76: {
77: // Get name of the target project which we want to test
78: var projectName = startupAssembly.GetName().Name;
79:
80: // Get currently executing test project path
81: var applicationBasePath = System.AppContext.BaseDirectory;
82:
83: // Find the path to the target project
84: var directoryInfo = new DirectoryInfo(applicationBasePath);
85: do
86: {
87: directoryInfo = directoryInfo.Parent;
88:
89: var projectDirectoryInfo = new DirectoryInfo(Path.Combine(directoryInfo.FullName, projectRelativePath));
90: if (projectDirectoryInfo.Exists)
91: {
92: var projectFileInfo = new FileInfo(Path.Combine(projectDirectoryInfo.FullName, projectName, $"{projectName}.csproj"));
93: if (projectFileInfo.Exists)
94: {
95: return Path.Combine(projectDirectoryInfo.FullName, projectName);
96: }
97: }
98: }
99: while (directoryInfo.Parent != null);
100:
101: throw new Exception($"Project root could not be located using the application root {applicationBasePath}.");
102: }
103: }
104: }
The TestFixture
class is responsible for configuring and creating the TestServer
, setting up an HttpClient
to communicate with the TestServer
. Each of the integration tests uses the Client
property to connect to the test server and make a request.
[!code-csharpMain]
1: using System;
2: using System.Collections.Generic;
3: using System.Net;
4: using System.Net.Http;
5: using System.Threading.Tasks;
6: using Xunit;
7:
8: namespace TestingControllersSample.Tests.IntegrationTests
9: {
10: public class HomeControllerTests : IClassFixture<TestFixture<TestingControllersSample.Startup>>
11: {
12: private readonly HttpClient _client;
13:
14: public HomeControllerTests(TestFixture<TestingControllersSample.Startup> fixture)
15: {
16: _client = fixture.Client;
17: }
18:
19: [Fact]
20: public async Task ReturnsInitialListOfBrainstormSessions()
21: {
22: // Arrange - get a session known to exist
23: var testSession = Startup.GetTestSession();
24:
25: // Act
26: var response = await _client.GetAsync("/");
27:
28: // Assert
29: response.EnsureSuccessStatusCode();
30: var responseString = await response.Content.ReadAsStringAsync();
31: Assert.Contains(testSession.Name, responseString);
32: }
33:
34: [Fact]
35: public async Task PostAddsNewBrainstormSession()
36: {
37: // Arrange
38: string testSessionName = Guid.NewGuid().ToString();
39: var data = new Dictionary<string, string>();
40: data.Add("SessionName", testSessionName);
41: var content = new FormUrlEncodedContent(data);
42:
43: // Act
44: var response = await _client.PostAsync("/", content);
45:
46: // Assert
47: Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
48: Assert.Equal("/", response.Headers.Location.ToString());
49: }
50: }
51: }
In the first test above, the responseString
holds the actual rendered HTML from the View, which can be inspected to confirm it contains expected results.
The second test constructs a form POST with a unique session name and POSTs it to the app, then verifies that the expected redirect is returned.
API methods
If your app exposes web APIs, it’s a good idea to have automated tests confirm they execute as expected. The built-in TestServer
makes it easy to test web APIs. If your API methods are using model binding, you should always check ModelState.IsValid
, and integration tests are the right place to confirm that your model validation is working properly.
The following set of tests target the Create
method in the (xref:)IdeasController class shown above:
[!code-csharpMain]
1: using System;
2: using System.Collections.Generic;
3: using System.Linq;
4: using System.Net;
5: using System.Net.Http;
6: using System.Threading.Tasks;
7: using Newtonsoft.Json;
8: using TestingControllersSample.ClientModels;
9: using TestingControllersSample.Core.Model;
10: using Xunit;
11:
12: namespace TestingControllersSample.Tests.IntegrationTests
13: {
14: public class ApiIdeasControllerTests : IClassFixture<TestFixture<TestingControllersSample.Startup>>
15: {
16: internal class NewIdeaDto
17: {
18: public NewIdeaDto(string name, string description, int sessionId)
19: {
20: Name = name;
21: Description = description;
22: SessionId = sessionId;
23: }
24:
25: public string Name { get; set; }
26: public string Description { get; set; }
27: public int SessionId { get; set; }
28: }
29:
30: private readonly HttpClient _client;
31:
32: public ApiIdeasControllerTests(TestFixture<TestingControllersSample.Startup> fixture)
33: {
34: _client = fixture.Client;
35: }
36:
37: [Fact]
38: public async Task CreatePostReturnsBadRequestForMissingNameValue()
39: {
40: // Arrange
41: var newIdea = new NewIdeaDto("", "Description", 1);
42:
43: // Act
44: var response = await _client.PostAsJsonAsync("/api/ideas/create", newIdea);
45:
46: // Assert
47: Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
48: }
49:
50: [Fact]
51: public async Task CreatePostReturnsBadRequestForMissingDescriptionValue()
52: {
53: // Arrange
54: var newIdea = new NewIdeaDto("Name", "", 1);
55:
56: // Act
57: var response = await _client.PostAsJsonAsync("/api/ideas/create", newIdea);
58:
59: // Assert
60: Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
61: }
62:
63: [Fact]
64: public async Task CreatePostReturnsBadRequestForSessionIdValueTooSmall()
65: {
66: // Arrange
67: var newIdea = new NewIdeaDto("Name", "Description", 0);
68:
69: // Act
70: var response = await _client.PostAsJsonAsync("/api/ideas/create", newIdea);
71:
72: // Assert
73: Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
74: }
75:
76: [Fact]
77: public async Task CreatePostReturnsBadRequestForSessionIdValueTooLarge()
78: {
79: // Arrange
80: var newIdea = new NewIdeaDto("Name", "Description", 1000001);
81:
82: // Act
83: var response = await _client.PostAsJsonAsync("/api/ideas/create", newIdea);
84:
85: // Assert
86: Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
87: }
88:
89: [Fact]
90: public async Task CreatePostReturnsNotFoundForInvalidSession()
91: {
92: // Arrange
93: var newIdea = new NewIdeaDto("Name", "Description", 123);
94:
95: // Act
96: var response = await _client.PostAsJsonAsync("/api/ideas/create", newIdea);
97:
98: // Assert
99: Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
100: }
101:
102: [Fact]
103: public async Task CreatePostReturnsCreatedIdeaWithCorrectInputs()
104: {
105: // Arrange
106: var testIdeaName = Guid.NewGuid().ToString();
107: var newIdea = new NewIdeaDto(testIdeaName, "Description", 1);
108:
109: // Act
110: var response = await _client.PostAsJsonAsync("/api/ideas/create", newIdea);
111:
112: // Assert
113: response.EnsureSuccessStatusCode();
114: var returnedSession = await response.Content.ReadAsJsonAsync<BrainstormSession>();
115: Assert.Equal(2, returnedSession.Ideas.Count);
116: Assert.Contains(testIdeaName, returnedSession.Ideas.Select(i => i.Name).ToList());
117: }
118:
119: [Fact]
120: public async Task ForSessionReturnsNotFoundForBadSessionId()
121: {
122: // Arrange & Act
123: var response = await _client.GetAsync("/api/ideas/forsession/500");
124:
125: // Assert
126: Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
127: }
128:
129: [Fact]
130: public async Task ForSessionReturnsIdeasForValidSessionId()
131: {
132: // Arrange
133: var testSession = Startup.GetTestSession();
134:
135: // Act
136: var response = await _client.GetAsync("/api/ideas/forsession/1");
137:
138: // Assert
139: response.EnsureSuccessStatusCode();
140: var ideaList = JsonConvert.DeserializeObject<List<IdeaDTO>>(
141: await response.Content.ReadAsStringAsync());
142: var firstIdea = ideaList.First();
143: Assert.Equal(testSession.Ideas.First().Name, firstIdea.Name);
144: }
145: }
146: }
Unlike integration tests of actions that returns HTML views, web API methods that return results can usually be deserialized as strongly typed objects, as the last test above shows. In this case, the test deserializes the result to a BrainstormSession
instance, and confirms that the idea was correctly added to its collection of ideas.
You’ll find additional examples of integration tests in this article’s sample project.
|