我正在寻找有关.NET mvc控制器的有效单元测试的建议。
在我工作的地方,许多此类测试使用moq来模拟数据层并断言某些数据层方法被调用。这对我来说似乎没有用,因为它基本上验证了实现没有改变而不是测试API。
我还阅读了一些文章,建议检查返回的视图模型的类型是否正确。我可以看到提供一些价值,但单独看起来不值得编写许多行代码的努力(我们的应用程序的数据模型非常庞大和复杂)。
任何人都可以建议一些更好的控制器单元测试方法或解释为什么上述方法有效/有用吗?
谢谢!
答案 0 :(得分:46)
控制器单元测试应测试操作方法中的代码算法,而不是数据层中的代码算法。这是模拟这些数据服务的一个原因。控制器期望从存储库/服务/等接收某些值,并且当它从它们接收不同信息时采取不同的行为。
您编写单元测试以断言控制器在非常特定的场景/环境中以非常特定的方式运行。您的数据层是应用程序的一部分,它将这些情况提供给控制器/操作方法。断言控制器调用服务方法很有价值,因为您可以确定控制器从其他位置获取信息。
检查返回的viewmodel的类型很有价值,因为如果返回错误类型的viewmodel,MVC将抛出运行时异常。您可以通过运行单元测试来防止在生产中发生这种情况。如果测试失败,则视图可能会在生产中抛出异常。
单元测试很有价值,因为它们使重构变得更加容易。您可以更改实现,并通过确保所有单元测试通过来断言行为仍然相同。
回答评论#1
如果更改被测方法的实现需要更改/删除下层模拟方法,则单元测试也必须更改。但是,这不应该像你想象的那样经常发生。
典型的红绿重构工作流程要求编写单元测试之前编写测试方法。 (这意味着在很短的时间内,您的测试代码将无法编译,这也是许多年轻/缺乏经验的开发人员难以采用红绿色重构的原因。)
如果您先编写单元测试,那么您将知道控制器需要从较低层获取信息。你怎么能确定它试图获取这些信息?通过模拟提供信息的下层方法,并断言控制器调用下层方法。
当我使用“改变实施”这个词时,我可能会错过任务。当控制器的动作方法&必须更改相应的单元测试以更改或删除模拟方法,您实际上是在改变控制器的行为。根据定义,重构意味着在不改变整体行为和预期结果的情况下改变实施。
红绿重构是一种质量保证方法,有助于防止错误和代码出现之前的缺陷。通常,开发人员会在实施后更改实施以删除错误。重申一下,你担心的案件不应该像你想象的那样频繁发生。
答案 1 :(得分:26)
你应该首先让你的控制器节食。然后你可以have fun对它们进行单元测试。如果它们很胖并且你把所有的业务逻辑都塞进其中,我同意你将在你的单元测试中传递你的生活嘲笑的东西并抱怨这是浪费时间。
当你谈到复杂的逻辑时,这并不一定意味着这个逻辑不能在不同的层中分开,每个方法都要单独进行单元测试。
答案 2 :(得分:9)
单元测试的目的是基于一组条件单独测试方法的行为。您可以使用模拟设置测试的条件,并通过检查方法与其周围的其他代码的交互方式来断言方法的行为 - 通过检查它尝试调用哪些外部方法,特别是通过检查它给出条件时返回的值。 / p>
因此,对于返回ActionResults的Controller方法,检查返回的ActionResult的值非常有用。
使用Moq查看'为控制器创建单元测试' here for some very clear examples部分。
以下是该页面的一个很好的示例,它测试当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);
}
答案 3 :(得分:9)
是的,您应该一直测试数据库。你进行模拟的时间越少,你从模拟中获得的价值就越小(你的系统中80%的可能错误都不能通过模拟来挑选)。
当您从控制器到数据库或Web服务的所有方式进行测试时,它不会被称为单元测试,而是集成测试。我个人认为集成测试与单元测试相反(即使它们都用于不同的目的)。我可以通过集成测试(场景测试)成功地进行测试驱动开发。
以下是我们团队的工作方式。开头的每个测试类都会重新生成数据库,并使用最少的数据集(例如:用户角色)填充/播种表。根据控制器的需要,我们填充DB并验证控制器是否执行了它的任务。这样设计使得其他方法留下的数据库损坏数据永远不会失败。除了时间运行之外,几乎所有单元测试的质量(即使它是一个理论)都是可以得到的。 使用容器可以减少顺序运行所需的时间。对于容器,我们不需要重新创建数据库,因为每个测试都在容器中获得自己的新数据库(在测试后将被删除)。
当我被迫使用模拟/存根时,我的职业生涯中只有2%的情况(或很少),因为无法创建更真实的数据源。但在所有其他情况下,集成测试是可能的。
我们花了一些时间用这种方法达到成熟水平。我们有一个很好的框架,处理测试数据填充和检索(一等公民)。它耗费大量时间!第一步是告别模拟和单元测试。如果嘲笑没有意义,那么它们不适合你!集成测试可以让您睡个好觉。
===================================
在以下评论后编辑:演示
集成测试或功能测试必须直接处理DB / source。没有嘲笑。所以这些是步骤。您想测试 getEmployee(emp_id)。以下所有这5个步骤都是在一个测试方法中完成的。
现在断言()/验证返回的数据是否正确
这证明 getEmployee()有效。直到3的步骤要求您只有测试项目使用的代码。第4步调用应用程序代码。我的意思是创建一个员工(第2步)应该通过测试项目代码而不是应用程序代码来完成。如果有创建员工的应用程序代码(例如: CreateEmployee()),则不应使用此代码。同样,当我们测试 CreateEmployee()时,不应使用 GetEmployee()应用程序代码。我们应该有一个测试项目代码,用于从表中获取数据。
这种方式没有嘲笑!删除和创建数据库的原因是为了防止数据库损坏数据。使用我们的方法,无论我们运行多少次,测试都会通过。
特别提示:在第5步中,getEmployee()返回一个雇员对象。如果稍后开发人员删除或更改字段名称,则测试会中断。如果开发人员稍后添加新字段会怎样?他/她忘记为它添加测试(断言)?测试不会捡起来。解决方案是添加字段计数检查。例如:Employee对象有4个字段(名字,姓氏,名称,性别)。因此,断言员工对象的字段数为4.因此,当添加新字段时,我们的测试将因计数而失败,并提醒开发人员为新添加的字段添加断言字段。
这是一篇很好的文章讨论integration testing over unit testing的好处,因为"单位测试会杀死!" (它说)
答案 4 :(得分:8)
我没有看到单元测试控制器的重点,因为它通常只是连接其他部分的一段代码。单元测试通常包括大量的模拟,只是验证其他服务是否正确连接。测试本身是实现代码的反映。
我更喜欢集成测试 - 我不是使用具体的控制器,而是使用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
(这可能是一个实现细节)。
答案 5 :(得分:1)
通常,当您谈论单元测试时,您正在测试一个单独的过程或方法,而不是整个系统,同时尝试消除所有外部依赖性。
换句话说,在测试控制器时,你是按方法编写测试方法,你甚至不需要加载视图或模型,这些是你应该“模拟”的部分。然后,您可以更改模拟以返回在其他测试中难以重现的值或错误。
答案 6 :(得分:0)
对于ASP.NET Core
,我通常遵循本指南:
https://docs.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());
}