ASP.NET Core 中的单元测试控制器逻辑Unit test controller logic in ASP.NET Core

本文内容

作者:Steve Smith

单元测试涉及通过基础结构和依赖项单独测试应用的一部分。单元测试控制器逻辑时,仅测试单个操作的内容,不测试其依赖项或框架自身的行为。

单元测试控制器Unit testing controllers

将控制器操作的单元测试设置为专注于控制器的行为。控制器单元测试将避开筛选器路由模型绑定等方案。涵盖共同响应请求的组件之间的交互的测试由集成测试处理。有关集成测试的详细信息,请参阅ASP.NET Core 中的集成测试

如果编写自定义筛选器和路由,应对其单独进行单元测试,而不是在测试特定控制器操作时进行。

要演示控制器单元测试,请查看以下示例应用中的控制器。

查看或下载示例代码如何下载

主控制器显示集体讨论会话列表并允许使用 POST 请求创建新的集体讨论会话:

  1. public class HomeController : Controller
  2. {
  3. private readonly IBrainstormSessionRepository _sessionRepository;
  4. public HomeController(IBrainstormSessionRepository sessionRepository)
  5. {
  6. _sessionRepository = sessionRepository;
  7. }
  8. public async Task<IActionResult> Index()
  9. {
  10. var sessionList = await _sessionRepository.ListAsync();
  11. var model = sessionList.Select(session => new StormSessionViewModel()
  12. {
  13. Id = session.Id,
  14. DateCreated = session.DateCreated,
  15. Name = session.Name,
  16. IdeaCount = session.Ideas.Count
  17. });
  18. return View(model);
  19. }
  20. public class NewSessionModel
  21. {
  22. [Required]
  23. public string SessionName { get; set; }
  24. }
  25. [HttpPost]
  26. public async Task<IActionResult> Index(NewSessionModel model)
  27. {
  28. if (!ModelState.IsValid)
  29. {
  30. return BadRequest(ModelState);
  31. }
  32. else
  33. {
  34. await _sessionRepository.AddAsync(new BrainstormSession()
  35. {
  36. DateCreated = DateTimeOffset.Now,
  37. Name = model.SessionName
  38. });
  39. }
  40. return RedirectToAction(actionName: nameof(Index));
  41. }
  42. }

前面的控制器:

HTTP GET Index 方法没有循环或分支,且仅调用一个方法。此操作的单元测试:

  • 使用 IBrainstormSessionRepository 方法模拟 GetTestSessions 服务。GetTestSessions 使用日期和会话名称创建两个 mock 集体讨论会话。
  • 执行 Index 方法。
  • 根据该方法返回的结果进行断言:
  1. [Fact]
  2. public async Task Index_ReturnsAViewResult_WithAListOfBrainstormSessions()
  3. {
  4. // Arrange
  5. var mockRepo = new Mock<IBrainstormSessionRepository>();
  6. mockRepo.Setup(repo => repo.ListAsync())
  7. .ReturnsAsync(GetTestSessions());
  8. var controller = new HomeController(mockRepo.Object);
  9. // Act
  10. var result = await controller.Index();
  11. // Assert
  12. var viewResult = Assert.IsType<ViewResult>(result);
  13. var model = Assert.IsAssignableFrom<IEnumerable<StormSessionViewModel>>(
  14. viewResult.ViewData.Model);
  15. Assert.Equal(2, model.Count());
  16. }
  1. private List<BrainstormSession> GetTestSessions()
  2. {
  3. var sessions = new List<BrainstormSession>();
  4. sessions.Add(new BrainstormSession()
  5. {
  6. DateCreated = new DateTime(2016, 7, 2),
  7. Id = 1,
  8. Name = "Test One"
  9. });
  10. sessions.Add(new BrainstormSession()
  11. {
  12. DateCreated = new DateTime(2016, 7, 1),
  13. Id = 2,
  14. Name = "Test Two"
  15. });
  16. return sessions;
  17. }

主控制器的 HTTP POST Index 方法测试验证:

通过使用 AddModelError 添加错误来测试无效模型状态,如下第一个测试所示:

  1. [Fact]
  2. public async Task IndexPost_ReturnsBadRequestResult_WhenModelStateIsInvalid()
  3. {
  4. // Arrange
  5. var mockRepo = new Mock<IBrainstormSessionRepository>();
  6. mockRepo.Setup(repo => repo.ListAsync())
  7. .ReturnsAsync(GetTestSessions());
  8. var controller = new HomeController(mockRepo.Object);
  9. controller.ModelState.AddModelError("SessionName", "Required");
  10. var newSession = new HomeController.NewSessionModel();
  11. // Act
  12. var result = await controller.Index(newSession);
  13. // Assert
  14. var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
  15. Assert.IsType<SerializableError>(badRequestResult.Value);
  16. }
  17. [Fact]
  18. public async Task IndexPost_ReturnsARedirectAndAddsSession_WhenModelStateIsValid()
  19. {
  20. // Arrange
  21. var mockRepo = new Mock<IBrainstormSessionRepository>();
  22. mockRepo.Setup(repo => repo.AddAsync(It.IsAny<BrainstormSession>()))
  23. .Returns(Task.CompletedTask)
  24. .Verifiable();
  25. var controller = new HomeController(mockRepo.Object);
  26. var newSession = new HomeController.NewSessionModel()
  27. {
  28. SessionName = "Test Name"
  29. };
  30. // Act
  31. var result = await controller.Index(newSession);
  32. // Assert
  33. var redirectToActionResult = Assert.IsType<RedirectToActionResult>(result);
  34. Assert.Null(redirectToActionResult.ControllerName);
  35. Assert.Equal("Index", redirectToActionResult.ActionName);
  36. mockRepo.Verify();
  37. }

ModelState 无效时,将返回与 GET 请求相同的 ViewResult测试不会尝试传入无效模型。传入无效模型不是有效的方法,因为不会运行模型绑定(尽管集成测试使用模型绑定)。在本例中,不测试模型绑定。这些单元测试仅测试操作方法中的代码。

第二个测试验证 ModelState 有效时的情况:

  • 已通过存储库添加新的 BrainstormSession
  • 该方法将返回带有所需属性的 RedirectToActionResult

通常会忽略未调用的模拟调用,但在设置调用末尾调用 Verifiable 就可以在测试中进行 mock 验证。这通过对 mockRepo.Verify 的调用来完成,进行这种调用时,如果未调用所需方法,则测试将失败。

备注

通过此示例中使用的 Moq 库,可以混合可验证(或称“严格”)mock 和非可验证 mock(也称为“宽松”mock 或存根)。详细了解使用 Moq 自定义 Mock 行为

示例应用中的 SessionController 显示与特定集体讨论会话相关的信息。该控制器包含用于处理无效 id 值的逻辑(以下示例中有两个 return 方案可用来应对这些情况)。最后的 return 语句向视图 (Controllers/SessionController.cs) 返回一个新的 StormSessionViewModel

  1. public class SessionController : Controller
  2. {
  3. private readonly IBrainstormSessionRepository _sessionRepository;
  4. public SessionController(IBrainstormSessionRepository sessionRepository)
  5. {
  6. _sessionRepository = sessionRepository;
  7. }
  8. public async Task<IActionResult> Index(int? id)
  9. {
  10. if (!id.HasValue)
  11. {
  12. return RedirectToAction(actionName: nameof(Index),
  13. controllerName: "Home");
  14. }
  15. var session = await _sessionRepository.GetByIdAsync(id.Value);
  16. if (session == null)
  17. {
  18. return Content("Session not found.");
  19. }
  20. var viewModel = new StormSessionViewModel()
  21. {
  22. DateCreated = session.DateCreated,
  23. Name = session.Name,
  24. Id = session.Id
  25. };
  26. return View(viewModel);
  27. }
  28. }

单元测试包括对会话控制器 return 操作中的每个 Index 方案执行一个测试:

  1. [Fact]
  2. public async Task IndexReturnsARedirectToIndexHomeWhenIdIsNull()
  3. {
  4. // Arrange
  5. var controller = new SessionController(sessionRepository: null);
  6. // Act
  7. var result = await controller.Index(id: null);
  8. // Assert
  9. var redirectToActionResult =
  10. Assert.IsType<RedirectToActionResult>(result);
  11. Assert.Equal("Home", redirectToActionResult.ControllerName);
  12. Assert.Equal("Index", redirectToActionResult.ActionName);
  13. }
  14. [Fact]
  15. public async Task IndexReturnsContentWithSessionNotFoundWhenSessionNotFound()
  16. {
  17. // Arrange
  18. int testSessionId = 1;
  19. var mockRepo = new Mock<IBrainstormSessionRepository>();
  20. mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
  21. .ReturnsAsync((BrainstormSession)null);
  22. var controller = new SessionController(mockRepo.Object);
  23. // Act
  24. var result = await controller.Index(testSessionId);
  25. // Assert
  26. var contentResult = Assert.IsType<ContentResult>(result);
  27. Assert.Equal("Session not found.", contentResult.Content);
  28. }
  29. [Fact]
  30. public async Task IndexReturnsViewResultWithStormSessionViewModel()
  31. {
  32. // Arrange
  33. int testSessionId = 1;
  34. var mockRepo = new Mock<IBrainstormSessionRepository>();
  35. mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
  36. .ReturnsAsync(GetTestSessions().FirstOrDefault(
  37. s => s.Id == testSessionId));
  38. var controller = new SessionController(mockRepo.Object);
  39. // Act
  40. var result = await controller.Index(testSessionId);
  41. // Assert
  42. var viewResult = Assert.IsType<ViewResult>(result);
  43. var model = Assert.IsType<StormSessionViewModel>(
  44. viewResult.ViewData.Model);
  45. Assert.Equal("Test One", model.Name);
  46. Assert.Equal(2, model.DateCreated.Day);
  47. Assert.Equal(testSessionId, model.Id);
  48. }

移动到想法控制器,应用会将功能公开为 api/ideas 路由上的 Web API:

  • IdeaDTO 方法将返回与集体讨论会话关联的想法列表 (ForSession)。
  • Create 方法会向会话中添加新想法。
  1. [HttpGet("forsession/{sessionId}")]
  2. public async Task<IActionResult> ForSession(int sessionId)
  3. {
  4. var session = await _sessionRepository.GetByIdAsync(sessionId);
  5. if (session == null)
  6. {
  7. return NotFound(sessionId);
  8. }
  9. var result = session.Ideas.Select(idea => new IdeaDTO()
  10. {
  11. Id = idea.Id,
  12. Name = idea.Name,
  13. Description = idea.Description,
  14. DateCreated = idea.DateCreated
  15. }).ToList();
  16. return Ok(result);
  17. }
  18. [HttpPost("create")]
  19. public async Task<IActionResult> Create([FromBody]NewIdeaModel model)
  20. {
  21. if (!ModelState.IsValid)
  22. {
  23. return BadRequest(ModelState);
  24. }
  25. var session = await _sessionRepository.GetByIdAsync(model.SessionId);
  26. if (session == null)
  27. {
  28. return NotFound(model.SessionId);
  29. }
  30. var idea = new Idea()
  31. {
  32. DateCreated = DateTimeOffset.Now,
  33. Description = model.Description,
  34. Name = model.Name
  35. };
  36. session.AddIdea(idea);
  37. await _sessionRepository.UpdateAsync(session);
  38. return Ok(session);
  39. }

避免直接通过 API 调用返回企业域实体。域实体:

  • 包含的数据通常比客户端所需的数据更多。
  • 无需将应用的内部域模型与公开的 API 结合。

可以执行域实体与返回到客户端的类型之间的映射:

接着,示例应用会演示想法控制器的 CreateForSession API 方法的单元测试。

示例应用包含两个 ForSession 测试。第一个测试可确定 ForSession 是否返回无效会话的 NotFoundObjectResult(找不到 HTTP):

  1. [Fact]
  2. public async Task ForSession_ReturnsHttpNotFound_ForInvalidSession()
  3. {
  4. // Arrange
  5. int testSessionId = 123;
  6. var mockRepo = new Mock<IBrainstormSessionRepository>();
  7. mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
  8. .ReturnsAsync((BrainstormSession)null);
  9. var controller = new IdeasController(mockRepo.Object);
  10. // Act
  11. var result = await controller.ForSession(testSessionId);
  12. // Assert
  13. var notFoundObjectResult = Assert.IsType<NotFoundObjectResult>(result);
  14. Assert.Equal(testSessionId, notFoundObjectResult.Value);
  15. }

第二个 ForSession 测试可确定 ForSession 是否返回有效会话的会话想法列表 (<List<IdeaDTO>>)。这些测试还会检查第一个想法,以确认其 Name 属性正确:

  1. [Fact]
  2. public async Task ForSession_ReturnsIdeasForSession()
  3. {
  4. // Arrange
  5. int testSessionId = 123;
  6. var mockRepo = new Mock<IBrainstormSessionRepository>();
  7. mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
  8. .ReturnsAsync(GetTestSession());
  9. var controller = new IdeasController(mockRepo.Object);
  10. // Act
  11. var result = await controller.ForSession(testSessionId);
  12. // Assert
  13. var okResult = Assert.IsType<OkObjectResult>(result);
  14. var returnValue = Assert.IsType<List<IdeaDTO>>(okResult.Value);
  15. var idea = returnValue.FirstOrDefault();
  16. Assert.Equal("One", idea.Name);
  17. }

若要测试 Create 方法在 ModelState 无效时的行为,示例应用会在测试中将模型错误添加到控制器。请勿在单元测试中尝试测试模型有效性或模型绑定—仅测试操作方法在遇到无效 ModelState 时的行为:

  1. [Fact]
  2. public async Task Create_ReturnsBadRequest_GivenInvalidModel()
  3. {
  4. // Arrange & Act
  5. var mockRepo = new Mock<IBrainstormSessionRepository>();
  6. var controller = new IdeasController(mockRepo.Object);
  7. controller.ModelState.AddModelError("error", "some error");
  8. // Act
  9. var result = await controller.Create(model: null);
  10. // Assert
  11. Assert.IsType<BadRequestObjectResult>(result);
  12. }

Create 的第二个测试依赖存储库返回 null,所以 mock 存储库配置为返回 null无需创建测试数据库(在内存中或其他位置)并构建将返回此结果的查询。该测试可以在单个语句中完成,如示例代码所示:

  1. [Fact]
  2. public async Task Create_ReturnsHttpNotFound_ForInvalidSession()
  3. {
  4. // Arrange
  5. int testSessionId = 123;
  6. var mockRepo = new Mock<IBrainstormSessionRepository>();
  7. mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
  8. .ReturnsAsync((BrainstormSession)null);
  9. var controller = new IdeasController(mockRepo.Object);
  10. // Act
  11. var result = await controller.Create(new NewIdeaModel());
  12. // Assert
  13. Assert.IsType<NotFoundObjectResult>(result);
  14. }

第三个 Create 测试 Create_ReturnsNewlyCreatedIdeaForSession 验证调用了存储库的 UpdateAsync 方法。使用 Verifiable 调用 mock,然后调用模拟存储库的 Verify 方法,以确认执行了可验证的方法。确保 UpdateAsync 方法保存了数据不是单元测试的职责—这可以通过集成测试完成。

  1. [Fact]
  2. public async Task Create_ReturnsNewlyCreatedIdeaForSession()
  3. {
  4. // Arrange
  5. int testSessionId = 123;
  6. string testName = "test name";
  7. string testDescription = "test description";
  8. var testSession = GetTestSession();
  9. var mockRepo = new Mock<IBrainstormSessionRepository>();
  10. mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
  11. .ReturnsAsync(testSession);
  12. var controller = new IdeasController(mockRepo.Object);
  13. var newIdea = new NewIdeaModel()
  14. {
  15. Description = testDescription,
  16. Name = testName,
  17. SessionId = testSessionId
  18. };
  19. mockRepo.Setup(repo => repo.UpdateAsync(testSession))
  20. .Returns(Task.CompletedTask)
  21. .Verifiable();
  22. // Act
  23. var result = await controller.Create(newIdea);
  24. // Assert
  25. var okResult = Assert.IsType<OkObjectResult>(result);
  26. var returnSession = Assert.IsType<BrainstormSession>(okResult.Value);
  27. mockRepo.Verify();
  28. Assert.Equal(2, returnSession.Ideas.Count());
  29. Assert.Equal(testName, returnSession.Ideas.LastOrDefault().Name);
  30. Assert.Equal(testDescription, returnSession.Ideas.LastOrDefault().Description);
  31. }

测试 ActionResult<T>Test ActionResult<T>

在 ASP.NET Core 2.1 或更高版本中,ActionResult<T> (ActionResult<TValue>) 支持返回从 ActionResult 派生的类型或返回特定类型。

示例应用包含将返回给定会话 List<IdeaDTO>id 的方法。如果会话 id 不存在,控制器将返回 NotFound

  1. [HttpGet("forsessionactionresult/{sessionId}")]
  2. [ProducesResponseType(200)]
  3. [ProducesResponseType(404)]
  4. public async Task<ActionResult<List<IdeaDTO>>> ForSessionActionResult(int sessionId)
  5. {
  6. var session = await _sessionRepository.GetByIdAsync(sessionId);
  7. if (session == null)
  8. {
  9. return NotFound(sessionId);
  10. }
  11. var result = session.Ideas.Select(idea => new IdeaDTO()
  12. {
  13. Id = idea.Id,
  14. Name = idea.Name,
  15. Description = idea.Description,
  16. DateCreated = idea.DateCreated
  17. }).ToList();
  18. return result;
  19. }

ForSessionActionResult 中包含 ApiIdeasControllerTests 控制器的两个测试。

第一个测试可确认控制器将返回 ActionResult,而不是不存在会话 id 的不存在想法列表:

  1. [Fact]
  2. public async Task ForSessionActionResult_ReturnsNotFoundObjectResultForNonexistentSession()
  3. {
  4. // Arrange
  5. var mockRepo = new Mock<IBrainstormSessionRepository>();
  6. var controller = new IdeasController(mockRepo.Object);
  7. var nonExistentSessionId = 999;
  8. // Act
  9. var result = await controller.ForSessionActionResult(nonExistentSessionId);
  10. // Assert
  11. var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
  12. Assert.IsType<NotFoundObjectResult>(actionResult.Result);
  13. }

对于有效会话 id,第二个测试可确认该方法将返回:

  • 类型为 ActionResultList<IdeaDTO>
  • ActionResult.ValueList<IdeaDTO> 类型。
  • 列表中的第一项是与 mock 会话中存储的想法匹配的有效想法(通过调用 GetTestSession 获取)。
  1. [Fact]
  2. public async Task ForSessionActionResult_ReturnsIdeasForSession()
  3. {
  4. // Arrange
  5. int testSessionId = 123;
  6. var mockRepo = new Mock<IBrainstormSessionRepository>();
  7. mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
  8. .ReturnsAsync(GetTestSession());
  9. var controller = new IdeasController(mockRepo.Object);
  10. // Act
  11. var result = await controller.ForSessionActionResult(testSessionId);
  12. // Assert
  13. var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
  14. var returnValue = Assert.IsType<List<IdeaDTO>>(actionResult.Value);
  15. var idea = returnValue.FirstOrDefault();
  16. Assert.Equal("One", idea.Name);
  17. }

示例应用还包含用于为给定会话创建新的 Idea 的方法。控制器将返回:

  1. [HttpPost("createactionresult")]
  2. [ProducesResponseType(201)]
  3. [ProducesResponseType(400)]
  4. [ProducesResponseType(404)]
  5. public async Task<ActionResult<BrainstormSession>> CreateActionResult([FromBody]NewIdeaModel model)
  6. {
  7. if (!ModelState.IsValid)
  8. {
  9. return BadRequest(ModelState);
  10. }
  11. var session = await _sessionRepository.GetByIdAsync(model.SessionId);
  12. if (session == null)
  13. {
  14. return NotFound(model.SessionId);
  15. }
  16. var idea = new Idea()
  17. {
  18. DateCreated = DateTimeOffset.Now,
  19. Description = model.Description,
  20. Name = model.Name
  21. };
  22. session.AddIdea(idea);
  23. await _sessionRepository.UpdateAsync(session);
  24. return CreatedAtAction(nameof(CreateActionResult), new { id = session.Id }, session);
  25. }

CreateActionResult 中包含 ApiIdeasControllerTests 的三个测试。

第一个测试可确认将返回 BadRequest(对于无效模型)。

  1. [Fact]
  2. public async Task CreateActionResult_ReturnsBadRequest_GivenInvalidModel()
  3. {
  4. // Arrange & Act
  5. var mockRepo = new Mock<IBrainstormSessionRepository>();
  6. var controller = new IdeasController(mockRepo.Object);
  7. controller.ModelState.AddModelError("error", "some error");
  8. // Act
  9. var result = await controller.CreateActionResult(model: null);
  10. // Assert
  11. var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
  12. Assert.IsType<BadRequestObjectResult>(actionResult.Result);
  13. }

第二个测试可确认将返回 NotFound(如果会话不存在)。

  1. [Fact]
  2. public async Task CreateActionResult_ReturnsNotFoundObjectResultForNonexistentSession()
  3. {
  4. // Arrange
  5. var nonExistentSessionId = 999;
  6. string testName = "test name";
  7. string testDescription = "test description";
  8. var mockRepo = new Mock<IBrainstormSessionRepository>();
  9. var controller = new IdeasController(mockRepo.Object);
  10. var newIdea = new NewIdeaModel()
  11. {
  12. Description = testDescription,
  13. Name = testName,
  14. SessionId = nonExistentSessionId
  15. };
  16. // Act
  17. var result = await controller.CreateActionResult(newIdea);
  18. // Assert
  19. var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
  20. Assert.IsType<NotFoundObjectResult>(actionResult.Result);
  21. }

对于有效会话 id,最后一个测试可确认:

  • 该方法将返回类型为 ActionResultBrainstormSession
  • ActionResult.ResultCreatedAtActionResultCreatedAtActionResult 类似于包含 _标头的_201 CreatedLocation 响应。
  • ActionResult.ValueBrainstormSession 类型。
  • 调用了用于更新会话 UpdateAsync(testSession) 的 mock 调用。通过执行断言中的 Verifiable 来检查 mockRepo.Verify() 方法调用。
  • 将返回该会话的两个 Idea 对象。
  • 最后一项(通过对 Idea 的 mock 调用而添加的 UpdateAsync)与添加到测试中的会话的 newIdea 匹配。
  1. [Fact]
  2. public async Task CreateActionResult_ReturnsNewlyCreatedIdeaForSession()
  3. {
  4. // Arrange
  5. int testSessionId = 123;
  6. string testName = "test name";
  7. string testDescription = "test description";
  8. var testSession = GetTestSession();
  9. var mockRepo = new Mock<IBrainstormSessionRepository>();
  10. mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
  11. .ReturnsAsync(testSession);
  12. var controller = new IdeasController(mockRepo.Object);
  13. var newIdea = new NewIdeaModel()
  14. {
  15. Description = testDescription,
  16. Name = testName,
  17. SessionId = testSessionId
  18. };
  19. mockRepo.Setup(repo => repo.UpdateAsync(testSession))
  20. .Returns(Task.CompletedTask)
  21. .Verifiable();
  22. // Act
  23. var result = await controller.CreateActionResult(newIdea);
  24. // Assert
  25. var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
  26. var createdAtActionResult = Assert.IsType<CreatedAtActionResult>(actionResult.Result);
  27. var returnValue = Assert.IsType<BrainstormSession>(createdAtActionResult.Value);
  28. mockRepo.Verify();
  29. Assert.Equal(2, returnValue.Ideas.Count());
  30. Assert.Equal(testName, returnValue.Ideas.LastOrDefault().Name);
  31. Assert.Equal(testDescription, returnValue.Ideas.LastOrDefault().Description);
  32. }

控制器在任何 ASP.NET Core MVC 应用中起着核心作用。因此,应该对控制器表现达到预期怀有信心。在将应用部署到生产环境之前,自动测试可以检测到错误。

查看或下载示例代码如何下载

控制器逻辑的单元测试Unit tests of controller logic

单元测试涉及通过基础结构和依赖项单独测试应用的一部分。单元测试控制器逻辑时,仅测试单个操作的内容,不测试其依赖项或框架自身的行为。

将控制器操作的单元测试设置为专注于控制器的行为。控制器单元测试将避开筛选器路由模型绑定等方案。涵盖共同响应请求的组件之间的交互的测试由集成测试处理。有关集成测试的详细信息,请参阅ASP.NET Core 中的集成测试

如果编写自定义筛选器和路由,应对其单独进行单元测试,而不是在测试特定控制器操作时进行。

要演示控制器单元测试,请查看以下示例应用中的控制器。主控制器显示集体讨论会话列表并允许使用 POST 请求创建新的集体讨论会话:

  1. public class HomeController : Controller
  2. {
  3. private readonly IBrainstormSessionRepository _sessionRepository;
  4. public HomeController(IBrainstormSessionRepository sessionRepository)
  5. {
  6. _sessionRepository = sessionRepository;
  7. }
  8. public async Task<IActionResult> Index()
  9. {
  10. var sessionList = await _sessionRepository.ListAsync();
  11. var model = sessionList.Select(session => new StormSessionViewModel()
  12. {
  13. Id = session.Id,
  14. DateCreated = session.DateCreated,
  15. Name = session.Name,
  16. IdeaCount = session.Ideas.Count
  17. });
  18. return View(model);
  19. }
  20. public class NewSessionModel
  21. {
  22. [Required]
  23. public string SessionName { get; set; }
  24. }
  25. [HttpPost]
  26. public async Task<IActionResult> Index(NewSessionModel model)
  27. {
  28. if (!ModelState.IsValid)
  29. {
  30. return BadRequest(ModelState);
  31. }
  32. else
  33. {
  34. await _sessionRepository.AddAsync(new BrainstormSession()
  35. {
  36. DateCreated = DateTimeOffset.Now,
  37. Name = model.SessionName
  38. });
  39. }
  40. return RedirectToAction(actionName: nameof(Index));
  41. }
  42. }

前面的控制器:

HTTP GET Index 方法没有循环或分支,且仅调用一个方法。此操作的单元测试:

  • 使用 IBrainstormSessionRepository 方法模拟 GetTestSessions 服务。GetTestSessions 使用日期和会话名称创建两个 mock 集体讨论会话。
  • 执行 Index 方法。
  • 根据该方法返回的结果进行断言:
  1. [Fact]
  2. public async Task Index_ReturnsAViewResult_WithAListOfBrainstormSessions()
  3. {
  4. // Arrange
  5. var mockRepo = new Mock<IBrainstormSessionRepository>();
  6. mockRepo.Setup(repo => repo.ListAsync())
  7. .ReturnsAsync(GetTestSessions());
  8. var controller = new HomeController(mockRepo.Object);
  9. // Act
  10. var result = await controller.Index();
  11. // Assert
  12. var viewResult = Assert.IsType<ViewResult>(result);
  13. var model = Assert.IsAssignableFrom<IEnumerable<StormSessionViewModel>>(
  14. viewResult.ViewData.Model);
  15. Assert.Equal(2, model.Count());
  16. }
  1. private List<BrainstormSession> GetTestSessions()
  2. {
  3. var sessions = new List<BrainstormSession>();
  4. sessions.Add(new BrainstormSession()
  5. {
  6. DateCreated = new DateTime(2016, 7, 2),
  7. Id = 1,
  8. Name = "Test One"
  9. });
  10. sessions.Add(new BrainstormSession()
  11. {
  12. DateCreated = new DateTime(2016, 7, 1),
  13. Id = 2,
  14. Name = "Test Two"
  15. });
  16. return sessions;
  17. }

主控制器的 HTTP POST Index 方法测试验证:

通过使用 AddModelError 添加错误来测试无效模型状态,如下第一个测试所示:

  1. [Fact]
  2. public async Task IndexPost_ReturnsBadRequestResult_WhenModelStateIsInvalid()
  3. {
  4. // Arrange
  5. var mockRepo = new Mock<IBrainstormSessionRepository>();
  6. mockRepo.Setup(repo => repo.ListAsync())
  7. .ReturnsAsync(GetTestSessions());
  8. var controller = new HomeController(mockRepo.Object);
  9. controller.ModelState.AddModelError("SessionName", "Required");
  10. var newSession = new HomeController.NewSessionModel();
  11. // Act
  12. var result = await controller.Index(newSession);
  13. // Assert
  14. var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
  15. Assert.IsType<SerializableError>(badRequestResult.Value);
  16. }
  17. [Fact]
  18. public async Task IndexPost_ReturnsARedirectAndAddsSession_WhenModelStateIsValid()
  19. {
  20. // Arrange
  21. var mockRepo = new Mock<IBrainstormSessionRepository>();
  22. mockRepo.Setup(repo => repo.AddAsync(It.IsAny<BrainstormSession>()))
  23. .Returns(Task.CompletedTask)
  24. .Verifiable();
  25. var controller = new HomeController(mockRepo.Object);
  26. var newSession = new HomeController.NewSessionModel()
  27. {
  28. SessionName = "Test Name"
  29. };
  30. // Act
  31. var result = await controller.Index(newSession);
  32. // Assert
  33. var redirectToActionResult = Assert.IsType<RedirectToActionResult>(result);
  34. Assert.Null(redirectToActionResult.ControllerName);
  35. Assert.Equal("Index", redirectToActionResult.ActionName);
  36. mockRepo.Verify();
  37. }

ModelState 无效时,将返回与 GET 请求相同的 ViewResult测试不会尝试传入无效模型。传入无效模型不是有效的方法,因为不会运行模型绑定(尽管集成测试使用模型绑定)。在本例中,不测试模型绑定。这些单元测试仅测试操作方法中的代码。

第二个测试验证 ModelState 有效时的情况:

  • 已通过存储库添加新的 BrainstormSession
  • 该方法将返回带有所需属性的 RedirectToActionResult

通常会忽略未调用的模拟调用,但在设置调用末尾调用 Verifiable 就可以在测试中进行 mock 验证。这通过对 mockRepo.Verify 的调用来完成,进行这种调用时,如果未调用所需方法,则测试将失败。

备注

通过此示例中使用的 Moq 库,可以混合可验证(或称“严格”)mock 和非可验证 mock(也称为“宽松”mock 或存根)。详细了解使用 Moq 自定义 Mock 行为

示例应用中的 SessionController 显示与特定集体讨论会话相关的信息。该控制器包含用于处理无效 id 值的逻辑(以下示例中有两个 return 方案可用来应对这些情况)。最后的 return 语句向视图 (Controllers/SessionController.cs) 返回一个新的 StormSessionViewModel

  1. public class SessionController : Controller
  2. {
  3. private readonly IBrainstormSessionRepository _sessionRepository;
  4. public SessionController(IBrainstormSessionRepository sessionRepository)
  5. {
  6. _sessionRepository = sessionRepository;
  7. }
  8. public async Task<IActionResult> Index(int? id)
  9. {
  10. if (!id.HasValue)
  11. {
  12. return RedirectToAction(actionName: nameof(Index),
  13. controllerName: "Home");
  14. }
  15. var session = await _sessionRepository.GetByIdAsync(id.Value);
  16. if (session == null)
  17. {
  18. return Content("Session not found.");
  19. }
  20. var viewModel = new StormSessionViewModel()
  21. {
  22. DateCreated = session.DateCreated,
  23. Name = session.Name,
  24. Id = session.Id
  25. };
  26. return View(viewModel);
  27. }
  28. }

单元测试包括对会话控制器 return 操作中的每个 Index 方案执行一个测试:

  1. [Fact]
  2. public async Task IndexReturnsARedirectToIndexHomeWhenIdIsNull()
  3. {
  4. // Arrange
  5. var controller = new SessionController(sessionRepository: null);
  6. // Act
  7. var result = await controller.Index(id: null);
  8. // Assert
  9. var redirectToActionResult =
  10. Assert.IsType<RedirectToActionResult>(result);
  11. Assert.Equal("Home", redirectToActionResult.ControllerName);
  12. Assert.Equal("Index", redirectToActionResult.ActionName);
  13. }
  14. [Fact]
  15. public async Task IndexReturnsContentWithSessionNotFoundWhenSessionNotFound()
  16. {
  17. // Arrange
  18. int testSessionId = 1;
  19. var mockRepo = new Mock<IBrainstormSessionRepository>();
  20. mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
  21. .ReturnsAsync((BrainstormSession)null);
  22. var controller = new SessionController(mockRepo.Object);
  23. // Act
  24. var result = await controller.Index(testSessionId);
  25. // Assert
  26. var contentResult = Assert.IsType<ContentResult>(result);
  27. Assert.Equal("Session not found.", contentResult.Content);
  28. }
  29. [Fact]
  30. public async Task IndexReturnsViewResultWithStormSessionViewModel()
  31. {
  32. // Arrange
  33. int testSessionId = 1;
  34. var mockRepo = new Mock<IBrainstormSessionRepository>();
  35. mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
  36. .ReturnsAsync(GetTestSessions().FirstOrDefault(
  37. s => s.Id == testSessionId));
  38. var controller = new SessionController(mockRepo.Object);
  39. // Act
  40. var result = await controller.Index(testSessionId);
  41. // Assert
  42. var viewResult = Assert.IsType<ViewResult>(result);
  43. var model = Assert.IsType<StormSessionViewModel>(
  44. viewResult.ViewData.Model);
  45. Assert.Equal("Test One", model.Name);
  46. Assert.Equal(2, model.DateCreated.Day);
  47. Assert.Equal(testSessionId, model.Id);
  48. }

移动到想法控制器,应用会将功能公开为 api/ideas 路由上的 Web API:

  • IdeaDTO 方法将返回与集体讨论会话关联的想法列表 (ForSession)。
  • Create 方法会向会话中添加新想法。
  1. [HttpGet("forsession/{sessionId}")]
  2. public async Task<IActionResult> ForSession(int sessionId)
  3. {
  4. var session = await _sessionRepository.GetByIdAsync(sessionId);
  5. if (session == null)
  6. {
  7. return NotFound(sessionId);
  8. }
  9. var result = session.Ideas.Select(idea => new IdeaDTO()
  10. {
  11. Id = idea.Id,
  12. Name = idea.Name,
  13. Description = idea.Description,
  14. DateCreated = idea.DateCreated
  15. }).ToList();
  16. return Ok(result);
  17. }
  18. [HttpPost("create")]
  19. public async Task<IActionResult> Create([FromBody]NewIdeaModel model)
  20. {
  21. if (!ModelState.IsValid)
  22. {
  23. return BadRequest(ModelState);
  24. }
  25. var session = await _sessionRepository.GetByIdAsync(model.SessionId);
  26. if (session == null)
  27. {
  28. return NotFound(model.SessionId);
  29. }
  30. var idea = new Idea()
  31. {
  32. DateCreated = DateTimeOffset.Now,
  33. Description = model.Description,
  34. Name = model.Name
  35. };
  36. session.AddIdea(idea);
  37. await _sessionRepository.UpdateAsync(session);
  38. return Ok(session);
  39. }

避免直接通过 API 调用返回企业域实体。域实体:

  • 包含的数据通常比客户端所需的数据更多。
  • 无需将应用的内部域模型与公开的 API 结合。

可以执行域实体与返回到客户端的类型之间的映射:

接着,示例应用会演示想法控制器的 CreateForSession API 方法的单元测试。

示例应用包含两个 ForSession 测试。第一个测试可确定 ForSession 是否返回无效会话的 NotFoundObjectResult(找不到 HTTP):

  1. [Fact]
  2. public async Task ForSession_ReturnsHttpNotFound_ForInvalidSession()
  3. {
  4. // Arrange
  5. int testSessionId = 123;
  6. var mockRepo = new Mock<IBrainstormSessionRepository>();
  7. mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
  8. .ReturnsAsync((BrainstormSession)null);
  9. var controller = new IdeasController(mockRepo.Object);
  10. // Act
  11. var result = await controller.ForSession(testSessionId);
  12. // Assert
  13. var notFoundObjectResult = Assert.IsType<NotFoundObjectResult>(result);
  14. Assert.Equal(testSessionId, notFoundObjectResult.Value);
  15. }

第二个 ForSession 测试可确定 ForSession 是否返回有效会话的会话想法列表 (<List<IdeaDTO>>)。这些测试还会检查第一个想法,以确认其 Name 属性正确:

  1. [Fact]
  2. public async Task ForSession_ReturnsIdeasForSession()
  3. {
  4. // Arrange
  5. int testSessionId = 123;
  6. var mockRepo = new Mock<IBrainstormSessionRepository>();
  7. mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
  8. .ReturnsAsync(GetTestSession());
  9. var controller = new IdeasController(mockRepo.Object);
  10. // Act
  11. var result = await controller.ForSession(testSessionId);
  12. // Assert
  13. var okResult = Assert.IsType<OkObjectResult>(result);
  14. var returnValue = Assert.IsType<List<IdeaDTO>>(okResult.Value);
  15. var idea = returnValue.FirstOrDefault();
  16. Assert.Equal("One", idea.Name);
  17. }

若要测试 Create 方法在 ModelState 无效时的行为,示例应用会在测试中将模型错误添加到控制器。请勿在单元测试中尝试测试模型有效性或模型绑定—仅测试操作方法在遇到无效 ModelState 时的行为:

  1. [Fact]
  2. public async Task Create_ReturnsBadRequest_GivenInvalidModel()
  3. {
  4. // Arrange & Act
  5. var mockRepo = new Mock<IBrainstormSessionRepository>();
  6. var controller = new IdeasController(mockRepo.Object);
  7. controller.ModelState.AddModelError("error", "some error");
  8. // Act
  9. var result = await controller.Create(model: null);
  10. // Assert
  11. Assert.IsType<BadRequestObjectResult>(result);
  12. }

Create 的第二个测试依赖存储库返回 null,所以 mock 存储库配置为返回 null无需创建测试数据库(在内存中或其他位置)并构建将返回此结果的查询。该测试可以在单个语句中完成,如示例代码所示:

  1. [Fact]
  2. public async Task Create_ReturnsHttpNotFound_ForInvalidSession()
  3. {
  4. // Arrange
  5. int testSessionId = 123;
  6. var mockRepo = new Mock<IBrainstormSessionRepository>();
  7. mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
  8. .ReturnsAsync((BrainstormSession)null);
  9. var controller = new IdeasController(mockRepo.Object);
  10. // Act
  11. var result = await controller.Create(new NewIdeaModel());
  12. // Assert
  13. Assert.IsType<NotFoundObjectResult>(result);
  14. }

第三个 Create 测试 Create_ReturnsNewlyCreatedIdeaForSession 验证调用了存储库的 UpdateAsync 方法。使用 Verifiable 调用 mock,然后调用模拟存储库的 Verify 方法,以确认执行了可验证的方法。确保 UpdateAsync 方法保存了数据不是单元测试的职责—这可以通过集成测试完成。

  1. [Fact]
  2. public async Task Create_ReturnsNewlyCreatedIdeaForSession()
  3. {
  4. // Arrange
  5. int testSessionId = 123;
  6. string testName = "test name";
  7. string testDescription = "test description";
  8. var testSession = GetTestSession();
  9. var mockRepo = new Mock<IBrainstormSessionRepository>();
  10. mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
  11. .ReturnsAsync(testSession);
  12. var controller = new IdeasController(mockRepo.Object);
  13. var newIdea = new NewIdeaModel()
  14. {
  15. Description = testDescription,
  16. Name = testName,
  17. SessionId = testSessionId
  18. };
  19. mockRepo.Setup(repo => repo.UpdateAsync(testSession))
  20. .Returns(Task.CompletedTask)
  21. .Verifiable();
  22. // Act
  23. var result = await controller.Create(newIdea);
  24. // Assert
  25. var okResult = Assert.IsType<OkObjectResult>(result);
  26. var returnSession = Assert.IsType<BrainstormSession>(okResult.Value);
  27. mockRepo.Verify();
  28. Assert.Equal(2, returnSession.Ideas.Count());
  29. Assert.Equal(testName, returnSession.Ideas.LastOrDefault().Name);
  30. Assert.Equal(testDescription, returnSession.Ideas.LastOrDefault().Description);
  31. }

测试 ActionResult<T>Test ActionResult<T>

在 ASP.NET Core 2.1 或更高版本中,ActionResult<T> (ActionResult<TValue>) 支持返回从 ActionResult 派生的类型或返回特定类型。

示例应用包含将返回给定会话 List<IdeaDTO>id 的方法。如果会话 id 不存在,控制器将返回 NotFound

  1. [HttpGet("forsessionactionresult/{sessionId}")]
  2. [ProducesResponseType(200)]
  3. [ProducesResponseType(404)]
  4. public async Task<ActionResult<List<IdeaDTO>>> ForSessionActionResult(int sessionId)
  5. {
  6. var session = await _sessionRepository.GetByIdAsync(sessionId);
  7. if (session == null)
  8. {
  9. return NotFound(sessionId);
  10. }
  11. var result = session.Ideas.Select(idea => new IdeaDTO()
  12. {
  13. Id = idea.Id,
  14. Name = idea.Name,
  15. Description = idea.Description,
  16. DateCreated = idea.DateCreated
  17. }).ToList();
  18. return result;
  19. }

ForSessionActionResult 中包含 ApiIdeasControllerTests 控制器的两个测试。

第一个测试可确认控制器将返回 ActionResult,而不是不存在会话 id 的不存在想法列表:

  1. [Fact]
  2. public async Task ForSessionActionResult_ReturnsNotFoundObjectResultForNonexistentSession()
  3. {
  4. // Arrange
  5. var mockRepo = new Mock<IBrainstormSessionRepository>();
  6. var controller = new IdeasController(mockRepo.Object);
  7. var nonExistentSessionId = 999;
  8. // Act
  9. var result = await controller.ForSessionActionResult(nonExistentSessionId);
  10. // Assert
  11. var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
  12. Assert.IsType<NotFoundObjectResult>(actionResult.Result);
  13. }

对于有效会话 id,第二个测试可确认该方法将返回:

  • 类型为 ActionResultList<IdeaDTO>
  • ActionResult.ValueList<IdeaDTO> 类型。
  • 列表中的第一项是与 mock 会话中存储的想法匹配的有效想法(通过调用 GetTestSession 获取)。
  1. [Fact]
  2. public async Task ForSessionActionResult_ReturnsIdeasForSession()
  3. {
  4. // Arrange
  5. int testSessionId = 123;
  6. var mockRepo = new Mock<IBrainstormSessionRepository>();
  7. mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
  8. .ReturnsAsync(GetTestSession());
  9. var controller = new IdeasController(mockRepo.Object);
  10. // Act
  11. var result = await controller.ForSessionActionResult(testSessionId);
  12. // Assert
  13. var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
  14. var returnValue = Assert.IsType<List<IdeaDTO>>(actionResult.Value);
  15. var idea = returnValue.FirstOrDefault();
  16. Assert.Equal("One", idea.Name);
  17. }

示例应用还包含用于为给定会话创建新的 Idea 的方法。控制器将返回:

  1. [HttpPost("createactionresult")]
  2. [ProducesResponseType(201)]
  3. [ProducesResponseType(400)]
  4. [ProducesResponseType(404)]
  5. public async Task<ActionResult<BrainstormSession>> CreateActionResult([FromBody]NewIdeaModel model)
  6. {
  7. if (!ModelState.IsValid)
  8. {
  9. return BadRequest(ModelState);
  10. }
  11. var session = await _sessionRepository.GetByIdAsync(model.SessionId);
  12. if (session == null)
  13. {
  14. return NotFound(model.SessionId);
  15. }
  16. var idea = new Idea()
  17. {
  18. DateCreated = DateTimeOffset.Now,
  19. Description = model.Description,
  20. Name = model.Name
  21. };
  22. session.AddIdea(idea);
  23. await _sessionRepository.UpdateAsync(session);
  24. return CreatedAtAction(nameof(CreateActionResult), new { id = session.Id }, session);
  25. }

CreateActionResult 中包含 ApiIdeasControllerTests 的三个测试。

第一个测试可确认将返回 BadRequest(对于无效模型)。

  1. [Fact]
  2. public async Task CreateActionResult_ReturnsBadRequest_GivenInvalidModel()
  3. {
  4. // Arrange & Act
  5. var mockRepo = new Mock<IBrainstormSessionRepository>();
  6. var controller = new IdeasController(mockRepo.Object);
  7. controller.ModelState.AddModelError("error", "some error");
  8. // Act
  9. var result = await controller.CreateActionResult(model: null);
  10. // Assert
  11. var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
  12. Assert.IsType<BadRequestObjectResult>(actionResult.Result);
  13. }

第二个测试可确认将返回 NotFound(如果会话不存在)。

  1. [Fact]
  2. public async Task CreateActionResult_ReturnsNotFoundObjectResultForNonexistentSession()
  3. {
  4. // Arrange
  5. var nonExistentSessionId = 999;
  6. string testName = "test name";
  7. string testDescription = "test description";
  8. var mockRepo = new Mock<IBrainstormSessionRepository>();
  9. var controller = new IdeasController(mockRepo.Object);
  10. var newIdea = new NewIdeaModel()
  11. {
  12. Description = testDescription,
  13. Name = testName,
  14. SessionId = nonExistentSessionId
  15. };
  16. // Act
  17. var result = await controller.CreateActionResult(newIdea);
  18. // Assert
  19. var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
  20. Assert.IsType<NotFoundObjectResult>(actionResult.Result);
  21. }

对于有效会话 id,最后一个测试可确认:

  • 该方法将返回类型为 ActionResultBrainstormSession
  • ActionResult.ResultCreatedAtActionResultCreatedAtActionResult 类似于包含 _标头的_201 CreatedLocation 响应。
  • ActionResult.ValueBrainstormSession 类型。
  • 调用了用于更新会话 UpdateAsync(testSession) 的 mock 调用。通过执行断言中的 Verifiable 来检查 mockRepo.Verify() 方法调用。
  • 将返回该会话的两个 Idea 对象。
  • 最后一项(通过对 Idea 的 mock 调用而添加的 UpdateAsync)与添加到测试中的会话的 newIdea 匹配。
  1. [Fact]
  2. public async Task CreateActionResult_ReturnsNewlyCreatedIdeaForSession()
  3. {
  4. // Arrange
  5. int testSessionId = 123;
  6. string testName = "test name";
  7. string testDescription = "test description";
  8. var testSession = GetTestSession();
  9. var mockRepo = new Mock<IBrainstormSessionRepository>();
  10. mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
  11. .ReturnsAsync(testSession);
  12. var controller = new IdeasController(mockRepo.Object);
  13. var newIdea = new NewIdeaModel()
  14. {
  15. Description = testDescription,
  16. Name = testName,
  17. SessionId = testSessionId
  18. };
  19. mockRepo.Setup(repo => repo.UpdateAsync(testSession))
  20. .Returns(Task.CompletedTask)
  21. .Verifiable();
  22. // Act
  23. var result = await controller.CreateActionResult(newIdea);
  24. // Assert
  25. var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
  26. var createdAtActionResult = Assert.IsType<CreatedAtActionResult>(actionResult.Result);
  27. var returnValue = Assert.IsType<BrainstormSession>(createdAtActionResult.Value);
  28. mockRepo.Verify();
  29. Assert.Equal(2, returnValue.Ideas.Count());
  30. Assert.Equal(testName, returnValue.Ideas.LastOrDefault().Name);
  31. Assert.Equal(testDescription, returnValue.Ideas.LastOrDefault().Description);
  32. }

其他资源Additional resources