在单元测试中使用依赖注入容器

12

我们在一个相当大的应用程序中非常成功地使用Simple Injector。我们对所有生产类都使用构造函数注入,并配置Simple Injector来填充所有内容,一切都很顺利。

然而,在单元测试中,我们没有使用Simple Injector管理依赖关系树。相反,我们手动创建了所有对象。

我花了几天时间进行了一次重大的重构,几乎所有的时间都用来修复我们在单元测试中手动构建的依赖关系树。

这让我想知道 - 是否有人有任何模式可以用来配置他们在单元测试中使用的依赖关系树?对于我们来说,至少在测试中,我们的依赖关系树往往相当简单,但是它们很多。

有人有什么方法来管理这些吗?


不确定您正在寻找什么模式。为什么不在测试初始化(例如xunit的构造函数)中创建容器?该模式很简单--组合。 - Artyom
3
如果你真的对单元测试模式感兴趣,那么你应该阅读《xUnit测试模式》(https://www.amazon.co.uk/xUnit-Test-Patterns-Refactoring-Code-ebook/dp/B004X1D36K/ref=dp_kinw_strp_1)。 - Steven
请阅读《测试驱动开发:实例》。 - andrew.fox
2个回答

18

对于真正的单元测试(即只测试一个类并模拟其所有依赖关系的测试),使用DI框架是没有任何意义的。在这些测试中:

  • 如果您发现自己有很多重复的代码用于使用所有已创建的模拟对象新建实例,则一种有用的策略是在设置方法中创建所有模拟对象和主题测试实例(这些可以都是私有实例字段),然后每个单独测试的“安排”区域只需要调用所需模拟方法的适当Setup()代码。这样,您每个测试类仅会出现一个new PersonController(...)语句。
  • 如果您需要创建许多领域/数据对象,则创建构建器对象以为测试提供合理值将非常有用。因此,您不需要在整个代码中调用一个带有大量虚假值的巨大构造函数,而只需调用var person = new PersonBuilder().Build(),可能还需要一些链接方法调用来获取测试中特定关注的数据部分。您也可能会对AutoFixture感兴趣,但我从未使用过,因此无法保证它。

如果您正在编写需要测试系统的几个部分之间交互的集成测试,但仍需要能够模拟特定部分,那么请考虑为您的服务创建构建器类,以便您可以这样说,例如var personController = new PersonControllerBuilder.WithRealDatabase(connection).WithAuthorization(new AllowAllAuthorizationService()).Build()

如果您正在编写端到端或“场景”测试,需要测试整个系统,则有意义设置DI框架,利用与实际产品使用的相同配置代码。您仍然可以利用您创建的其他构建器类来构建数据。

var user = new PersonBuilder().Build();
using(Login.As(user))
{
     var controller = Container.Get<PersonController>();
     var result = controller.GetCurrentUser();
     Assert.AreEqual(result.Username, user.Username)
}

2
我相信在单元测试中,Object Mother 是建造者模式的名称。顺便说一句,回答不错。+1 - Steven
对于真正的单元测试,即使用BDD风格的测试驱动开发,并且不鼓励使用模拟。模拟应该只存在于模块的边缘,而不是内部。类不是一个模块。如果你模拟了一个类的所有依赖关系,那么重构将会变得不可能,因为这样会破坏测试。 - andrew.fox
@andrew.fox: 这是一个有趣的观点。我发现即使在BDD实践中,使用自动化测试在各个层面上都是有优势的。来自 单元测试的维基百科文章 中说道:“在面向对象编程中,一个单元通常是整个接口,比如一个类或者一个独立方法... 诸如方法存根、模拟对象、虚假、和测试工具等替代品可以用于帮助测试被隔离的模块。” 对于一个以类为单位的单元来说,它的依赖关系就是它的边缘。 - StriplingWarrior
@StriplingWarrior - 你所写的是后来出现的所谓“伦敦学派”。它有一些优点(易于入门),但也有许多缺点(长期维护困难)。请查看“Ian Cooper的TDD,Where Did It All Go Wrong”(https://www.youtube.com/watch?v=EZ05e7EMOLM)或Robert C. Martin的https://blog.cleancoder.com/uncle-bob/2017/03/03/TDD-Harms-Architecture.html。 - andrew.fox
@andrew.fox:感谢您的文章和视频,以及给我提供“伦敦单元测试学派”这个词汇,导致了这一章节(https://freecontent.manning.com/what-is-a-unit-test-part-2-classical-vs-london-schools/),更有助于理解您的观点。我发现我自然地在伦敦学派和古典学派之间切换,具体取决于我正在测试的系统类型。对于功能非常简单,单元测试只是代码本身的镜像的情况,我经常跳过编写单元测试的步骤。 - StriplingWarrior
显示剩余4条评论

12
请勿在单元测试中使用DI容器。在单元测试中,您试图独立测试一个类或模块,并且在这个领域中几乎没有使用DI容器的用处。
与集成测试不同,因为您想要测试系统中的组件如何集成和协同工作。在这种情况下,您经常使用生产DI配置,并替换一些服务为虚拟服务(例如EmailService),但尽可能接近真实情况。在这种情况下,您通常会使用容器来解析整个对象图。
希望在单元测试中也使用DI容器的愿望,往往源于无效的模式。例如,如果您尝试在每个测试中创建被测试类及其所有依赖项,那么您将得到大量重复的初始化代码,并且类下的小变化可能会影响整个系统并需要更改数十个单元测试。这显然会导致可维护性问题。
过去帮助我解决这个问题的一个模式是使用简单的SUT特定工厂方法。该方法集中了被测试类的创建,并最小化了当被测试类的依赖关系发生变化时需要进行的更改量。以下是这样的工厂方法的示例:
private ClassUnderTest CreateClassUnderTest(
    ILogger logger = null,
    IMailSender mailSender = null,
    IEventPublisher publisher = null)
{
    return new ClassUnderTest(
        logger ?? new FakeLogger(),
        mailSender ?? new FakeMailer(),
        publisher ?? new FakePublisher());
}

工厂方法的参数复制了类的构造函数参数,但将它们全部变成了可选项。对于任何未由调用方提供的特定依赖项,都会注入一个新的默认虚拟实现。
通常这种方法非常有效,因为在大多数测试中,您只关心一个或两个依赖项。其他依赖可能是类的功能所必需的,但对于特定测试可能并不重要。因此,工厂方法允许您仅提供测试中感兴趣的依赖项,同时从测试方法中删除未使用的依赖项的噪音。以下是使用工厂方法的测试方法示例:
public void Doing_something_will_always_log_a_message()
{
    // Arrange
    var logger = new ListLogger();

    ClassUnderTest sut = CreateClassUnderTest(logger: logger);

    // Act
    sut.DoSomething();
    
    // Arrange
    Assert.IsTrue(logger.Count > 0);    
}

如果你有兴趣学习如何编写易读、可信和可维护的(RTM)测试,Roy Osherove的书The Art of Unit Testing (second edition)是一本很好的读物。这本书对我理解如何编写出色的单元测试有很大帮助。如果你想深入了解依赖注入及其相关模式,请考虑阅读Dependency Injection Principles, Practices, and Patterns(我是其中的合著者)。

你提出的是“伦敦单元测试学派”,即过度使用模拟。这会导致脆弱的测试,当您更改任何内部或重构时,测试就无法通过编译。 对于OP,我建议阅读有关“芝加哥TDD学派”的内容,在此学派中,您将测试行为(因此BDD实践被发明出来强调测试中用户流程的需要)。 BDD后来被Gerkin窃取,但它并不是同一件事情。 - andrew.fox

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接