一个单元应该如何测试.NET MVC控制器
本文关键字:控制器 测试 NET 何测试 MVC 一个 单元 | 更新日期: 2023-09-27 18:19:55
我正在寻找有关.NET mvc控制器的有效单元测试的建议。
在我工作的地方,许多这样的测试都使用moq来模拟数据层,并断言调用了某些数据层方法。这对我来说似乎没有用,因为它本质上验证了实现没有改变,而不是测试API。
我还读过一些文章,推荐检查返回的视图模型类型是否正确。我可以看到这提供了一些价值,但仅凭这一点似乎不值得写很多行模拟代码(我们的应用程序的数据模型非常庞大和复杂)。
有人能提出一些更好的控制器单元测试方法吗?或者解释为什么上述方法有效/有用?
谢谢!
控制器单元测试应该在操作方法中测试代码算法,而不是在数据层中。这就是嘲笑那些数据服务的原因之一。控制器期望从存储库/服务等接收某些值,并在从它们接收不同信息时采取不同的行动。
您编写单元测试来断言控制器在非常特定的场景/环境中以非常特定的方式运行。您的数据层是为控制器/操作方法提供这些情况的应用程序的一部分。断言服务方法是由控制器调用的是有价值的,因为您可以确定控制器从另一个地方获得信息。
检查返回的视图模型的类型很有价值,因为如果返回了错误类型的视图模型,MVC将引发运行时异常。您可以通过运行单元测试来防止这种情况在生产中发生。如果测试失败,那么视图可能会在生产中引发异常。
单元测试可能很有价值,因为它们使重构更加容易。您可以更改实现,并通过确保所有单元测试都通过来断言行为仍然相同
对评论#1的回答
如果更改被测方法的实现需要更改/删除较低层的模拟方法,那么单元测试也必须更改。然而,这种情况不应该像你想象的那样经常发生。
典型的红-绿重构工作流要求在编写单元测试之前编写单元测试。(这意味着在很短的一段时间内,你的测试代码不会编译,这就是为什么许多年轻/缺乏经验的开发人员很难采用红绿重构的原因。)
如果您先编写单元测试,您就会知道控制器需要从较低层获取信息。你怎么能确定它试图获得这些信息?通过模拟出提供信息的较低层方法,并断言控制器调用了较低层的方法。
当我使用术语"更改实现"时,我可能说错了;必须更改相应的单元测试以更改或删除模拟方法,这实际上是在更改控制器的行为。根据定义,重构意味着在不改变整体行为和预期结果的情况下更改实现。
红绿重构是一种有助于防止错误的质量保证方法;代码中的缺陷在出现之前。通常情况下,开发人员会在bug出现后更改实现以删除它们。因此,重申一下,你担心的情况不应该像你想象的那样经常发生。
你应该首先让你的控制器节食。然后你可以对它们进行有趣的单元测试。如果它们很胖,而你把所有的业务逻辑都塞进了它们里面,我同意你会在单元测试中嘲笑一些东西,并抱怨这是浪费时间。
当你谈论复杂逻辑时,这并不一定意味着这个逻辑不能在不同的层中分离,每个方法都要单独进行单元测试。
是的,您应该一直测试到DB。你在模拟中投入的时间更少,从模拟中获得的价值也更少(系统中80%的可能错误无法通过模拟来识别)。
这是一篇伟大的文章,讨论了集成测试相对于单元测试的好处;单元测试击杀"(上面写着)
当您测试从控制器到数据库或web服务的所有过程时,它不被称为单元测试,而是集成测试。我个人认为集成测试和单元测试是对立的,尽管它们都有不同的用途。。尽管我们需要单元测试和集成测试"时间限制"是真实存在的,因此编写这两种测试都是不切实际的,因此只坚持集成测试。我能够通过集成测试(场景测试)成功地进行测试驱动的开发。
以下是它对我们团队的作用。一开始的每个测试类都会重新生成DB,并用最小的数据集(例如:用户角色)填充/种子化表。根据控制器的需要,我们填充数据库并验证控制器是否完成了任务。这是以这样一种方式设计的,即其他方法留下的数据库损坏数据永远不会通过测试。除了运行所需的时间外,单元测试的几乎所有质量(即使它是一种理论)都是可以获得的使用容器可以减少按顺序运行所需的时间。同样,对于容器,我们不需要重新创建DB,因为每个测试都会在容器中获得自己的新DB(测试后会删除)
在我的职业生涯中,只有2%的情况(或者很少)我被迫使用mock/stub,因为不可能创建更真实的数据源。但在所有其他情况下,集成测试都是可能的。
我们花了一些时间才用这种方法达到一个成熟的水平。我们有一个很好的框架来处理测试数据的填充和检索(一等公民)。它得到了巨大的回报!第一步是告别mock和单元测试。如果嘲笑没有意义,那么它们不适合你!综合测试能让你睡个好觉。
====================================
在以下评论后编辑:演示
集成测试或功能测试必须直接处理数据库/源代码。没有模仿。这些就是步骤。您想要测试getEmployee(emp_id)。以下这5个步骤都是在一个单独的测试方法中完成的。
-
删除数据库
-
创建数据库并填充角色和其他基础数据
-
创建ID为的员工记录
-
使用此ID并调用getEmployee(emp_ID)//这可能是一个api url调用(这样一来,数据库连接字符串就不需要在测试项目中维护,我们只需更改域名就可以测试几乎所有的环境)
-
现在断言()/验证返回的数据是否正确
这证明getEmployee()是有效的。步骤3要求您的代码只能由测试项目使用。步骤4调用应用程序代码。我的意思是创建一个雇员(步骤2)应该通过测试项目代码而不是应用程序代码来完成。如果有创建雇员的应用程序代码(例如:CreateEmployee()),则不应使用该代码。同样,当我们测试CreateEmployee()时,不应该使用GetEmployee()用程序代码。我们应该有一个用于从表中获取数据的测试项目代码。
这样就没有嘲笑了!删除并创建数据库的原因是为了防止数据库中存在损坏的数据。使用我们的方法,无论我们运行多少次,测试都会通过
特别提示:在步骤5中,getEmployee()返回一个employee对象。如果稍后开发人员删除或更改字段名,则测试将中断。如果开发人员稍后添加一个新字段,该怎么办?他/她忘记为它添加一个测试(断言)?测试不起作用。解决方案是添加字段计数检查。例如:Employee对象有4个字段(名字、姓氏、职务、性别)。所以断言employee对象的字段数为4。因此,当添加新字段时,我们的测试将因计数而失败,并提醒开发人员为新添加的字段添加断言字段。
单元测试的目的是根据一组条件孤立地测试方法的行为。您可以使用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);
}
我认为单元测试控制器没有多大意义,因为它通常只是连接其他部分的一段代码。单元测试通常包括大量的嘲讽,只是验证其他服务是否正确连接。测试本身是实现代码的反映。
我更喜欢集成测试——我不是从一个具体的控制器开始,而是从一个Url开始,并验证返回的Model是否具有正确的值。在Ivonna的帮助下,测试可能看起来像:
var response = new TestSession().Get("/Users/List");
Assert.IsInstanceOf<UserListModel>(response.Model);
var model = (UserListModel) response.Model;
Assert.AreEqual(1, model.Users.Count);
我可以模拟数据库访问,但我更喜欢一种不同的方法:设置一个SQLite的内存实例,并在每个新测试中重新创建它,以及所需的数据。它使我的测试足够快,但我没有进行复杂的模拟,而是使它们变得清晰,例如,只创建并保存一个User实例,而不是模拟UserService
(这可能是一个实现细节)。
通常在谈论单元测试时,您测试的是一个单独的过程或方法,而不是整个系统,同时试图消除所有外部依赖关系。
换句话说,在测试控制器时,您正在逐个方法地编写测试,甚至不需要加载视图或模型,这些都是您应该"模拟"的部分。然后,您可以更改mock以返回在其他测试中难以重现的值或错误。
我通常遵循ASP.NET Core
:的指南
https://learn.microsoft.com/en-us/aspnet/core/mvc/controllers/testing?view=aspnetcore-5.0
代码示例:
https://github.com/dotnet/AspNetCore.Docs/tree/master/aspnetcore/mvc/controllers/testing/samples/
示例:
控制器:
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());
}