4

我一直在研究NuGetGallery中单元测试的完成方式。我观察到当控制器被测试时,服务类被模拟了。这对我来说很有意义,因为在测试控制器逻辑时,我不想担心下面的架构层。在使用这种方法一段时间后,我注意到当我的服务类发生变化时,我经常在我的控制器测试中修复我的模拟。为了解决这个问题,在没有咨询比我聪明的人的情况下,我开始编写这样的测试(别担心,我还没有做到这一点):

public class PersonController : Controller
{
    private readonly LESRepository _repository;

    public PersonController(LESRepository repository)
    {
        _repository = repository;
    }

    public ActionResult Index(int id)
    {
        var model = _repository.GetAll<Person>()
            .FirstOrDefault(x => x.Id == id);

        var viewModel = new VMPerson(model);
        return View(viewModel);
    }
}

public class PersonControllerTests
{
    public void can_get_person()
    {
        var person = _helper.CreatePerson(username: "John");
        var controller = new PersonController(_repository);
        controller.FakeOutContext();

        var result = (ViewResult)controller.Index(person.Id);
        var model = (VMPerson)result.Model;
        Assert.IsTrue(model.Person.Username == "John");
    }
}

我想这将是集成测试,因为我使用的是真实数据库(我更喜欢内存数据库)。我通过将数据放入我的数据库来开始我的测试(每个测试都在一个事务中运行,并在测试完成时回滚)。然后我打电话给我的控制器,我真的不在乎它如何从数据库中检索数据(通过存储库或服务类),只是要发送到视图的模型必须具有我放入数据库的记录,也就是我的断言. 这种方法很酷的一点是,很多时候我可以继续添加更多的复杂层,而无需更改我的控制器测试:

public class PersonController : Controller
{
    private readonly LESRepository _repository;
    private readonly PersonService _personService;

    public PersonController(LESRepository repository)
    {
        _repository = repository;
        _personService = new PersonService(_repository);
    }

    public ActionResult Index(int id)
    {
        var model = _personService.GetActivePerson(id);
        if(model  == null)
          return PersonNotFoundResult();

        var viewModel = new VMPerson(model);
        return View(viewModel);
    }
}

现在我意识到我没有为我的 PersonService 创建一个接口并将它传递给我的控制器的构造函数。原因是 1)我不打算模拟我的 PersonService 和 2)我觉得我不需要注入我的依赖项,因为我的 PersonController 现在只需要依赖一种类型的 PersonService。

我是单元测试的新手,我总是很高兴被证明我错了。请指出为什么我测试控制器的方式可能是一个非常糟糕的主意(除了我的测试运行时间明显增加)。

4

4 回答 4

5

唔。伙计,这里有几件事。

首先,看起来您正在尝试测试控制器方法。伟大的 :)

所以这意味着,控制器需要的任何东西都应该被模拟。这是因为

  1. 您不想担心在该依赖项中会发生什么。
  2. 您可以验证依赖项是否被调用/执行。

好的,让我们看看你做了什么,我会看看我是否可以重构它以使其更具可测试性。

-记住-我正在测试CONTROLLER METHOD,而不是控制器方法调用/依赖的东西。

所以这意味着我不关心服务实例或存储库实例(无论您决定遵循哪种架构方式)。

注意:我把事情简单化了,所以我去掉了很多废话等等。

界面

首先,我们需要一个存储库的接口。这可以实现为内存中的存储库、实体框架存储库等。您很快就会明白为什么。

public interface ILESRepository
{
    IQueryable<Person> GetAll();
}

控制器

在这里,我们使用接口。IRepository这意味着使用模拟或真实实例非常容易且很棒。

public class PersonController : Controller
{
    private readonly ILESRepository _repository;

    public PersonController(ILESRepository repository)
    {
       if (repository == null)
       {
           throw new ArgumentNullException("repository");
       }
        _repository = repository;
    }

    public ActionResult Index(int id)
    {
        var model = _repository.GetAll<Person>()
            .FirstOrDefault(x => x.Id == id);

        var viewModel = new VMPerson(model);
        return View(viewModel);
    }
}

单元测试

好的 - 这是神奇的金钱射击的东西。首先,我们创建了一些 Fake People。在这里和我一起工作......我会告诉你我们在哪里使用它。这只是一个无聊、简单的POCO's 列表。

public static class FakePeople()
{
    public static IList<Person> GetSomeFakePeople()
    {
        return new List<Person>
        {
            new Person { Id = 1, Name = "John" },
            new Person { Id = 2, Name = "Fred" },
            new Person { Id = 3, Name = "Sally" },
        }
    }
}

现在我们有了测试本身。我正在使用xUnit我的测试框架和moq我的模拟。任何框架都可以,在这里。

public class PersonControllerTests
{
    [Fact]
    public void GivenAListOfPeople_Index_Returns1Person()
    {
        // Arrange.
        var mockRepository = new Mock<ILESRepository>();
        mockRepository.Setup(x => x.GetAll<Person>())
                                   .Returns(
                                FakePeople.GetSomeFakePeople()
                                          .AsQueryable);
        var controller = new PersonController(mockRepository);
        controller.FakeOutContext();

        // Act.
        var result = controller.Index(person.Id) as ViewResult;

        // Assert.
        Assert.NotNull(result);
        var model = result.Model as VMPerson;
        Assert.NotNull(model);
        Assert.Equal(1, model.Person.Id);
        Assert.Equal("John", model.Person.Username);

        // Make sure we actually called the GetAll<Person>() method on our mock.
        mockRepository.Verify(x => x.GetAll<Person>(), Times.Once());
    }
}

好的,让我们看看我做了什么。

首先,我整理我的废话。我首先创建了一个ILESRepository. 然后我说:如果有人调用该GetAll<Person>()方法,那么......不要 - 真的 - 访问数据库或文件或其他任何东西......只需返回一个人员列表,该列表创建于FakePeople.GetSomeFakePeople().

所以这就是控制器中会发生的事情......

var model = _repository.GetAll<Person>()
                       .FirstOrDefault(x => x.Id == id);

首先,我们要求我们的模拟点击该GetAll<Person>()方法。我只是“设置”以返回人员列表......所以我们有一个包含 3 个Person对象的列表。接下来,我们FirstOrDefault(...)在这个 3 个对象的列表上调用 a Person.. 它返回单个对象或 null,具体取决于 的值id是什么。

多田!这就是金钱射击:)

现在回到单元测试的其余部分。

我们Act,然后我们Assert。那里没什么难的。对于奖励积分,我verify实际上GetAll<Person>()在控制器方法内的模拟中调用了该Index方法。这是一个安全调用,以确保我们的控制器逻辑(我们正在测试)正确完成。

有时,您可能想要检查不良情况,例如有人传入了不良数据。这意味着您可能永远不会使用模拟方法(这是正确的),因此您verify永远不会调用它们。

好的 - 问题,上课?

于 2012-04-28T02:52:49.467 回答
2

即使你不打算模拟一个接口,我强烈建议你不要通过在构造函数中创建对象来隐藏对象的真正依赖关系,你正在破坏单一责任原则并且你正在编写不可测试的代码。

编写测试时要考虑的最重要的事情是:“编写测试没有万能钥匙”。有很多工具可以帮助你编写测试,但真正的努力应该放在编写可测试的代码上,而不是试图破解我们现有的代码来编写一个通常最终成为集成测试而不是单元测试的测试.

在构造函数中创建新对象是您的代码不可测试的第一个重要信号。

当我过渡到开始编写测试时,这些链接帮助了我很多,让我告诉你,在你开始之后,这将成为你日常工作的自然部分,你会喜欢编写测试的好处不再编写没有测试的代码

清洁代码指南(在 Google 中使用): http: //misko.hevery.com/code-reviewers-guide/

要获取更多信息,请阅读以下内容:

http://misko.hevery.com/2008/09/30/to-new-or-not-to-new/

并观看来自 Misko Hevery 的视频

http://www.youtube.com/watch?v=wEhu57pih5w&feature=player_embedded

编辑:

Martin Fowler 的这篇文章解释了 Classical 和 Mockist TDD 方法之间的区别

http://martinfowler.com/articles/mocksArentStubs.html

作为总结:

  • 经典的 TDD 方法:这意味着在不创建替代项或替身(模拟、存根、假人)的情况下尽可能测试所有内容,但 Web 服务或数据库等外部服务除外。经典测试人员仅对外部服务使用双打

    • 好处:当您测试时,您实际上是在测试应用程序的接线逻辑和逻辑本身(不是孤立的)
    • 缺点:如果发生错误,您可能会看到数百个测试失败,并且很难找到负责的代码
  • Mockist TDD 方法:遵循 Mockist 方法的人将隔离测试所有代码,因为他们将为每个依赖项创建双精度

    • 好处:您正在隔离测试应用程序的每个部分。如果发生错误,您可以确切地知道它发生在哪里,因为只有少数测试会失败,理想情况下只有一个
    • 缺点:您必须将所有依赖项加倍,这会使测试变得更加困难,但您可以使用 AutoFixture 等工具自动为依赖项创建双打

这是另一篇关于编写可测试代码的好文章

http://www.loosecouplings.com/2011/01/how-to-write-testable-code-overview.html

于 2012-04-27T23:55:29.933 回答
1

有一些缺点。

首先,当您有一个依赖于外部组件(如实时数据库)的测试时,该测试不再是真正可预测的。它可能由于多种原因而失败 - 网络中断、数据库帐户密码更改、缺少一些 DLL 等。因此,当您的测试突然失败时,您无法立即确定缺陷在哪里。是数据库问题吗?你班上的一些棘手的错误?

当您只需知道哪个测试失败就可以立即回答该问题时,您就拥有了令人羡慕的缺陷定位质量。

其次,如果存在数据库问题,所有依赖它的测试都会立即失败。这可能不是那么严重,因为您可能会意识到原因是什么,但我保证它会减慢您检查每个原因的速度。广泛的失败可以掩盖真正的问题,因为您不想查看 50 个测试中的每一个的异常。

而且我知道您想了解除执行时间之外的其他因素,但这确实很重要。您希望尽可能频繁地运行您的测试,而较长的运行时间不鼓励这样做。

我有两个项目:一个在 10 秒内运行 600 多个测试,一个在 50 秒内运行 40 多个测试(这个项目实际上是故意与数据库对话)。我在开发时更频繁地运行更快的测试套件。猜猜我觉得哪一个更容易使用?

综上所述,测试外部组件是有价值的。只是不是在您进行单元测试时。集成测试更脆弱,更慢。这使它们更加昂贵。

于 2012-04-27T23:58:01.803 回答
1

在单元测试中访问数据库会产生以下后果:

  1. 性能。填充数据库并访问它很慢。您进行的测试越多,等待的时间就越长。如果您使用模拟,您的控制器测试可能会在几毫秒内运行,而如果它直接使用数据库则需要几秒钟。
  2. 复杂性。对于共享数据库,您必须处理多个代理针对同一数据库运行测试的并发问题。需要配置数据库,需要创建结构,填充数据等。它变得相当复杂。
  3. 覆盖范围。您会发现,如果不进行模拟,几乎不可能测试某些条件。示例可能包括验证数据库超时时要执行的操作。或者如果发送电子邮件失败怎么办。
  4. 维护。对数据库模式的更改,尤其是频繁更改,将影响几乎所有使用数据库的测试。刚开始有 10 个测试时,它可能看起来不多,但请考虑当您有 2000 个测试时。您可能还会发现更改业务规则和调整测试更加复杂,因为您必须修改数据库中填充的数据以验证业务规则。

您必须询问测试业务规则是否值得。在大多数情况下,答案可能是“否”。

我遵循的方法是:

  1. 通过模拟依赖关系和模拟可能发生的条件(如数据库错误等)来实现单元类(控制器、服务层等)。这些测试验证了业务逻辑,旨在尽可能多地覆盖决策路径。使用PEX之类的工具来突出显示您从未想过的任何问题。在修复 PEX 突出显示的一些问题后,您会惊讶于您的应用程序会变得多么健壮(和弹性)。
  2. 编写数据库测试以验证我使用的 ORM 是否适用于底层数据库。您会对 EF 和其他 ORM 与某些数据库引擎(和版本)的问题感到惊讶。这些测试对于调优性能和减少发送到数据库的查询和数据量也很有用。
  3. 编写编码的 UI 测试,使浏览器自动化并验证系统是否实际工作。在这种情况下,我会事先用一些数据填充数据库。这些测试只是自动化了我手动完成的测试。目的是验证关键部分是否仍然有效。
于 2012-04-27T23:59:48.450 回答