ASP.NET Core 中的单元测试控制器逻辑Unit test controller logic in ASP.NET Core
本文内容
作者:Steve Smith
单元测试涉及通过基础结构和依赖项单独测试应用的一部分。单元测试控制器逻辑时,仅测试单个操作的内容,不测试其依赖项或框架自身的行为。
单元测试控制器Unit testing controllers
将控制器操作的单元测试设置为专注于控制器的行为。控制器单元测试将避开筛选器、路由或模型绑定等方案。涵盖共同响应请求的组件之间的交互的测试由集成测试处理。有关集成测试的详细信息,请参阅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 对象框架(如
IBrainstormSessionRepository
Moq)的模拟 服务进行测试。模拟对象是由一组预先确定的用于测试的属性和方法行为的对象。有关详细信息,请参阅集成测试简介。
HTTP GET Index
方法没有循环或分支,且仅调用一个方法。此操作的单元测试:
- 使用
IBrainstormSessionRepository
方法模拟GetTestSessions
服务。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
方法测试验证:
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);
}
}
单元测试包括对会话控制器 return
操作中的每个 Index
方案执行一个测试:
[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:
IdeaDTO
方法将返回与集体讨论会话关联的想法列表 (ForSession
)。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>Test ActionResult<T>
在 ASP.NET Core 2.1 或更高版本中,ActionResult<T> (ActionResult<TValue>) 支持返回从 ActionResult
派生的类型或返回特定类型。
示例应用包含将返回给定会话 List<IdeaDTO>
的 id
的方法。如果会话 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;
}
ForSessionActionResult
中包含 ApiIdeasControllerTests
控制器的两个测试。
第一个测试可确认控制器将返回 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
,第二个测试可确认该方法将返回:
- 类型为
ActionResult
的List<IdeaDTO>
。 - ActionResult
.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);
}
CreateActionResult
中包含 ApiIdeasControllerTests
的三个测试。
第一个测试可确认将返回 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
,最后一个测试可确认:
- 该方法将返回类型为
ActionResult
的BrainstormSession
。 - ActionResult
.Result 是 CreatedAtActionResult。CreatedAtActionResult
类似于包含 _标头的_201 CreatedLocation
响应。 - ActionResult
.Value 是BrainstormSession
类型。 - 调用了用于更新会话
UpdateAsync(testSession)
的 mock 调用。通过执行断言中的Verifiable
来检查mockRepo.Verify()
方法调用。 - 将返回该会话的两个
Idea
对象。 - 最后一项(通过对
Idea
的 mock 调用而添加的UpdateAsync
)与添加到测试中的会话的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 应用中起着核心作用。因此,应该对控制器表现达到预期怀有信心。在将应用部署到生产环境之前,自动测试可以检测到错误。
控制器逻辑的单元测试Unit tests of controller logic
单元测试涉及通过基础结构和依赖项单独测试应用的一部分。单元测试控制器逻辑时,仅测试单个操作的内容,不测试其依赖项或框架自身的行为。
将控制器操作的单元测试设置为专注于控制器的行为。控制器单元测试将避开筛选器、路由或模型绑定等方案。涵盖共同响应请求的组件之间的交互的测试由集成测试处理。有关集成测试的详细信息,请参阅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 对象框架(如
IBrainstormSessionRepository
Moq)的模拟 服务进行测试。模拟对象是由一组预先确定的用于测试的属性和方法行为的对象。有关详细信息,请参阅集成测试简介。
HTTP GET Index
方法没有循环或分支,且仅调用一个方法。此操作的单元测试:
- 使用
IBrainstormSessionRepository
方法模拟GetTestSessions
服务。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
方法测试验证:
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);
}
}
单元测试包括对会话控制器 return
操作中的每个 Index
方案执行一个测试:
[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:
IdeaDTO
方法将返回与集体讨论会话关联的想法列表 (ForSession
)。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>Test ActionResult<T>
在 ASP.NET Core 2.1 或更高版本中,ActionResult<T> (ActionResult<TValue>) 支持返回从 ActionResult
派生的类型或返回特定类型。
示例应用包含将返回给定会话 List<IdeaDTO>
的 id
的方法。如果会话 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;
}
ForSessionActionResult
中包含 ApiIdeasControllerTests
控制器的两个测试。
第一个测试可确认控制器将返回 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
,第二个测试可确认该方法将返回:
- 类型为
ActionResult
的List<IdeaDTO>
。 - ActionResult
.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);
}
CreateActionResult
中包含 ApiIdeasControllerTests
的三个测试。
第一个测试可确认将返回 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
,最后一个测试可确认:
- 该方法将返回类型为
ActionResult
的BrainstormSession
。 - ActionResult
.Result 是 CreatedAtActionResult。CreatedAtActionResult
类似于包含 _标头的_201 CreatedLocation
响应。 - ActionResult
.Value 是BrainstormSession
类型。 - 调用了用于更新会话
UpdateAsync(testSession)
的 mock 调用。通过执行断言中的Verifiable
来检查mockRepo.Verify()
方法调用。 - 将返回该会话的两个
Idea
对象。 - 最后一项(通过对
Idea
的 mock 调用而添加的UpdateAsync
)与添加到测试中的会话的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);
}
其他资源Additional resources
- ASP.NET Core 中的集成测试
- 使用 Visual Studio 创建和运行单元测试
- MyTested.AspNetCore.Mvc - ASP.NET Core MVC 的 Fluent 测试库 – 强类型单元测试库,提供用于测试 MVC 和 Web API 应用的 Fluent 界面。(不由 Microsoft 进行支持或维护。)