如何处理TDD中接口过度使用的问题?

26

我发现当我进行TDD时,通常会导致非常多的接口。对于有依赖关系的类,它们会通过常规方式通过构造函数进行注入:

public class SomeClass
{
    public SomeClass(IDependencyA first, IDependency second)
    {
        // ...
    }
}
结果是几乎每个类都将实现一个接口。 是的,代码将被解耦并且可以很容易地在隔离环境中进行测试,但也会有额外的间接层,这让我感到有点…不安。感觉不对劲。 有人能分享其他不涉及如此频繁使用接口的方法吗? 你们其他人怎么样了?

不确定问题是否可以回答...我看到接口是需要实际实现/类满足的角色。例如,为了让“我”“快速到达办公室”,我需要一个“交通工具”和一个“最佳路径查找器”。接口也是分隔两个层的边界。因此,接口帮助您确定需要哪些角色,并为它们分配清晰的离散责任。如果您想要单独测试每个类(而不是其依赖项),则无法避免它;但您可能需要审查您的角色和责任。 - Gishu
4个回答

14

你的测试结果告诉你需要重新设计你的类。

有时候,你无法避免传递需要被存根以使你的类可测试的复杂协作者,但是你应该寻找方法来提供它们的输出,并考虑如何重新安排它们的交互以消除复杂的依赖关系。

例如,不要将TaxCalculator与在CalculateTaxes期间访问数据库的ITaxRateRepository进行关联,而是在创建TaxCalculator实例之前获取这些值,并将其提供给其构造函数:

// Bad! (If necessary on occasion)
public TaxCalculator(ITaxRateRepository taxRateRepository) {}

// Good!
public TaxCalculator(IDictonary<Locale, TaxRate> taxRateDictionary) {}

有时候这意味着你需要做出更大的改变,调整对象生命周期或重构大量代码,但我发现一旦开始寻找,通常会找到低空果实。

关于减少依赖性的技术的优秀总结,请参见模拟消除模式


我真的很喜欢你发布的链接。 - Kugel

7
不要使用接口!大多数模拟框架可以模拟具体类。

你是说我应该注入具体类而不是接口吗? - Chris
8
为什么不呢?如果你创建一个接口的唯一原因是为了进行模拟,而在生产代码中只有一个实现,那么这样做就没有什么意义。我建议不要这样做。 - dty
1
那么,我应该将这些具体类中的方法设置为虚拟方法以便进行模拟,是这样吗? - Chris
1
你没有明确说你在使用Java,但是由于你提到了接口,我假设你是在用Java。在这种情况下,Java中的所有(非静态)方法已经是虚方法了。 - dty
2
在C#中,声明虚方法仍然比创建冗余接口更好。请参见http://stackoverflow.com/questions/90851/is-creating-interfacesfor-almost-every-class-justified-or-are-interfaces-overus?lq=1 - Michael Freidgeim
显示剩余2条评论

3
这就是基于模拟的测试方法的缺点。这涉及到测试边界的讨论,也与模拟有关。通过将测试用例与域类的比例保持为1:1,您的测试边界非常小。小的测试边界会导致接口和依赖它们的测试大量增加。由于您正在模拟和存根化的交互数量很多,因此重构变得更加困难。通过使用单个测试来测试类集群,重构变得更容易,并且您可以使用更少的接口。但是要注意,您可能会一次性测试太多类。您的类越复杂,需要测试的代码路径就越多。这可能会导致组合爆炸,您无法测试所有情况。请听取代码和测试的建议,它们告诉您有关代码的一些信息。如果您看到复杂性增加,请考虑引入新的测试用例、接口/实现并在原始代码中进行模拟。

如果我在一个测试中测试多个类,那么我认为这些类应该与同一功能相关。一个例子是当A类使用B类和C类时,它们都是辅助类。我应该将B和C注入到A中,还是可以让A实例化它们? - Chris
我的风格是在构造函数中实例化它们,这样如果以后决定单独测试A、B和C类,则可以通过简单的“引入参数”重构使它们可注入。依赖注入很好,但它仍然可能是YAGNI(You Aren't Gonna Need It)。 - bcarlso
你的考虑是关于何时使用模拟/存根。这并不证明接口对于模拟是必需的。请参阅http://stackoverflow.com/questions/90851/is-creating-interfacesfor-almost-every-class-justified-or-are-interfaces-overus?rq=1 - Michael Freidgeim

1

如果您对传递到某个类中的接口数量感到不安,那么这可能是您引入了过多不同的依赖项的迹象。

如果SomeClass 依赖于IDependencyAIDependencyBIDependencyC,则有机会将该类与这三个接口执行的逻辑提取到另一个类/接口IDependencyABC中。

然后,在编写SomeClass 的测试时,您只需要模拟IDependencyABC 提供的逻辑即可。

此外,如果您仍然感到不舒服,也许您并不需要接口。例如,包含状态(例如传递的参数)的类可能只需作为具体类创建并传递。Jeff的答案暗示了这一点,他提到只将您所需的内容传递到构造函数中。这提供了更少的耦合,更好地反映了类的需求意图。只需小心传递数据结构(IDictionary<,>)。

最终,当你在进行TDD周期时感到温暖和融洽的感觉时,它才是有效的。如果你感到不安,要注意一些代码异味并修复这些问题以重新回到正轨。

只要小心传递数据结构(IDictionary<,>)即可。这似乎是在建议不要像Jeff在他的例子中建议的那样传递数据结构。你能解释一下为什么传递数据结构会有问题吗? - Chris
2
在类内部传递基本数据结构通常没有问题。但是我发现在公共接口中,这可能会掩盖方法调用的意图。更合适的做法可能是传递一个模型所需的类(它可能在内部有一个字典)。我曾多次调试遗留代码,其中许多方法传递了HashSet、Dictionary和List。由于基本数据结构固有的上下文很少,因此所有这些的意图都非常不清楚。 - Sheldon Warkentin
好的,现在我明白你的意思了。我同意,一个适当的领域对象通常比原始数据结构更好。 - Chris
不想拖延重点,但原始数据结构(字典、列表、哈希集)是可变的。当传递可变结构时,这可能会在以后引起许多调试问题。 - Sheldon Warkentin

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