简体   繁体   English

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

[英]How should one unit test a .NET MVC controller?

I'm looking for advice regarding effective unit testing of .NET mvc controllers.我正在寻找有关 .NET mvc 控制器有效单元测试的建议。

Where I work, many such tests use moq to mock the data layer and to assert that certain data-layer methods are called.在我工作的地方,许多这样的测试使用 moq 来模拟数据层并断言某些数据层方法被调用。 This doesn't seem useful to me, since it essentially verifies that the implementation has not changed rather than testing the API.这对我来说似乎没什么用,因为它本质上是验证实现没有改变,而不是测试 API。

I've also read articles recommending things like checking that the type of view model returned is correct.我还阅读了推荐诸如检查返回的视图模型类型是否正确的文章。 I can see that providing some value, but alone it doesn't seem to merit the effort of writing many lines of mocking code (our application's data model is very large and complex).我可以看到它提供了一些价值,但仅凭它似乎并不值得编写多行模拟代码(我们的应用程序的数据模型非常庞大和复杂)。

Can anyone suggest some better approaches to controller unit testing or explain why the above approaches are valid/useful?谁能建议一些更好的控制器单元测试方法或解释为什么上述方法有效/有用?

Thanks!谢谢!

A controller unit test should test the code algorithms in your action methods, not in your data layer.控制器单元测试应该测试您的操作方法中的代码算法,而不是您的数据层。 This is one reason to mock those data services.这是模拟这些数据服务的原因之一。 The controller expects to receive certain values from repositories / services / etc, and to act differently when it receives different information from them.控制器期望从存储库/服务/等接收某些值,并在从它们接收不同的信息时采取不同的行动。

You write unit tests to assert the controller behaves in very specific ways in very specific scenarios / circumstances.您编写单元测试来断言控制器在非常特定的场景/情况下以非常特定的方式运行。 Your data layer is one piece of the app that provides those circumstances to the controller / action methods.您的数据层是为控制器/操作方法提供这些情况的应用程序的一部分。 Asserting that a service method was called by the controller is valuable because you can be certain that the controller gets the information from another place.断言服务方法被控制器调用是有价值的,因为您可以确定控制器从另一个地方获取信息。

Checking the type of the viewmodel returned is valuable because, if the wrong type of viewmodel is returned, MVC will throw a runtime exception.检查返回的视图模型的类型是有价值的,因为如果返回错误的视图模型类型,MVC 将抛出运行时异常。 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.如果测试失败,那么视图可能会在生产中抛出异常。

Unit tests can be valuable because they make refactoring much easier.单元测试很有价值,因为它们使重构更容易。 You can change the implementation, and assert that the behavior is still the same by making sure all of the unit tests pass.您可以更改实现,并通过确保所有单元测试通过来断言行为仍然相同。

Answer to comment #1回复评论 #1

If changing the implementation of a method-under-test calls for the change / removal of a lower-layer mocked method, then the unit test must also change.如果更改被测方法的实现需要更改/删除下层模拟方法,那么单元测试也必须更改。 However, this shouldn't happen as often as you may think.但是,这不应该像您想象的那样经常发生。

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.) (这意味着在短时间内,您的测试代码将无法编译,这也是许多年轻/缺乏经验的开发人员难以采用红绿重构的原因。)

If you write your unit tests first, you will come to a point where you know the controller needs to get information from a lower layer.如果您首先编写单元测试,您将知道控制器需要从较低层获取信息。 How can you be certain it tries to get that information?您如何确定它会尝试获取该信息? By mocking out the lower layer method that provides the information, and asserting that the lower-layer method is invoked by the controller.通过模拟提供信息的下层方法,并断言控制器调用了下层方法。

I may have misspoke when I used the term "changing implementation."当我使用“改变实施”这个词时,我可能说错了。 When a controller's action method & corresponding unit test must be altered to change or remove a mocked method, you are really changing the behavior of the controller.当必须更改控制器的操作方法和相应的单元测试以更改或删除模拟方法时,您实际上是在更改控制器的行为。 Refactoring, by definition, means changing the implementation without altering the overall behavior and expected results.根据定义,重构意味着在不改变整体行为和预期结果的情况下改变实现。

Red-green-refactor is a Quality Assurance approach that helps prevent bugs & defects in code before they ever appear.红绿重构是一种质量保证方法,有助于防止代码中的错误和缺陷出现。 Typically developers change implementation to remove bugs after they appear.通常,开发人员会在错误出现后更改实现以将其删除。 So to reiterate, the cases you are worried about should not happen as often as you think.所以重申一下,你担心的情况不应该像你想象的那样经常发生。

You should first put your controllers on a diet.你应该首先让你的控制器节食。 Then you can have fun unit testing them.然后你就可以愉快地对它们进行单元测试了。 If they are fat and you have stuffed all your business logic inside them, I agree that you will be passing your life mocking stuff around in your unit tests and complaining that this is a waste of time.如果它们很胖并且你已经把所有的业务逻辑都塞进它们里面,我同意你会在你的单元测试中传递你的生活嘲笑的东西,并抱怨这是浪费时间。

When you talk about complex logic, this doesn't necessarily mean that this logic cannot be separated in different layers and each method be unit tested in isolation.当你谈论复杂的逻辑时,这并不一定意味着这个逻辑不能在不同的层中分离并且每个方法都单独进行单元测试。

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).您投入到模拟中的时间更少,并且您从模拟中获得的价值也非常少(系统中 80% 的可能错误无法通过模拟来选择)。

When you test all the way from a controller to DB or web service then it is not called unit testing but integration testing.当您从控制器一直测试到数据库或 Web 服务时,它不称为单元测试,而是集成测试。 I personally believe in integration testing as opposed to unit testing(even though they both serve different purposes).我个人相信集成测试而不是单元测试(即使它们都用于不同的目的)。 And I am able to do test-driven development successfully with integration tests(scenario testing).而且我能够通过集成测试(场景测试)成功地进行测试驱动开发。

Here is how it works for our team.这是我们团队的工作方式。 Every test class in the beginning regenerates DB and populates/seeds the tables with minimum set of data(eg: user roles).开始时的每个测试类都会重新生成数据库并使用最少的数据集(例如:用户角色)填充/播种表。 Based on a controllers need we populate DB and verify if the controller does it's task.基于控制器的需要,我们填充数据库并验证控制器是否完成它的任务。 This is designed in such a way that DB corrupt data left by other methods will never fail a test.这样做的目的是使其他方法留下的 DB 损坏数据永远不会使测试失败。 Except time take to run, pretty much all qualities of unit test(even though it is a theory) are gettable.除了运行所需的时间之外,几乎所有单元测试的质量(即使它是一个理论)都是可以获得的。 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).同样对于容器,我们不需要重新创建数据库,因为每个测试都会在容器中获得自己的新数据库(将在测试后删除)。

There were only 2% situations(or very rarely) in my career when I was forced to use mocks/stubs as it was not possible to create a more realistic data source.在我的职业生涯中,只有 2% 的情况(或很少)当我被迫使用模拟/存根时,因为无法创建更真实的数据源。 But in all other situations integration tests was a possibility.但在所有其他情况下,集成测试是可能的。

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.集成测试让您睡个好觉。

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

Edited after a comment below: Demo在下面的评论后编辑:演示

Integration test or functional test has to deal with DB/source directly.集成测试或功能测试必须直接处理 DB/源。 No mocks.没有嘲笑。 So these are the steps.所以这些是步骤。 You want to test getEmployee( emp_id) .您想测试getEmployee( emp_id) all these 5 steps below are done in a single test method.下面的所有这 5 个步骤都是在一个测试方法中完成的。

  1. Drop DB删除数据库

  2. Create DB and populate roles and other infra data创建数据库并填充角色和其他基础设施数据

  3. Create an employee record with ID创建带有 ID 的员工记录

  4. Use this ID and call getEmployee(emp_id) // this could an api-url call (that way db connection string need not be maintained in a test project, and we could test almost all environment by simply changing domain names)使用这个ID并调用getEmployee(emp_id) //这可以是一个api-url调用(这样db连接字符串就不需要在测试项目中维护,我们只需更改域名就可以测试几乎所有环境)

  5. Now Assert()/ Verify if the returned data is correct现在 Assert()/ 验证返回的数据是否正确

    This proves that getEmployee() works .这证明getEmployee()有效。 Steps until 3 requires you to have code used only by test project.第 3 步之前的步骤要求您拥有仅供测试项目使用的代码。 Step 4 calls the application code.第 4 步调用应用程序代码。 What I meant is creating an employee (step 2) should be done by test project code not application code.我的意思是创建员工(第 2 步)应该通过测试项目代码而不是应用程序代码来完成。 If there is an application code to create employee (eg: CreateEmployee() ) then this should not be used.如果有用于创建员工的应用程序代码(例如: CreateEmployee() ),则不应使用此代码。 Same way, when we test CreateEmployee() then GetEmployee() application code should not be used.同样,当我们测试CreateEmployee() 时,不应使用GetEmployee()应用程序代码。 We should have a test project code for fetching data from a table.我们应该有一个用于从表中获取数据的测试项目代码。

This way there are no mocks!这样就没有嘲笑了! The reason to drop and create DB is to prevent DB from having corrupt data.删除和创建 DB 的原因是为了防止 DB 有损坏的数据。 With our approach, the test will pass no matter how many times we run it.使用我们的方法,无论我们运行多少次,测试都会通过。

Special Tip: In step 5 getEmployee() returns an employee object.特别提示:在第 5 步 getEmployee() 返回一个员工对象。 If later a developer removes or changes a field name the test breaks.如果稍后开发人员删除或更改字段名称,则测试中断。 What if a developer adds a new field later?如果开发人员稍后添加新字段怎么办? And he/she forgets to add a test for it (assert)?他/她忘记为其添加测试(断言)? Test would not pick it up.测试不会捡起来。 The solution is to add a field count check.解决方案是添加字段计数检查。 eg: Employee object has 4 fields (First Name, Last Name, Designation, Sex).例如:Employee 对象有 4 个字段(First Name、Last Name、Designation、Sex)。 So Assert number of fields of employee object is 4. So when new field is added our test will fail because of the count and reminds the developer to add an assert field for the newly added field.因此员工对象的Assert 字段数为4。所以当添加新字段时,我们的测试会因为计数失败并提醒开发人员为新添加的字段添加一个assert 字段。

And this is a great article discussing the benefits of integration testing over unit testing because "unit testing kills!"这是一篇很棒的文章,讨论了集成测试相对于单元测试的好处,因为“单元测试会杀死!” (it says) (它说)

The point of a unit test is to test the behaviour of a method in isolation, based on a set of conditions.单元测试的重点是根据一组条件单独测试方法的行为。 You set the conditions of the test using mocks, and assert the method's behaviour by checking how it interacts with other code around it -- by checking which external methods it tries to call, but particularly by checking the value it returns given the conditions.您使用模拟设置测试的条件,并通过检查它如何与周围的其他代码交互来断言方法的行为——通过检查它尝试调用哪些外部方法,但特别是通过检查给定条件下它返回的值。

So in the case of Controller methods, which return ActionResults, it is very useful to inspect the value of the returned ActionResult.因此,对于返回 ActionResults 的 Controller 方法,检查返回的 ActionResult 的值非常有用。

Have a look at the section 'Creating Unit Tests for Controllers' here for some very clear examples using Moq.查看此处的“为控制器创建单元测试”部分,了解使用 Moq 的一些非常清晰的示例

Here is a nice sample from that page which tests that an appropriate view is returned when the Controller attempts to create a contact record and it fails.这是来自该页面的一个很好的示例,它测试当控制器尝试创建联系人记录但失败时返回适当的视图。

[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.测试本身是实现代码的反映。

I prefer integration tests -- I start not with a concrete controller, but with an Url, and verify that the returned Model has the correct values.我更喜欢集成测试——我不是从一个具体的控制器开始,而是从一个 Url 开始,并验证返回的模型是否具有正确的值。 With the help of Ivonna , the test might look like:Ivonna的帮助下,测试可能如下所示:

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.我可以模拟数据库访问,但我更喜欢不同的方法:设置 SQLite 的内存实例,并在每个新测试中重新创建它,以及所需的数据。 It makes my tests fast enough, but instead of complicated mocking, I make them clear, eg just create and save a User instance, rather than mock the UserService (which might be an implementation detail).它使我的测试足够快,但不是复杂的模拟,而是让它们清楚,例如,只需创建并保存一个 User 实例,而不是模拟UserService (这可能是一个实现细节)。

Usually when you're talking about unit tests, you're testing one individual procedure or method, not an entire system, while trying to eliminate all external dependencies.通常,当您谈论单元测试时,您正在测试一个单独的过程或方法,而不是整个系统,同时试图消除所有外部依赖项。

In other words, when testing the controller, you're writing tests method by method and you should not need to even have the view or model loaded, those are the parts you should "mock out".换句话说,在测试控制器时,您正在逐个编写测试方法,您甚至不需要加载视图或模型,这些是您应该“模拟”的部分。 You can then change the mocks to return values or errors that are hard to reproduce in other testing.然后,您可以更改模拟以返回在其他测试中难以重现的值或错误。

I usually follow this guide for ASP.NET Core :我通常遵循ASP.NET Core本指南:

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

Code samples:代码示例:

https://github.com/dotnet/AspNetCore.Docs/tree/master/aspnetcore/mvc/controllers/testing/samples/ https://github.com/dotnet/AspNetCore.Docs/tree/master/aspnetcore/mvc/controllers/testing/samples/

Example:例子:

Controller:控制器:

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

Unit test:单元测试:

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

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM