促进可测试代码的设计原则是什么?(设计可测试代码与通过测试驱动设计)


54

我从事的大多数项目都将开发和单元测试隔离考虑,这使得以后编写单元测试成为噩梦。我的目标是在高级和低级设计阶段本身都牢记测试。

我想知道是否存在定义明确的设计原则来促进可测试的代码。我最近了解的一个这样的原理是通过依赖注入和控制反转的依赖反转。

我读过有一种叫做SOLID的东西。我想了解是否遵循SOLID原则间接导致了易于测试的代码?如果没有,是否存在任何明确的设计原则来促进可测试的代码?

我知道有些东西称为“测试驱动开发”。虽然,我对在设计阶段本身就考虑到测试的代码设计更感兴趣,而不是通过测试来推动设计。我希望这是有道理的。

与该主题相关的另一个问题是,为了能够为每个模块编写单元测试用例,是否可以重构现有产品/项目并更改代码和设计?



谢谢。我才刚刚开始阅读这篇文章,这已经很有意义了。

1
这是我的面试问题之一(“您如何设计易于进行单元测试的代码?”)。它单向地向我展示了他们是否了解单元测试,模拟/存根,OOD以及潜在的TDD。可悲的是,答案通常类似于“建立测试数据库”。
克里斯·皮特曼

Answers:


56

是的,SOLID是设计易于测试的代码的一种非常好的方法。作为简短的入门:

S-单一责任原则: 一个对象应该只做一件事,并且应该是代码库中唯一可以做一件事的对象。例如,以一个领域类,例如一个发票。发票类应代表系统中使用的发票的数据结构和业务规则。它应该是代码库中唯一表示发票的类。可以进一步细分为说一种方法应该有一个目的,并且应该是代码库中满足此需求的唯一方法。

通过遵循该原理,可以减少必须编写的用于在不同对象上测试相同功能的测试次数,从而提高设计的可测试性,并且通常还会得到较小的功能,这些功能更容易单独进行测试。

O-开放/封闭原则: 一个类应该对扩展开放,但对变化不开放。一旦对象存在并正常工作,理想情况下,无需回到该对象进行更改以添加新功能。相反,应该通过派生对象或将新的或不同的依赖实现插入对象来扩展对象,以提供该新功能。这样可以避免回归。您可以在需要的时间和位置引入新功能,而无需更改对象的行为,因为该对象已在其他地方使用。

通过坚持这一原则,通常可以提高代码的容忍“ mo头”的能力,并且还可以避免不得不重写测试以预期新的行为。对象的所有现有测试仍应在未扩展的实现上运行,而使用扩展的实现对新功能的新测试也应运行。

L-Liskov替代原理: 依赖于B类的A类应该能够使用任何X:B,而无需了解它们之间的区别。基本上,这意味着您用作依赖项的任何行为都应具有与依赖类相同的行为。举一个简短的例子,假设您有一个公开Write(string)的IWriter接口,该接口由ConsoleWriter实现。现在,您必须改为写入文件,因此您可以创建FileWriter。这样做时,必须确保可以像ConsoleWriter一样使用FileWriter(这意味着依赖项可以与之交互的唯一方法是通过调用Write(string)),因此FileWriter可能需要这样做必须从依赖项以外的其他位置提供作业(例如要写入的路径和文件)。

这对于编写可测试的代码非常重要,因为符合LSP的设计可以在任何时候用“模拟”对象代替真实对象,而无需更改预期的行为,从而可以放心地对小段代码进行测试然后系统将使用插入的真实对象。

I接口隔离原则: 接口应具有尽可能少的方法,以提供接口定义的角色的功能。简而言之,与较小的较大接口相比,较小的接口更好。这是因为较大的接口有更多更改的原因,并且会导致代码库中其他不必要的更改。

遵守ISP协议可以降低被测系统和这些SUT依赖关系的复杂性,从而提高可测性。如果要测试的对象取决于公开DoOne(),DoTwo()和DoThree()的接口IDoThreeThings,则即使该对象仅使用DoTwo方法,也必须模拟实现所有这三种方法的对象。但是,如果对象仅依赖于IDoTwo(仅公开DoTwo),则可以更轻松地模拟具有该方法的对象。

D-Dependency Inversion Principle(D依赖性倒置原则):创建 和抽象绝对不应依赖于其他具体概念,而应依赖抽象。该原理直接加强了松耦合的宗旨。对象永远不必知道对象是什么。相反,它应该关心对象的作用。因此,在定义对象或方法的属性和参数时,总是比使用具体实现更优选使用接口和/或抽象基类。这样一来,您就可以将一种实现方式交换为另一种实现方式,而不必更改用法(如果您还遵循与DIP紧密联系的LSP)。

再次,这对于可测试性是巨大的,因为它允许您再次将依赖项的模拟实现而不是“生产”实现注入到要测试的对象中,同时仍以与之相同的形式测试对象在生产中。这是“独立”进行单元测试的关键。


16

我读过有一种叫做SOLID的东西。我想了解是否遵循SOLID原则间接导致了易于测试的代码?

如果正确应用,是的。Jeff博客文章以非常简短的方式解释了SOLID原理(提到的播客也值得一听),如果更长的描述会让您失望,我建议在那儿看看。

根据我的经验,SOLID的2条原则在设计可测试的代码中起着主要作用:

  • 接口隔离原则 -您应该首选许多特定于客户机的接口,而不是较少的通用接口。这与“ 单一职责原则”结合使用,可以帮助您设计面向功能/任务的类,作为回报,它们更易于测试(与更通用的类或经常被滥用的“经理”“上下文”相比)-更少的依赖,更少的复杂性,更细粒度的明显测试。简而言之,小型组件导致简单的测试。
  • 依赖倒置原则 -契约式设计,而不是实现。在测试复杂对象时,这将使您受益最多,并且意识到您并不需​​要整个依赖关系图就可以对其进行设置,但是您只需模拟接口即可完成。

我相信在设计可测性时,这两个对您的帮助最大。其余的也有影响,但我想说不会那么大。

(...)为了能够为每个模块编写单元测试用例,是否可以重构现有产品/项目并更改代码和设计?

没有现有的单元测试,简而言之-麻烦。单元测试是您代码可以工作的保证。如果您具有适当的测试覆盖范围,则会立即发现引入重大更改的信息。

现在,如果您想更改现有代码添加单元测试,则会在您还没有测试但已经更改了代码的地方引入了空白。自然,您可能不知道所做的更改是什么。您要避免这种情况。

无论如何,单元测试还是值得编写的,即使是针对难以测试的代码。如果您的代码正在运行,但未经过单元测试,则适当的解决方案是为其编写测试,然后进行更改。但是,请注意,更改测试代码以使其更易于测试是您的管理人员可能不想花钱的事情(您可能会听说这几乎没有带来任何业务价值)。


iaw高内聚力和低耦合
jk。

8

您的第一个问题:

SOLID确实是必经之路。我发现,关于可测试性,SOLID首字母缩略词的两个最重要方面是S(单一职责)和D(依赖项注入)。

单一责任 您的课程实际上应该只做一件事,而只能做一件事。创建文件,解析一些输入并将其写入文件的类已经在做三件事。如果您的课程只做一件事,那么您将确切知道该做些什么,为此设计测试用例应该很容易。

依赖注入(DI):这使您可以控制测试环境。无需在代码内部创建外来对象,而是通过类构造函数或方法调用将其注入。在进行单元测试时,您只需用完全控制的存根或模拟替换实类。

您的第二个问题: 理想情况下,您在重构代码之前编写测试来记录代码的功能。这样,您可以记录重构所产生的结果与原始代码相同。但是,您的问题是功能代码难以测试。这是经典情况!我的建议是:在单元测试之前仔细考虑重构。如果你可以的话; 为工作代码编写测试,然后重构代码,然后重构测试。我知道这将花费数小时,但您将更加确定,重构代码与旧代码相同。话虽如此,我已经放弃了很多次。类非常丑陋和混乱,以至于重写是使它们可测试的唯一方法。


4

除了着重于实现松散耦合的其他答案外,我还要说一句有关测试复杂逻辑的内容。

我曾经不得不对一个逻辑错综复杂,有很多条件且很难理解字段作用的类进行单元测试。

我用许多代表状态机的小类替换了此代码。逻辑变得更加容易遵循,因为前一类的不同状态变得明确。每个状态类彼此独立,因此可以轻松测试。

状态是显式的事实使得枚举代码的所有可能路径(状态转换)变得更加容易,从而为每个代码编写了单元测试。

当然,并非每个复杂的逻辑都可以建模为状态机。


3

以我的经验来看,SOLID是一个很好的开始,SOLID的四个方面在单元测试中确实可以很好地工作。

  • 单一责任原则 -每个班级只做一件事情,一件事情。计算值,打开文件,解析字符串等。因此,投入和产出以及决策点的数量应非常小。这使得编写测试变得容易。
  • Liskov替换原则 -您应该能够在存根和模拟中进行替换,而无需更改代码的期望属性(预期结果)。
  • 接口隔离原理 -通过接口分离接触点使使用Moq等模拟框架创建存根和模拟非常容易。不必依赖具体的类,您只需依赖实现接口的内容即可。
  • 依赖注入原理 -这是允许您通过要测试的方法中的构造函数,属性或参数将这些存根和模拟注入代码中的内容。

我还将研究不同的模式,尤其是工厂模式。假设您有一个实现接口的具体类。您将创建一个工厂来实例化具体的类,但是返回接口。

public interface ISomeInterface
{
    int GetValue();
}  

public class SomeClass : ISomeInterface
{
    public int GetValue()
    {
         return 1;
    }
}

public interface ISomeOtherInterface
{
    bool IsSuccess();
}

public class SomeOtherClass : ISomeOtherInterface
{
     private ISomeInterface m_SomeInterface;

     public SomeOtherClass(ISomeInterface someInterface)
     {
          m_SomeInterface = someInterface;
     }

     public bool IsSuccess()
     {
          return m_SomeInterface.GetValue() == 1;
     }
}

public class SomeFactory
{
     public virtual ISomeInterface GetSomeInterface()
     {
          return new SomeClass();
     }

     public virtual ISomeOtherInterface GetSomeOtherInterface()
     {
          ISomeInterface someInterface = GetSomeInterface();

          return new SomeOtherClass(someInterface);
     }
}

在测试中,您可以Moq或其他模拟框架覆盖该虚拟方法并返回设计的接口。但是就执行代码而言,工厂并没有改变。您还可以通过这种方式隐藏很多实现细节,您的实现代码不在乎接口的构建方式,它所关心的只是取回接口。

如果您想对此进行扩展,我强烈建议您阅读单元测试的艺术。它提供了一些有关如何使用此原理的出色示例,并且快速阅读。


1
这称为依赖关系“反转”原理,而不是“注入”原理。
Mathias Lykkegaard Lorenzen
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.