一个单元如何测试.NET MVC 控制器?

我在寻求有关.NET mvc 控制器的有效单元测试的建议。

在我工作的地方,许多这样的测试使用 moq 来模拟数据层,并断言某些数据层方法被调用。这对我来说似乎没有用处,因为它实际上是验证实现没有改变,而不是测试 API。

我也读过一些文章,推荐一些事情,比如检查返回的视图模型的类型是否正确。我可以看到它提供了一些价值,但是仅凭这一点似乎不值得编写许多模仿代码(我们的应用程序的数据模型非常庞大和复杂)。

Can anyone suggest some better approaches to controller unit testing or explain why the above approaches are valid/useful?

谢谢!

76914 次浏览

控制器单元测试应该测试操作方法中的代码算法,而不是数据层中的代码算法。这是嘲笑这些数据服务的一个原因。控制器期望从存储库/服务等接收某些值,并在从这些存储库/服务接收不同的信息时采取不同的行为。

您编写单元测试来断言控制器在非常特定的场景/环境中以非常特定的方式行为。您的数据层是应用程序的一部分,它为控制器/操作方法提供这些环境。断言某个服务方法被控制器调用是有价值的,因为您可以确定控制器从另一个地方获取信息。

Checking the type of the viewmodel returned is valuable because, if the wrong type of viewmodel is returned, MVC will throw a runtime exception. You can prevent this from happening in production by running a unit test. If the test fails, then the view may throw an exception in production.

单元测试可能很有价值,因为它们使重构变得更加容易。您可以更改实现,并通过确保所有单元测试都通过来断言行为仍然相同。

回答第一条评论

如果更改正在测试的方法的实现要求更改/删除较低层的模拟方法,那么单元测试也必须更改。然而,这种情况不应该像你想象的那样经常发生。

The typical red-green-refactor workflow calls for writing your unit tests before writing the methods they test. (This means for a brief amount of time, your test code won't compile, and is why many young / inexperienced developers have difficulty adopting red green refactor.)

如果您先编写单元测试,那么您就会知道控制器需要从较低的层获取信息。你怎么能确定它会试图获取那些信息呢?通过模拟提供信息的下层方法,并断言控制器调用了下层方法。

当我使用“更改实现”这个术语时,我可能说错了当控制器的操作方法和相应的单元测试必须改变或删除模拟方法时,您实际上是在改变控制器的行为。根据定义,重构意味着在不改变整体行为和预期结果的情况下更改实现。

红-绿-重构是一种质量保证方法,有助于在代码中出现 bug 和缺陷之前防止它们的出现。通常情况下,开发人员会在 bug 出现后更改实现以删除它们。因此,重申一下,你所担心的情况不应该像你想象的那样频繁发生。

通常,当您谈论单元测试时,您测试的是一个单独的过程或方法,而不是整个系统,同时试图消除所有的外部依赖关系。

换句话说,在测试控制器时,您正在一个方法一个方法地编写测试,甚至不需要加载视图或模型,这些是您应该“模拟”的部分。然后可以更改模拟以返回在其他测试中难以重现的值或错误。

单元测试的要点是基于一组条件隔离地测试方法的行为。您可以使用 mock 设置测试的条件,并通过检查方法如何与周围的其他代码交互来断言该方法的行为——检查它试图调用哪些外部方法,特别是通过检查给定条件下它返回的值。

因此,对于返回 ActionResults 的 Controller 方法,检查返回的 ActionResult 的值非常有用。

使用 Moq 查看 ’为控制器创建单元测试’ 这里有一些非常清楚的例子部分。

下面是该页面的一个很好的示例,它测试当 Controller 尝试创建联系人记录但失败时是否返回适当的视图。

[TestMethod]
public void CreateInvalidContact()
{
// Arrange
var contact = new Contact();
_service.Expect(s => s.CreateContact(contact)).Returns(false);
var controller = new ContactController(_service.Object);


// Act
var result = (ViewResult)controller.Create(contact);


// Assert
Assert.AreEqual("Create", result.ViewName);
}

I don't see much point in unit testing the controller, since it is usually just a piece of code that connects other pieces. Unit testing it typically includes lots of mocking and just verifies that the other services are connected correctly. The test itself is a reflection of the implementing code.

我更喜欢集成测试——我不从具体的控制器开始,而是从一个 Url 开始,并验证返回的 Model 具有正确的值。在 伊芳娜的帮助下,这个测试可能看起来像:

var response = new TestSession().Get("/Users/List");
Assert.IsInstanceOf<UserListModel>(response.Model);


var model = (UserListModel) response.Model;
Assert.AreEqual(1, model.Users.Count);

I can mock the database access, but I prefer a different approach: setup an in-memory instance of SQLite, and recreate it with each new test, together with the required data. It makes my tests fast enough, but instead of complicated mocking, I make them clear, e.g. just create and save a User instance, rather than mock the UserService (which might be an implementation detail).

你应该首先节食。然后你可以用 玩得开心单元测试它们。如果他们很胖,而且你已经把你所有的业务逻辑都塞进去了,我同意你会在你的单元测试中嘲笑他们,抱怨这是在浪费时间。

当你谈论复杂的逻辑时,这并不一定意味着这个逻辑不能分成不同的层,每个方法都要单独测试。

Yes, you should test all the way to the DB. The time you put into mocking is less and the value you get from mocking is very less too(80% of likely errors in your system cannot be picked by mocking).

这是一篇讨论 集成测试优于单元测试的好处的伟大文章,因为“单元测试杀死!”(它说)

当您测试从控制器到数据库或 Web 服务的所有方式,那么它不叫单元测试,而是集成测试。我个人相信集成测试,而不是单元测试,即使它们的用途不同。.即使我们同时需要单元测试和集成测试。“时间限制”是真实存在的,因此同时编写这两个条件是不切实际的,因此只需要单独编写集成测试即可。而且我能够通过集成测试(场景测试)成功地完成测试驱动开发。

我们的团队是这样运作的。最初的每个测试类都会重新生成 DB,并用最少的数据集(例如: 用户角色)填充/种子表。根据控制器的需要,我们填充数据库并验证控制器是否完成任务。这种设计方式使得其他方法留下的 DB 损坏的数据永远不会在测试中失败。除了运行所需的时间,几乎所有的单元测试质量(即使它是一个理论)都是可以得到的。Time taken to sequentially run can be reduced with containers. Also with containers, we don't need to recreate DB as every test gets its own fresh DB in a container(which will be removed after the test).

在我的职业生涯中,只有2% 的情况(或者非常罕见) ,我被迫使用模拟/存根,因为不可能创建更真实的数据源。但在所有其他情况下,集成测试都是有可能的。

It took us time to reach a matured level with this approach. we have a nice framework which deals with test data population and retrieval(first class citizens). And it pays off big time! First step is to say goodbye to mocks and unit tests. If mocks do not make sense then they are not for you! Integration test gives you good sleep.

===================================

编辑后的评论如下: 演示

集成测试或功能测试必须直接处理数据库/源。不要嘲笑我。这就是步骤。你想测试 GetEmployee (emp _ id)。所有这5个步骤都是在一个单一的测试方法中完成的。

  1. 丢弃尸体

  2. 创建 DB 并填充角色和其他底层数据

  3. 使用 ID 创建员工记录

  4. 使用这个 ID 并调用 getEmployee (emp _ ID) < em >//这可以调用 api-url (这样就不需要在测试项目中维护 db 连接字符串,我们可以通过简单地更改域名来测试几乎所有的环境)

  5. 现在,Assert ()/验证返回的数据是否正确

    这证明了 GetEmployee ()的工作原理。步骤3要求只有测试项目才能使用代码。步骤4调用应用程序代码。我的意思是创建员工(步骤2)应该通过测试项目代码而不是应用程序代码来完成。如果有一个应用程序代码来创建员工(例如: CreateEmployee ()) ,那么这不应该被使用。同样,当我们测试 CreateEmployee ()时,不应该使用 GetEmployee ()应用程序代码。我们应该有一个从表中获取数据的测试项目代码。

这样就不会被嘲笑了!删除和创建 DB 的原因是防止 DB 拥有损坏的数据。使用我们的方法,无论我们运行它多少次,测试都会通过。

特别提示: 在步骤5中,getEmployee ()返回一个雇员对象。如果以后开发人员删除或更改字段名,则测试中断。如果开发人员稍后添加一个新字段会怎样?他/她忘记为它添加一个测试(断言) ?测试没有结果。解决方案是添加一个字段计数检查。Employee 对象有4个字段(姓,名,名称,性别)。因此,员工对象的字段数断言为4。因此,当添加新字段时,我们的测试将由于计数而失败,并提醒开发人员为新添加的字段添加断言字段。

对于 ASP.NET Core,我通常遵循以下指南:

Https://learn.microsoft.com/en-us/aspnet/core/mvc/controllers/testing?view=aspnetcore-5.0

Code samples:

文件/ https://github.com/dotnet/aspnetcore /主/aspnetcore/mvc/控制器/测试/样本/

例如:

总监:

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));
}
}

单元测试:

[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());
}