TDD和DI:依赖注入变得繁琐

9

C#,nUnit和Rhino Mocks,如果适用的话。

我在尝试为一个复杂的函数编写测试时,我的TDD之旅继续进行。假设我正在编写一个表单,当保存表单时,必须同时保存表单中的依赖对象...例如表单问题的答案,如果有的话还包括附件以及“日志”条目(例如“blahblah更新了表单。”或“blahblah附加了一个文件。”)。此保存功能还会根据表单在保存功能期间更改的状态向各种人发送电子邮件。

这意味着为了完全测试表单的保存功能及其所有依赖项,我必须注入五到六个数据提供程序来测试这个函数,并确保一切都按正确的方式和顺序触发。当编写多个链接的构造函数以插入模拟提供程序时,这很麻烦。我认为我错过了一些东西,无论是重构的方式还是简单地设置模拟数据提供程序的更好方法。

我应该进一步研究重构方法,以查看如何简化此函数吗?观察者模式听起来怎么样,这样依赖对象就可以检测到父表单何时被保存并处理自己?我知道人们说要拆分函数以进行测试...这意味着我测试每个依赖对象的单独保存函数,但不测试表单本身的保存函数,因为它规定了每个对象应该如何首先保存自己?


如果您展示您的代码,那么提供改进建议会更有帮助。 - Esko Luontola
8个回答

16

首先,如果你正在遵循TDD,则不要将测试包装在一个复杂的函数周围。相反,你需要将函数与测试交织在一起,几乎同时编写两者,但测试略领先于函数。查看TDD的三个法则

当你遵循这三条法则,并勤于重构时,你永远不会得到“一个复杂的函数”。相反,你会得到许多经过测试的简单函数。

现在,回到你的问题。如果你已经有了“一个复杂的函数”,并且想要在它周围包装测试,则应执行以下操作:

  1. 显式添加模拟对象,而不是通过DI添加。(例如,类似于'test'标志和“if”语句来选择模拟对象而非真实对象)
  2. 编写几个测试来覆盖组件的基本操作。
  3. 无情地进行重构,将复杂的函数分解为许多简单的小函数,同时尽可能频繁地运行你拼凑出来的测试。
  4. 尽可能将“test”标志提高至高层。当你重构时,将数据源传递给小而简单的函数。不要让“test”标志影响除最高层之外的其他函数。
  5. 重写测试。当你重构时,尽可能多地重写测试,使其调用简单的小函数而非大型顶级函数。你可以从测试中将模拟对象传递给简单函数。
  6. 摆脱“test”标志,并确定你真正需要多少DI。由于你已经在更低层编写了测试,可以通过参数插入模拟对象,因此你可能不再需要在顶层模拟太多数据源。

如果在所有这些操作之后,DI仍然很麻烦,那么考虑注入一个包含所有数据源引用的单个对象。注入一个东西总比注入多个东西容易。


@ Uncle Bob。您在最初的几行中提到了我一直在做的事情。 - vijaysylvester
2
请不要使用上帝对象。我已经花费了太多的时间来清理那些毁坏模块化的依赖对象,因为所有代码都依赖于它们。 - Steve Freeman
@Uncle Bob,谢谢您。前两句话深深地触动了我。 - John Wang

7

使用AutoMocking容器。RhinoMocks已经有一个写好的。

想象一下你有一个通过构造函数注入了很多依赖项的类。以下是使用RhinoMocks设置它的样子,不使用AutoMocking容器:

private MockRepository _mocks;
private BroadcastListViewPresenter _presenter;
private IBroadcastListView _view;
private IAddNewBroadcastEventBroker _addNewBroadcastEventBroker;
private IBroadcastService _broadcastService;
private IChannelService _channelService;
private IDeviceService _deviceService;
private IDialogFactory _dialogFactory;
private IMessageBoxService _messageBoxService;
private ITouchScreenService _touchScreenService;
private IDeviceBroadcastFactory _deviceBroadcastFactory;
private IFileBroadcastFactory _fileBroadcastFactory;
private IBroadcastServiceCallback _broadcastServiceCallback;
private IChannelServiceCallback _channelServiceCallback;

[SetUp]
public void SetUp()
{
    _mocks = new MockRepository();
    _view = _mocks.DynamicMock<IBroadcastListView>();

    _addNewBroadcastEventBroker = _mocks.DynamicMock<IAddNewBroadcastEventBroker>();

    _broadcastService = _mocks.DynamicMock<IBroadcastService>();
    _channelService = _mocks.DynamicMock<IChannelService>();
    _deviceService = _mocks.DynamicMock<IDeviceService>();
    _dialogFactory = _mocks.DynamicMock<IDialogFactory>();
    _messageBoxService = _mocks.DynamicMock<IMessageBoxService>();
    _touchScreenService = _mocks.DynamicMock<ITouchScreenService>();
    _deviceBroadcastFactory = _mocks.DynamicMock<IDeviceBroadcastFactory>();
    _fileBroadcastFactory = _mocks.DynamicMock<IFileBroadcastFactory>();
    _broadcastServiceCallback = _mocks.DynamicMock<IBroadcastServiceCallback>();
    _channelServiceCallback = _mocks.DynamicMock<IChannelServiceCallback>();


    _presenter = new BroadcastListViewPresenter(
        _addNewBroadcastEventBroker,
        _broadcastService,
        _channelService,
        _deviceService,
        _dialogFactory,
        _messageBoxService,
        _touchScreenService,
        _deviceBroadcastFactory,
        _fileBroadcastFactory,
        _broadcastServiceCallback,
        _channelServiceCallback);

    _presenter.View = _view;
}

现在,这里有一个使用AutoMocking容器的相同示例:
private MockRepository _mocks;
private AutoMockingContainer _container;
private BroadcastListViewPresenter _presenter;
private IBroadcastListView _view;

[SetUp]
public void SetUp()
{

    _mocks = new MockRepository();
    _container = new AutoMockingContainer(_mocks);
    _container.Initialize();

    _view = _mocks.DynamicMock<IBroadcastListView>();
    _presenter = _container.Create<BroadcastListViewPresenter>();
    _presenter.View = _view;

}

更简单了,是吗?

AutoMocking容器会自动为构造函数中的每个依赖项创建模拟对象,并且您可以通过以下方式访问它们以进行测试:

using (_mocks.Record())
    {
      _container.Get<IChannelService>().Expect(cs => cs.ChannelIsBroadcasting(channel)).Return(false);
      _container.Get<IBroadcastService>().Expect(bs => bs.Start(8));
    }

希望这能有所帮助。我知道随着AutoMocking容器的出现,我的测试工作变得更加简单了。


这种方法只是隐藏了复杂性,而没有减轻它。根本问题在于被测试的代码,而不是测试代码本身。 - Igor Brejc
这是一个合理的做法。只需注意测试同时对多个服务设置期望值的情况。每个测试通常只应针对一个服务设置期望。最好有一个工具为其余服务提供无期望存根。 - Frank Schwieterman

5

你说得没错,这可能会很繁琐。

支持模拟方法的人会指出,代码本来就写得不正确。也就是说,你不应该在这个方法中构造依赖对象。相反,注入API应该有函数来创建适当的对象。

至于模拟六个不同的对象,那是真的。但是,如果你也正在对这些系统进行单元测试,那么这些对象应该已经有了可以使用的模拟基础设施。

最后,使用一个可以为你完成一些工作的模拟框架。


5
我没有你的代码,但我的第一反应是你的测试试图告诉你,你的对象有太多的协作者。在这种情况下,我总是发现缺少一个构造函数,应该将其打包成一个更高级别的结构。使用自动模拟容器只会压制你从测试中得到的反馈。请参见http://www.mockobjects.com/2007/04/test-smell-bloated-constructor.html以获取更长的讨论。

4
在这种情况下,我通常会发现类似于“这表明你的对象具有过多的依赖项”或“你的对象具有太多的协作者”之类的说法是相当牵强附会的。当然,MVC控制器或表单需要调用许多不同的服务和对象来完成其职责;毕竟,它位于应用程序的顶层。您可以将其中一些依赖项合并到更高级别的对象中(比如将ShippingMethodRepository和TransitTimeCalculator合并成ShippingRateFinder),但这只能达到一定程度,特别是对于这些顶层、面向展示的对象来说。这样做可以少量地模拟对象,但实际上只是通过一个间接层混淆了实际的依赖关系,而没有真正删除它们。
另一个亵渎的建议是说,如果您正在注入依赖对象并为其创建一个接口,而该接口很可能永远不会改变(您真的会在更改代码时放置一个新的MessageBoxService吗?真的吗?),那么就不要费力了。该依赖项是对象的预期行为的一部分,您应该一起测试它们,因为集成测试是真正的业务价值所在。
另一个亵渎的建议是,我通常认为对于MVC控制器或Windows Form,单元测试几乎没有什么用处。每次看到有人模拟HttpContext并测试是否设置了cookie时,我都想尖叫。谁关心AccountController是否设置了cookie?我不关心。该cookie与将控制器视为黑匣子无关;需要集成测试来测试其功能(嗯,在集成测试中,在Login()之后调用PrivilegedArea()失败了)。这样,如果登录cookie的格式发生变化,您就可以避免使数百万个无用的单元测试失效。
将单元测试保存给对象模型,将集成测试保存给展示层,尽可能避免使用模拟对象。如果模拟特定依赖项很困难,那么是时候实事求是了:不要进行单元测试,而是编写集成测试,并停止浪费时间。

3
简单来说,你试图测试的代码功能过于复杂。我认为遵循单一职责原则可能会有所帮助。
保存按钮方法应该只包含顶级调用,将任务委托给其他对象。这些对象可以通过接口进行抽象。然后,当你测试保存按钮方法时,你只需测试与模拟对象的交互。
下一步是编写针对这些低级类的测试,但由于你只测试它们的独立性,因此事情会变得更容易。如果你需要复杂的测试设置代码,那么这是一个不好的设计(或不好的测试方法)的很好指标。
推荐阅读:
  1. 《代码整洁之道》
  2. Google编写可测试代码指南

1

构造函数依赖注入并不是唯一的 DI 方式。如果你正在使用 C#,如果你的构造函数没有做任何重要的工作,你可以使用属性依赖注入。这在对象构造函数方面极大地简化了事情,但会增加函数的复杂性。在开始工作之前,你的函数必须检查任何依赖属性的空值,并在它们为空时抛出 InvalidOperation 异常。


不同意,将其变为基于属性的并不能简化问题,只是隐藏了复杂性。 - eglasius
简化来说,它允许您将复杂性从一个位置转移到另一个位置。这实际上可以简化测试或系统的其他方面,使您能够以更小的块处理更简单的部分。 - Randolpho

0

当测试某些东西很困难时,通常是代码质量的症状,即代码不可测试(在这个播客中提到,如果我没记错的话)。建议重构代码,使其易于测试。将代码分割成类的一些启发式方法是SRP和OCP。要获取更具体的说明,需要查看相关代码。


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