ASP.NET Core 中的单元测试控制器逻辑
作者:Steve Smith
单元测试涉及通过基础结构和依赖项单独测试应用的一部分。 单元测试控制器逻辑时,仅测试单个操作的内容,不测试其依赖项或框架自身的行为。
单元测试控制器
将控制器操作的单元测试设置为专注于控制器的行为。 控制器单元测试将避开筛选器、路由或模型绑定等方案。 涵盖共同响应请求的组件之间的交互的测试由集成测试处理。 有关集成测试的详细信息,请参阅ASP.NET Core 中的集成测试。
如果编写自定义筛选器和路由,应对其单独进行单元测试,而不是在测试特定控制器操作时进行。
要演示控制器单元测试,请查看以下示例应用中的控制器。
主控制器显示集体讨论会话列表并允许使用 POST 请求创建新的集体讨论会话:
public class HomeController : Controller { private readonly IBrainstormSessionRepository _sessionRepository; public HomeController(IBrainstormSessionRepository sessionRepository) { _sessionRepository = sessionRepository; } public async Task<IActionResult> Index() { var sessionList = await _sessionRepository.ListAsync(); var model = sessionList.Select(session => new StormSessionViewModel() { Id = session.Id, DateCreated = session.DateCreated, Name = session.Name, IdeaCount = session.Ideas.Count }); return View(model); } public class NewSessionModel { [Required] public string SessionName { get; set; } } [HttpPost] public async Task<IActionResult> Index(NewSessionModel model) { if (!ModelState.IsValid) { return BadRequest(ModelState); } else { await _sessionRepository.AddAsync(new BrainstormSession() { DateCreated = DateTimeOffset.Now, Name = model.SessionName }); } return RedirectToAction(actionName: nameof(Index)); } }
前面的控制器:
- 遵循显式依赖关系原则。
- 期望依赖关系注入 (DI) 提供
IBrainstormSessionRepository
的实例。 - 可以通过使用 mock 对象框架(如 Moq)的模拟
IBrainstormSessionRepository
服务进行测试。 模拟对象是由一组预先确定的用于测试的属性和方法行为的对象。 有关详细信息,请参阅集成测试简介。
HTTP GET Index
方法没有循环或分支,且仅调用一个方法。 此操作的单元测试:
- 使用
GetTestSessions
方法模拟IBrainstormSessionRepository
服务。GetTestSessions
使用日期和会话名称创建两个 mock 集体讨论会话。 - 执行
Index
方法。 - 根据该方法返回的结果进行断言:
- 将返回 ViewResult。
- ViewDataDictionary.Model 是
StormSessionViewModel
。 - 有两个集体讨论会话存储在
ViewDataDictionary.Model
中。
[Fact] public async Task Index_ReturnsAViewResult_WithAListOfBrainstormSessions() { // Arrange var mockRepo = new Mock<IBrainstormSessionRepository>(); mockRepo.Setup(repo => repo.ListAsync()) .ReturnsAsync(GetTestSessions()); var controller = new HomeController(mockRepo.Object); // Act var result = await controller.Index(); // Assert var viewResult = Assert.IsType<ViewResult>(result); var model = Assert.IsAssignableFrom<IEnumerable<StormSessionViewModel>>( viewResult.ViewData.Model); Assert.Equal(2, model.Count()); }
private List<BrainstormSession> GetTestSessions() { var sessions = new List<BrainstormSession>(); sessions.Add(new BrainstormSession() { DateCreated = new DateTime(2016, 7, 2), Id = 1, Name = "Test One" }); sessions.Add(new BrainstormSession() { DateCreated = new DateTime(2016, 7, 1), Id = 2, Name = "Test Two" }); return sessions; }
主控制器的 HTTP POST Index
方法测试验证:
- 当 ModelState.IsValid 为
false
时,操作方法将返回有相应数据的 400 错误请求 ViewResult。 - 当
ModelState.IsValid
为true
时:- 将调用存储库上的
Add
方法。 - 将返回有正确参数的 RedirectToActionResult。
- 将调用存储库上的
通过使用 AddModelError 添加错误来测试无效模型状态,如下第一个测试所示:
[Fact] public async Task IndexPost_ReturnsBadRequestResult_WhenModelStateIsInvalid() { // Arrange var mockRepo = new Mock<IBrainstormSessionRepository>(); mockRepo.Setup(repo => repo.ListAsync()) .ReturnsAsync(GetTestSessions()); var controller = new HomeController(mockRepo.Object); controller.ModelState.AddModelError("SessionName", "Required"); var newSession = new HomeController.NewSessionModel(); // Act var result = await controller.Index(newSession); // Assert var badRequestResult = Assert.IsType<BadRequestObjectResult>(result); Assert.IsType<SerializableError>(badRequestResult.Value); } [Fact] public async Task IndexPost_ReturnsARedirectAndAddsSession_WhenModelStateIsValid() { // Arrange var mockRepo = new Mock<IBrainstormSessionRepository>(); mockRepo.Setup(repo => repo.AddAsync(It.IsAny<BrainstormSession>())) .Returns(Task.CompletedTask) .Verifiable(); var controller = new HomeController(mockRepo.Object); var newSession = new HomeController.NewSessionModel() { SessionName = "Test Name" }; // Act var result = await controller.Index(newSession); // Assert var redirectToActionResult = Assert.IsType<RedirectToActionResult>(result); Assert.Null(redirectToActionResult.ControllerName); Assert.Equal("Index", redirectToActionResult.ActionName); mockRepo.Verify(); }
当 ModelState 无效时,将返回与 GET 请求相同的 ViewResult
。 测试不会尝试传入无效模型。 传入无效模型不是有效的方法,因为不会运行模型绑定(尽管集成测试使用模型绑定)。 在本例中,不测试模型绑定。 这些单元测试仅测试操作方法中的代码。
第二个测试验证 ModelState
有效时的情况:
- 已通过存储库添加新的
BrainstormSession
。 - 该方法将返回带有所需属性的
RedirectToActionResult
。
通常会忽略未调用的模拟调用,但在设置调用末尾调用 Verifiable
就可以在测试中进行 mock 验证。 这通过对 mockRepo.Verify
的调用来完成,进行这种调用时,如果未调用所需方法,则测试将失败。
备注
通过此示例中使用的 Moq 库,可以混合可验证(或称“严格”)mock 和非可验证 mock(也称为“宽松”mock 或存根)。 详细了解使用 Moq 自定义 Mock 行为。
示例应用中的 SessionController 显示与特定集体讨论会话相关的信息。 该控制器包含用于处理无效 id
值的逻辑(以下示例中有两个 return
方案可用来应对这些情况)。 最后的 return
语句向视图 (Controllers/SessionController.cs) 返回一个新的 StormSessionViewModel
:
public class SessionController : Controller { private readonly IBrainstormSessionRepository _sessionRepository; public SessionController(IBrainstormSessionRepository sessionRepository) { _sessionRepository = sessionRepository; } public async Task<IActionResult> Index(int? id) { if (!id.HasValue) { return RedirectToAction(actionName: nameof(Index), controllerName: "Home"); } var session = await _sessionRepository.GetByIdAsync(id.Value); if (session == null) { return Content("Session not found."); } var viewModel = new StormSessionViewModel() { DateCreated = session.DateCreated, Name = session.Name, Id = session.Id }; return View(viewModel); } }
单元测试包括对会话控制器 Index
操作中的每个 return
方案执行一个测试:
[Fact] public async Task IndexReturnsARedirectToIndexHomeWhenIdIsNull() { // Arrange var controller = new SessionController(sessionRepository: null); // Act var result = await controller.Index(id: null); // Assert var redirectToActionResult = Assert.IsType<RedirectToActionResult>(result); Assert.Equal("Home", redirectToActionResult.ControllerName); Assert.Equal("Index", redirectToActionResult.ActionName); } [Fact] public async Task IndexReturnsContentWithSessionNotFoundWhenSessionNotFound() { // Arrange int testSessionId = 1; var mockRepo = new Mock<IBrainstormSessionRepository>(); mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId)) .ReturnsAsync((BrainstormSession)null); var controller = new SessionController(mockRepo.Object); // Act var result = await controller.Index(testSessionId); // Assert var contentResult = Assert.IsType<ContentResult>(result); Assert.Equal("Session not found.", contentResult.Content); } [Fact] public async Task IndexReturnsViewResultWithStormSessionViewModel() { // Arrange int testSessionId = 1; var mockRepo = new Mock<IBrainstormSessionRepository>(); mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId)) .ReturnsAsync(GetTestSessions().FirstOrDefault( s => s.Id == testSessionId)); var controller = new SessionController(mockRepo.Object); // Act var result = await controller.Index(testSessionId); // Assert var viewResult = Assert.IsType<ViewResult>(result); var model = Assert.IsType<StormSessionViewModel>( viewResult.ViewData.Model); Assert.Equal("Test One", model.Name); Assert.Equal(2, model.DateCreated.Day); Assert.Equal(testSessionId, model.Id); }
移动到想法控制器,应用会将功能公开为 api/ideas
路由上的 Web API:
ForSession
方法将返回与集体讨论会话关联的想法列表 (IdeaDTO
)。Create
方法会向会话中添加新想法。
[HttpGet("forsession/{sessionId}")] public async Task<IActionResult> ForSession(int sessionId) { var session = await _sessionRepository.GetByIdAsync(sessionId); if (session == null) { return NotFound(sessionId); } var result = session.Ideas.Select(idea => new IdeaDTO() { Id = idea.Id, Name = idea.Name, Description = idea.Description, DateCreated = idea.DateCreated }).ToList(); return Ok(result); } [HttpPost("create")] public async Task<IActionResult> Create([FromBody]NewIdeaModel model) { if (!ModelState.IsValid) { return BadRequest(ModelState); } var session = await _sessionRepository.GetByIdAsync(model.SessionId); if (session == null) { return NotFound(model.SessionId); } var idea = new Idea() { DateCreated = DateTimeOffset.Now, Description = model.Description, Name = model.Name }; session.AddIdea(idea); await _sessionRepository.UpdateAsync(session); return Ok(session); }
避免直接通过 API 调用返回企业域实体。 域实体:
- 包含的数据通常比客户端所需的数据更多。
- 无需将应用的内部域模型与公开的 API 结合。
可以执行域实体与返回到客户端的类型之间的映射:
- 手动执行 LINQ
Select
,如同示例应用所用的那样。 有关详细信息,请参阅 LINQ(语言集成查询)。 - 自动生成库,如 AutoMapper。
接着,示例应用会演示想法控制器的 Create
和 ForSession
API 方法的单元测试。
示例应用包含两个 ForSession
测试。 第一个测试可确定 ForSession
是否返回无效会话的 NotFoundObjectResult(找不到 HTTP):
[Fact] public async Task ForSession_ReturnsHttpNotFound_ForInvalidSession() { // Arrange int testSessionId = 123; var mockRepo = new Mock<IBrainstormSessionRepository>(); mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId)) .ReturnsAsync((BrainstormSession)null); var controller = new IdeasController(mockRepo.Object); // Act var result = await controller.ForSession(testSessionId); // Assert var notFoundObjectResult = Assert.IsType<NotFoundObjectResult>(result); Assert.Equal(testSessionId, notFoundObjectResult.Value); }
第二个 ForSession
测试可确定 ForSession
是否返回有效会话的会话想法列表 (<List<IdeaDTO>>
)。 这些测试还会检查第一个想法,以确认其 Name
属性正确:
[Fact] public async Task ForSession_ReturnsIdeasForSession() { // Arrange int testSessionId = 123; var mockRepo = new Mock<IBrainstormSessionRepository>(); mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId)) .ReturnsAsync(GetTestSession()); var controller = new IdeasController(mockRepo.Object); // Act var result = await controller.ForSession(testSessionId); // Assert var okResult = Assert.IsType<OkObjectResult>(result); var returnValue = Assert.IsType<List<IdeaDTO>>(okResult.Value); var idea = returnValue.FirstOrDefault(); Assert.Equal("One", idea.Name); }
若要测试 Create
方法在 ModelState
无效时的行为,示例应用会在测试中将模型错误添加到控制器。 请勿在单元测试中尝试测试模型有效性或模型绑定—仅测试操作方法在遇到无效 ModelState
时的行为:
[Fact] public async Task Create_ReturnsBadRequest_GivenInvalidModel() { // Arrange & Act var mockRepo = new Mock<IBrainstormSessionRepository>(); var controller = new IdeasController(mockRepo.Object); controller.ModelState.AddModelError("error", "some error"); // Act var result = await controller.Create(model: null); // Assert Assert.IsType<BadRequestObjectResult>(result); }
Create
的第二个测试依赖存储库返回 null
,所以 mock 存储库配置为返回 null
。 无需创建测试数据库(在内存中或其他位置)并构建将返回此结果的查询。 该测试可以在单个语句中完成,如示例代码所示:
[Fact] public async Task Create_ReturnsHttpNotFound_ForInvalidSession() { // Arrange int testSessionId = 123; var mockRepo = new Mock<IBrainstormSessionRepository>(); mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId)) .ReturnsAsync((BrainstormSession)null); var controller = new IdeasController(mockRepo.Object); // Act var result = await controller.Create(new NewIdeaModel()); // Assert Assert.IsType<NotFoundObjectResult>(result); }
第三个 Create
测试 Create_ReturnsNewlyCreatedIdeaForSession
验证调用了存储库的 UpdateAsync
方法。 使用 Verifiable
调用 mock,然后调用模拟存储库的 Verify
方法,以确认执行了可验证的方法。 确保 UpdateAsync
方法保存了数据不是单元测试的职责—这可以通过集成测试完成。
[Fact] public async Task Create_ReturnsNewlyCreatedIdeaForSession() { // Arrange int testSessionId = 123; string testName = "test name"; string testDescription = "test description"; var testSession = GetTestSession(); var mockRepo = new Mock<IBrainstormSessionRepository>(); mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId)) .ReturnsAsync(testSession); var controller = new IdeasController(mockRepo.Object); var newIdea = new NewIdeaModel() { Description = testDescription, Name = testName, SessionId = testSessionId }; mockRepo.Setup(repo => repo.UpdateAsync(testSession)) .Returns(Task.CompletedTask) .Verifiable(); // Act var result = await controller.Create(newIdea); // Assert var okResult = Assert.IsType<OkObjectResult>(result); var returnSession = Assert.IsType<BrainstormSession>(okResult.Value); mockRepo.Verify(); Assert.Equal(2, returnSession.Ideas.Count()); Assert.Equal(testName, returnSession.Ideas.LastOrDefault().Name); Assert.Equal(testDescription, returnSession.Ideas.LastOrDefault().Description); }
测试 ActionResult<T>
在 ASP.NET Core 2.1 或更高版本中,ActionResult<T> (ActionResult<TValue>) 支持返回从 ActionResult
派生的类型或返回特定类型。
示例应用包含将返回给定会话 id
的 List<IdeaDTO>
的方法。 如果会话 id
不存在,控制器将返回 NotFound:
[HttpGet("forsessionactionresult/{sessionId}")] [ProducesResponseType(200)] [ProducesResponseType(404)] public async Task<ActionResult<List<IdeaDTO>>> ForSessionActionResult(int sessionId) { var session = await _sessionRepository.GetByIdAsync(sessionId); if (session == null) { return NotFound(sessionId); } var result = session.Ideas.Select(idea => new IdeaDTO() { Id = idea.Id, Name = idea.Name, Description = idea.Description, DateCreated = idea.DateCreated }).ToList(); return result; }
ApiIdeasControllerTests
中包含 ForSessionActionResult
控制器的两个测试。
第一个测试可确认控制器将返回 ActionResult
,而不是不存在会话 id
的不存在想法列表:
ActionResult
类型为ActionResult<List<IdeaDTO>>
。- Result 为 NotFoundObjectResult。
[Fact] public async Task ForSessionActionResult_ReturnsNotFoundObjectResultForNonexistentSession() { // Arrange var mockRepo = new Mock<IBrainstormSessionRepository>(); var controller = new IdeasController(mockRepo.Object); var nonExistentSessionId = 999; // Act var result = await controller.ForSessionActionResult(nonExistentSessionId); // Assert var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result); Assert.IsType<NotFoundObjectResult>(actionResult.Result); }
对于有效会话 id
,第二个测试可确认该方法将返回:
- 类型为
List<IdeaDTO>
的ActionResult
。 - ActionResult<T>.Value 是
List<IdeaDTO>
类型。 - 列表中的第一项是与 mock 会话中存储的想法匹配的有效想法(通过调用
GetTestSession
获取)。
[Fact] public async Task ForSessionActionResult_ReturnsIdeasForSession() { // Arrange int testSessionId = 123; var mockRepo = new Mock<IBrainstormSessionRepository>(); mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId)) .ReturnsAsync(GetTestSession()); var controller = new IdeasController(mockRepo.Object); // Act var result = await controller.ForSessionActionResult(testSessionId); // Assert var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result); var returnValue = Assert.IsType<List<IdeaDTO>>(actionResult.Value); var idea = returnValue.FirstOrDefault(); Assert.Equal("One", idea.Name); }
示例应用还包含用于为给定会话创建新的 Idea
的方法。 控制器将返回:
- BadRequest(对于无效模型)。
- NotFound(如果会话不存在)。
- CreatedAtAction(当使用新想法更新会话时)。
[HttpPost("createactionresult")] [ProducesResponseType(201)] [ProducesResponseType(400)] [ProducesResponseType(404)] public async Task<ActionResult<BrainstormSession>> CreateActionResult([FromBody]NewIdeaModel model) { if (!ModelState.IsValid) { return BadRequest(ModelState); } var session = await _sessionRepository.GetByIdAsync(model.SessionId); if (session == null) { return NotFound(model.SessionId); } var idea = new Idea() { DateCreated = DateTimeOffset.Now, Description = model.Description, Name = model.Name }; session.AddIdea(idea); await _sessionRepository.UpdateAsync(session); return CreatedAtAction(nameof(CreateActionResult), new { id = session.Id }, session); }
ApiIdeasControllerTests
中包含 CreateActionResult
的三个测试。
第一个测试可确认将返回 BadRequest(对于无效模型)。
[Fact] public async Task CreateActionResult_ReturnsBadRequest_GivenInvalidModel() { // Arrange & Act var mockRepo = new Mock<IBrainstormSessionRepository>(); var controller = new IdeasController(mockRepo.Object); controller.ModelState.AddModelError("error", "some error"); // Act var result = await controller.CreateActionResult(model: null); // Assert var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result); Assert.IsType<BadRequestObjectResult>(actionResult.Result); }
第二个测试可确认将返回 NotFound(如果会话不存在)。
[Fact] public async Task CreateActionResult_ReturnsNotFoundObjectResultForNonexistentSession() { // Arrange var nonExistentSessionId = 999; string testName = "test name"; string testDescription = "test description"; var mockRepo = new Mock<IBrainstormSessionRepository>(); var controller = new IdeasController(mockRepo.Object); var newIdea = new NewIdeaModel() { Description = testDescription, Name = testName, SessionId = nonExistentSessionId }; // Act var result = await controller.CreateActionResult(newIdea); // Assert var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result); Assert.IsType<NotFoundObjectResult>(actionResult.Result); }
对于有效会话 id
,最后一个测试可确认:
- 该方法将返回类型为
BrainstormSession
的ActionResult
。 - ActionResult<T>.Result 是 CreatedAtActionResult。
CreatedAtActionResult
类似于包含Location
标头的 201 Created 响应。 - ActionResult<T>.Value 是
BrainstormSession
类型。 - 调用了用于更新会话
UpdateAsync(testSession)
的 mock 调用。 通过执行断言中的mockRepo.Verify()
来检查Verifiable
方法调用。 - 将返回该会话的两个
Idea
对象。 - 最后一项(通过对
UpdateAsync
的 mock 调用而添加的Idea
)与添加到测试中的会话的newIdea
匹配。
[Fact] public async Task CreateActionResult_ReturnsNewlyCreatedIdeaForSession() { // Arrange int testSessionId = 123; string testName = "test name"; string testDescription = "test description"; var testSession = GetTestSession(); var mockRepo = new Mock<IBrainstormSessionRepository>(); mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId)) .ReturnsAsync(testSession); var controller = new IdeasController(mockRepo.Object); var newIdea = new NewIdeaModel() { Description = testDescription, Name = testName, SessionId = testSessionId }; mockRepo.Setup(repo => repo.UpdateAsync(testSession)) .Returns(Task.CompletedTask) .Verifiable(); // Act var result = await controller.CreateActionResult(newIdea); // Assert var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result); var createdAtActionResult = Assert.IsType<CreatedAtActionResult>(actionResult.Result); var returnValue = Assert.IsType<BrainstormSession>(createdAtActionResult.Value); mockRepo.Verify(); Assert.Equal(2, returnValue.Ideas.Count()); Assert.Equal(testName, returnValue.Ideas.LastOrDefault().Name); Assert.Equal(testDescription, returnValue.Ideas.LastOrDefault().Description); }
控制器在任何 ASP.NET Core MVC 应用中起着核心作用。 因此,应该对控制器表现达到预期怀有信心。 在将应用部署到生产环境之前,自动测试可以检测到错误。
控制器逻辑的单元测试
单元测试涉及通过基础结构和依赖项单独测试应用的一部分。 单元测试控制器逻辑时,仅测试单个操作的内容,不测试其依赖项或框架自身的行为。
将控制器操作的单元测试设置为专注于控制器的行为。 控制器单元测试将避开筛选器、路由或模型绑定等方案。 涵盖共同响应请求的组件之间的交互的测试由集成测试处理。 有关集成测试的详细信息,请参阅ASP.NET Core 中的集成测试。
如果编写自定义筛选器和路由,应对其单独进行单元测试,而不是在测试特定控制器操作时进行。
要演示控制器单元测试,请查看以下示例应用中的控制器。 主控制器显示集体讨论会话列表并允许使用 POST 请求创建新的集体讨论会话:
public class HomeController : Controller { private readonly IBrainstormSessionRepository _sessionRepository; public HomeController(IBrainstormSessionRepository sessionRepository) { _sessionRepository = sessionRepository; } public async Task<IActionResult> Index() { var sessionList = await _sessionRepository.ListAsync(); var model = sessionList.Select(session => new StormSessionViewModel() { Id = session.Id, DateCreated = session.DateCreated, Name = session.Name, IdeaCount = session.Ideas.Count }); return View(model); } public class NewSessionModel { [Required] public string SessionName { get; set; } } [HttpPost] public async Task<IActionResult> Index(NewSessionModel model) { if (!ModelState.IsValid) { return BadRequest(ModelState); } else { await _sessionRepository.AddAsync(new BrainstormSession() { DateCreated = DateTimeOffset.Now, Name = model.SessionName }); } return RedirectToAction(actionName: nameof(Index)); } }
前面的控制器:
- 遵循显式依赖关系原则。
- 期望依赖关系注入 (DI) 提供
IBrainstormSessionRepository
的实例。 - 可以通过使用 mock 对象框架(如 Moq)的模拟
IBrainstormSessionRepository
服务进行测试。 模拟对象是由一组预先确定的用于测试的属性和方法行为的对象。 有关详细信息,请参阅集成测试简介。
HTTP GET Index
方法没有循环或分支,且仅调用一个方法。 此操作的单元测试:
- 使用
GetTestSessions
方法模拟IBrainstormSessionRepository
服务。GetTestSessions
使用日期和会话名称创建两个 mock 集体讨论会话。 - 执行
Index
方法。 - 根据该方法返回的结果进行断言:
- 将返回 ViewResult。
- ViewDataDictionary.Model 是
StormSessionViewModel
。 - 有两个集体讨论会话存储在
ViewDataDictionary.Model
中。
[Fact] public async Task Index_ReturnsAViewResult_WithAListOfBrainstormSessions() { // Arrange var mockRepo = new Mock<IBrainstormSessionRepository>(); mockRepo.Setup(repo => repo.ListAsync()) .ReturnsAsync(GetTestSessions()); var controller = new HomeController(mockRepo.Object); // Act var result = await controller.Index(); // Assert var viewResult = Assert.IsType<ViewResult>(result); var model = Assert.IsAssignableFrom<IEnumerable<StormSessionViewModel>>( viewResult.ViewData.Model); Assert.Equal(2, model.Count()); }
private List<BrainstormSession> GetTestSessions() { var sessions = new List<BrainstormSession>(); sessions.Add(new BrainstormSession() { DateCreated = new DateTime(2016, 7, 2), Id = 1, Name = "Test One" }); sessions.Add(new BrainstormSession() { DateCreated = new DateTime(2016, 7, 1), Id = 2, Name = "Test Two" }); return sessions; }
主控制器的 HTTP POST Index
方法测试验证:
- 当 ModelState.IsValid 为
false
时,操作方法将返回有相应数据的 400 错误请求 ViewResult。 - 当
ModelState.IsValid
为true
时:- 将调用存储库上的
Add
方法。 - 将返回有正确参数的 RedirectToActionResult。
- 将调用存储库上的
通过使用 AddModelError 添加错误来测试无效模型状态,如下第一个测试所示:
[Fact] public async Task IndexPost_ReturnsBadRequestResult_WhenModelStateIsInvalid() { // Arrange var mockRepo = new Mock<IBrainstormSessionRepository>(); mockRepo.Setup(repo => repo.ListAsync()) .ReturnsAsync(GetTestSessions()); var controller = new HomeController(mockRepo.Object); controller.ModelState.AddModelError("SessionName", "Required"); var newSession = new HomeController.NewSessionModel(); // Act var result = await controller.Index(newSession); // Assert var badRequestResult = Assert.IsType<BadRequestObjectResult>(result); Assert.IsType<SerializableError>(badRequestResult.Value); } [Fact] public async Task IndexPost_ReturnsARedirectAndAddsSession_WhenModelStateIsValid() { // Arrange var mockRepo = new Mock<IBrainstormSessionRepository>(); mockRepo.Setup(repo => repo.AddAsync(It.IsAny<BrainstormSession>())) .Returns(Task.CompletedTask) .Verifiable(); var controller = new HomeController(mockRepo.Object); var newSession = new HomeController.NewSessionModel() { SessionName = "Test Name" }; // Act var result = await controller.Index(newSession); // Assert var redirectToActionResult = Assert.IsType<RedirectToActionResult>(result); Assert.Null(redirectToActionResult.ControllerName); Assert.Equal("Index", redirectToActionResult.ActionName); mockRepo.Verify(); }
当 ModelState 无效时,将返回与 GET 请求相同的 ViewResult
。 测试不会尝试传入无效模型。 传入无效模型不是有效的方法,因为不会运行模型绑定(尽管集成测试使用模型绑定)。 在本例中,不测试模型绑定。 这些单元测试仅测试操作方法中的代码。
第二个测试验证 ModelState
有效时的情况:
- 已通过存储库添加新的
BrainstormSession
。 - 该方法将返回带有所需属性的
RedirectToActionResult
。
通常会忽略未调用的模拟调用,但在设置调用末尾调用 Verifiable
就可以在测试中进行 mock 验证。 这通过对 mockRepo.Verify
的调用来完成,进行这种调用时,如果未调用所需方法,则测试将失败。
备注
通过此示例中使用的 Moq 库,可以混合可验证(或称“严格”)mock 和非可验证 mock(也称为“宽松”mock 或存根)。 详细了解使用 Moq 自定义 Mock 行为。
示例应用中的 SessionController 显示与特定集体讨论会话相关的信息。 该控制器包含用于处理无效 id
值的逻辑(以下示例中有两个 return
方案可用来应对这些情况)。 最后的 return
语句向视图 (Controllers/SessionController.cs) 返回一个新的 StormSessionViewModel
:
public class SessionController : Controller { private readonly IBrainstormSessionRepository _sessionRepository; public SessionController(IBrainstormSessionRepository sessionRepository) { _sessionRepository = sessionRepository; } public async Task<IActionResult> Index(int? id) { if (!id.HasValue) { return RedirectToAction(actionName: nameof(Index), controllerName: "Home"); } var session = await _sessionRepository.GetByIdAsync(id.Value); if (session == null) { return Content("Session not found."); } var viewModel = new StormSessionViewModel() { DateCreated = session.DateCreated, Name = session.Name, Id = session.Id }; return View(viewModel); } }
单元测试包括对会话控制器 Index
操作中的每个 return
方案执行一个测试:
[Fact] public async Task IndexReturnsARedirectToIndexHomeWhenIdIsNull() { // Arrange var controller = new SessionController(sessionRepository: null); // Act var result = await controller.Index(id: null); // Assert var redirectToActionResult = Assert.IsType<RedirectToActionResult>(result); Assert.Equal("Home", redirectToActionResult.ControllerName); Assert.Equal("Index", redirectToActionResult.ActionName); } [Fact] public async Task IndexReturnsContentWithSessionNotFoundWhenSessionNotFound() { // Arrange int testSessionId = 1; var mockRepo = new Mock<IBrainstormSessionRepository>(); mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId)) .ReturnsAsync((BrainstormSession)null); var controller = new SessionController(mockRepo.Object); // Act var result = await controller.Index(testSessionId); // Assert var contentResult = Assert.IsType<ContentResult>(result); Assert.Equal("Session not found.", contentResult.Content); } [Fact] public async Task IndexReturnsViewResultWithStormSessionViewModel() { // Arrange int testSessionId = 1; var mockRepo = new Mock<IBrainstormSessionRepository>(); mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId)) .ReturnsAsync(GetTestSessions().FirstOrDefault( s => s.Id == testSessionId)); var controller = new SessionController(mockRepo.Object); // Act var result = await controller.Index(testSessionId); // Assert var viewResult = Assert.IsType<ViewResult>(result); var model = Assert.IsType<StormSessionViewModel>( viewResult.ViewData.Model); Assert.Equal("Test One", model.Name); Assert.Equal(2, model.DateCreated.Day); Assert.Equal(testSessionId, model.Id); }
移动到想法控制器,应用会将功能公开为 api/ideas
路由上的 Web API:
ForSession
方法将返回与集体讨论会话关联的想法列表 (IdeaDTO
)。Create
方法会向会话中添加新想法。
[HttpGet("forsession/{sessionId}")] public async Task<IActionResult> ForSession(int sessionId) { var session = await _sessionRepository.GetByIdAsync(sessionId); if (session == null) { return NotFound(sessionId); } var result = session.Ideas.Select(idea => new IdeaDTO() { Id = idea.Id, Name = idea.Name, Description = idea.Description, DateCreated = idea.DateCreated }).ToList(); return Ok(result); } [HttpPost("create")] public async Task<IActionResult> Create([FromBody]NewIdeaModel model) { if (!ModelState.IsValid) { return BadRequest(ModelState); } var session = await _sessionRepository.GetByIdAsync(model.SessionId); if (session == null) { return NotFound(model.SessionId); } var idea = new Idea() { DateCreated = DateTimeOffset.Now, Description = model.Description, Name = model.Name }; session.AddIdea(idea); await _sessionRepository.UpdateAsync(session); return Ok(session); }
避免直接通过 API 调用返回企业域实体。 域实体:
- 包含的数据通常比客户端所需的数据更多。
- 无需将应用的内部域模型与公开的 API 结合。
可以执行域实体与返回到客户端的类型之间的映射:
- 手动执行 LINQ
Select
,如同示例应用所用的那样。 有关详细信息,请参阅 LINQ(语言集成查询)。 - 自动生成库,如 AutoMapper。
接着,示例应用会演示想法控制器的 Create
和 ForSession
API 方法的单元测试。
示例应用包含两个 ForSession
测试。 第一个测试可确定 ForSession
是否返回无效会话的 NotFoundObjectResult(找不到 HTTP):
[Fact] public async Task ForSession_ReturnsHttpNotFound_ForInvalidSession() { // Arrange int testSessionId = 123; var mockRepo = new Mock<IBrainstormSessionRepository>(); mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId)) .ReturnsAsync((BrainstormSession)null); var controller = new IdeasController(mockRepo.Object); // Act var result = await controller.ForSession(testSessionId); // Assert var notFoundObjectResult = Assert.IsType<NotFoundObjectResult>(result); Assert.Equal(testSessionId, notFoundObjectResult.Value); }
第二个 ForSession
测试可确定 ForSession
是否返回有效会话的会话想法列表 (<List<IdeaDTO>>
)。 这些测试还会检查第一个想法,以确认其 Name
属性正确:
[Fact] public async Task ForSession_ReturnsIdeasForSession() { // Arrange int testSessionId = 123; var mockRepo = new Mock<IBrainstormSessionRepository>(); mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId)) .ReturnsAsync(GetTestSession()); var controller = new IdeasController(mockRepo.Object); // Act var result = await controller.ForSession(testSessionId); // Assert var okResult = Assert.IsType<OkObjectResult>(result); var returnValue = Assert.IsType<List<IdeaDTO>>(okResult.Value); var idea = returnValue.FirstOrDefault(); Assert.Equal("One", idea.Name); }
若要测试 Create
方法在 ModelState
无效时的行为,示例应用会在测试中将模型错误添加到控制器。 请勿在单元测试中尝试测试模型有效性或模型绑定—仅测试操作方法在遇到无效 ModelState
时的行为:
[Fact] public async Task Create_ReturnsBadRequest_GivenInvalidModel() { // Arrange & Act var mockRepo = new Mock<IBrainstormSessionRepository>(); var controller = new IdeasController(mockRepo.Object); controller.ModelState.AddModelError("error", "some error"); // Act var result = await controller.Create(model: null); // Assert Assert.IsType<BadRequestObjectResult>(result); }
Create
的第二个测试依赖存储库返回 null
,所以 mock 存储库配置为返回 null
。 无需创建测试数据库(在内存中或其他位置)并构建将返回此结果的查询。 该测试可以在单个语句中完成,如示例代码所示:
[Fact] public async Task Create_ReturnsHttpNotFound_ForInvalidSession() { // Arrange int testSessionId = 123; var mockRepo = new Mock<IBrainstormSessionRepository>(); mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId)) .ReturnsAsync((BrainstormSession)null); var controller = new IdeasController(mockRepo.Object); // Act var result = await controller.Create(new NewIdeaModel()); // Assert Assert.IsType<NotFoundObjectResult>(result); }
第三个 Create
测试 Create_ReturnsNewlyCreatedIdeaForSession
验证调用了存储库的 UpdateAsync
方法。 使用 Verifiable
调用 mock,然后调用模拟存储库的 Verify
方法,以确认执行了可验证的方法。 确保 UpdateAsync
方法保存了数据不是单元测试的职责—这可以通过集成测试完成。
[Fact] public async Task Create_ReturnsNewlyCreatedIdeaForSession() { // Arrange int testSessionId = 123; string testName = "test name"; string testDescription = "test description"; var testSession = GetTestSession(); var mockRepo = new Mock<IBrainstormSessionRepository>(); mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId)) .ReturnsAsync(testSession); var controller = new IdeasController(mockRepo.Object); var newIdea = new NewIdeaModel() { Description = testDescription, Name = testName, SessionId = testSessionId }; mockRepo.Setup(repo => repo.UpdateAsync(testSession)) .Returns(Task.CompletedTask) .Verifiable(); // Act var result = await controller.Create(newIdea); // Assert var okResult = Assert.IsType<OkObjectResult>(result); var returnSession = Assert.IsType<BrainstormSession>(okResult.Value); mockRepo.Verify(); Assert.Equal(2, returnSession.Ideas.Count()); Assert.Equal(testName, returnSession.Ideas.LastOrDefault().Name); Assert.Equal(testDescription, returnSession.Ideas.LastOrDefault().Description); }
测试 ActionResult<T>
在 ASP.NET Core 2.1 或更高版本中,ActionResult<T> (ActionResult<TValue>) 支持返回从 ActionResult
派生的类型或返回特定类型。
示例应用包含将返回给定会话 id
的 List<IdeaDTO>
的方法。 如果会话 id
不存在,控制器将返回 NotFound:
[HttpGet("forsessionactionresult/{sessionId}")] [ProducesResponseType(200)] [ProducesResponseType(404)] public async Task<ActionResult<List<IdeaDTO>>> ForSessionActionResult(int sessionId) { var session = await _sessionRepository.GetByIdAsync(sessionId); if (session == null) { return NotFound(sessionId); } var result = session.Ideas.Select(idea => new IdeaDTO() { Id = idea.Id, Name = idea.Name, Description = idea.Description, DateCreated = idea.DateCreated }).ToList(); return result; }
ApiIdeasControllerTests
中包含 ForSessionActionResult
控制器的两个测试。
第一个测试可确认控制器将返回 ActionResult
,而不是不存在会话 id
的不存在想法列表:
ActionResult
类型为ActionResult<List<IdeaDTO>>
。- Result 为 NotFoundObjectResult。
[Fact] public async Task ForSessionActionResult_ReturnsNotFoundObjectResultForNonexistentSession() { // Arrange var mockRepo = new Mock<IBrainstormSessionRepository>(); var controller = new IdeasController(mockRepo.Object); var nonExistentSessionId = 999; // Act var result = await controller.ForSessionActionResult(nonExistentSessionId); // Assert var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result); Assert.IsType<NotFoundObjectResult>(actionResult.Result); }
对于有效会话 id
,第二个测试可确认该方法将返回:
- 类型为
List<IdeaDTO>
的ActionResult
。 - ActionResult<T>.Value 是
List<IdeaDTO>
类型。 - 列表中的第一项是与 mock 会话中存储的想法匹配的有效想法(通过调用
GetTestSession
获取)。
[Fact] public async Task ForSessionActionResult_ReturnsIdeasForSession() { // Arrange int testSessionId = 123; var mockRepo = new Mock<IBrainstormSessionRepository>(); mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId)) .ReturnsAsync(GetTestSession()); var controller = new IdeasController(mockRepo.Object); // Act var result = await controller.ForSessionActionResult(testSessionId); // Assert var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result); var returnValue = Assert.IsType<List<IdeaDTO>>(actionResult.Value); var idea = returnValue.FirstOrDefault(); Assert.Equal("One", idea.Name); }
示例应用还包含用于为给定会话创建新的 Idea
的方法。 控制器将返回:
- BadRequest(对于无效模型)。
- NotFound(如果会话不存在)。
- CreatedAtAction(当使用新想法更新会话时)。
[HttpPost("createactionresult")] [ProducesResponseType(201)] [ProducesResponseType(400)] [ProducesResponseType(404)] public async Task<ActionResult<BrainstormSession>> CreateActionResult([FromBody]NewIdeaModel model) { if (!ModelState.IsValid) { return BadRequest(ModelState); } var session = await _sessionRepository.GetByIdAsync(model.SessionId); if (session == null) { return NotFound(model.SessionId); } var idea = new Idea() { DateCreated = DateTimeOffset.Now, Description = model.Description, Name = model.Name }; session.AddIdea(idea); await _sessionRepository.UpdateAsync(session); return CreatedAtAction(nameof(CreateActionResult), new { id = session.Id }, session); }
ApiIdeasControllerTests
中包含 CreateActionResult
的三个测试。
第一个测试可确认将返回 BadRequest(对于无效模型)。
[Fact] public async Task CreateActionResult_ReturnsBadRequest_GivenInvalidModel() { // Arrange & Act var mockRepo = new Mock<IBrainstormSessionRepository>(); var controller = new IdeasController(mockRepo.Object); controller.ModelState.AddModelError("error", "some error"); // Act var result = await controller.CreateActionResult(model: null); // Assert var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result); Assert.IsType<BadRequestObjectResult>(actionResult.Result); }
第二个测试可确认将返回 NotFound(如果会话不存在)。
[Fact] public async Task CreateActionResult_ReturnsNotFoundObjectResultForNonexistentSession() { // Arrange var nonExistentSessionId = 999; string testName = "test name"; string testDescription = "test description"; var mockRepo = new Mock<IBrainstormSessionRepository>(); var controller = new IdeasController(mockRepo.Object); var newIdea = new NewIdeaModel() { Description = testDescription, Name = testName, SessionId = nonExistentSessionId }; // Act var result = await controller.CreateActionResult(newIdea); // Assert var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result); Assert.IsType<NotFoundObjectResult>(actionResult.Result); }
对于有效会话 id
,最后一个测试可确认:
- 该方法将返回类型为
BrainstormSession
的ActionResult
。 - ActionResult<T>.Result 是 CreatedAtActionResult。
CreatedAtActionResult
类似于包含Location
标头的 201 Created 响应。 - ActionResult<T>.Value 是
BrainstormSession
类型。 - 调用了用于更新会话
UpdateAsync(testSession)
的 mock 调用。 通过执行断言中的mockRepo.Verify()
来检查Verifiable
方法调用。 - 将返回该会话的两个
Idea
对象。 - 最后一项(通过对
UpdateAsync
的 mock 调用而添加的Idea
)与添加到测试中的会话的newIdea
匹配。
[Fact] public async Task CreateActionResult_ReturnsNewlyCreatedIdeaForSession() { // Arrange int testSessionId = 123; string testName = "test name"; string testDescription = "test description"; var testSession = GetTestSession(); var mockRepo = new Mock<IBrainstormSessionRepository>(); mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId)) .ReturnsAsync(testSession); var controller = new IdeasController(mockRepo.Object); var newIdea = new NewIdeaModel() { Description = testDescription, Name = testName, SessionId = testSessionId }; mockRepo.Setup(repo => repo.UpdateAsync(testSession)) .Returns(Task.CompletedTask) .Verifiable(); // Act var result = await controller.CreateActionResult(newIdea); // Assert var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result); var createdAtActionResult = Assert.IsType<CreatedAtActionResult>(actionResult.Result); var returnValue = Assert.IsType<BrainstormSession>(createdAtActionResult.Value); mockRepo.Verify(); Assert.Equal(2, returnValue.Ideas.Count()); Assert.Equal(testName, returnValue.Ideas.LastOrDefault().Name); Assert.Equal(testDescription, returnValue.Ideas.LastOrDefault().Description); }
其他资源
- ASP.NET Core 中的集成测试
- 使用 Visual Studio 创建和运行单元测试
- MyTested.AspNetCore.Mvc - ASP.NET Core MVC 的 Fluent 测试库 – 强类型单元测试库,提供用于测试 MVC 和 Web API 应用的 Fluent 界面。 (不由 Microsoft 进行支持或维护。)
上一篇:没有了