重构时如何保持单元测试正常工作?


29

在另一个问题中,揭示了TDD的难题之一是在重构期间和重构之后使测试套件与代码库保持同步。

现在,我非常喜欢重构。我不会放弃做TDD。但是我也遇到过这样的测试问题,即以较小的重构会导致很多测试失败。

重构时如何避免破坏测试?

  • 您是否将测试写得更好?如果是这样,您应该寻找什么?
  • 您是否避免某些类型的重构?
  • 有测试重构工具吗?

编辑:我写了一个新问题,问我要问什么(但将此问题保留为一个有趣的变体)。


7
我会认为,使用TDD时,重构的第一步是编写一个失败的测试,然后重构代码以使其正常运行。
马特·艾伦

您的IDE也无法弄清楚如何重构测试吗?

@ThorbjørnRavn Andersen,是的,我写了一个新问题,问我要问的问题(但保留为一个有趣的变体;请参阅azheglov的回答,本质上就是你说的)
Alex

是否考虑将thar Info添加到此问题?

Answers:


35

您要尝试做的并不是真正的重构。有了重构,顾名思义,你不改变什么软件呢,你改怎么它做它。

从所有绿色测试开始(全部通过),然后在“幕后”进行修改(例如,将方法从派生类移至基类,提取方法或使用Builder封装Composite等等)。您的测试应该仍然通过。

您所描述的似乎不是重构,而是重新设计,这也可以增强被测软件的功能。TDD和重构(正如我在此处尝试定义的)不冲突。您仍然可以重构(绿色-绿色)并应用TDD(红色-绿色)来开发“增量”功能。


7
相同的代码X复制了15个位置。在每个地方定制。您可以将其设为通用库并参数化X或使用策略模式来考虑这些差异。我保证X的单元测试将失败。X的客户端将失败,因为公共接口略有变化。重新设计还是重构?我称之为重构,但无论哪种方式,它都会破坏各种各样的东西。底线是您无法重构,除非您确切知道它们如何组合在一起。然后修复测试是乏味的,但最终是微不足道的。
凯文

3
如果测试需要不断调整,则可能暗示测试过于详细。例如,假设一段代码需要在某些情况下以特定顺序触发事件A,B和C。旧代码按ABC顺序进行操作,测试按该顺序预期事件。如果重构的代码按ACB顺序发出事件,则它仍会按照规范运行,但测试将失败。
otto

3
@Kevin:我相信您描述的是重新设计,因为公共接口发生了变化。Fowler对重构的定义(“在不改变其外部行为的情况下更改代码的内部结构”)非常清楚。
azheglov 2011年

3
@azheglov:也许但以我的经验,如果实现不好,那么接口也是如此
Kevin

2
一个完全有效和明确的问题最终以“单词的含义”讨论结束。谁在乎您如何称呼它,让我们在其他地方进行讨论。同时,此答案完全省略了任何实际答案,但迄今为止投票最多。我明白为什么人们将TDD视为宗教。
Dirk Boer

21

进行单元测试的好处之一是,您可以放心地进行重构。

如果重构未更改公共接口,则您将单元测试保持不变,并确保重构后它们全部通过。

如果重构确实更改了公共接口,则应首先重写测试。重构直到新测试通过。

我永远不会避免任何重构,因为它会破坏测试。编写单元测试可能会很痛苦,但从长远来看,值得这样做。


7

与其他答案相反,必须注意的一点是,如果测试是白盒测试,在重构被测系统(SUT)时,某些测试方法可能会变得脆弱。

如果我使用一个嘲讽的框架,验证了秩序的呼吁嘲笑(当顺序无关紧要,因为调用是没有副作用)的方法; 然后,如果我的代码通过按不同顺序进行的那些方法调用而更加干净,然后我进行重构,则测试将中断。通常,模拟可以将脆弱性引入测试。

如果我通过暴露SUT的私有成员或受保护成员来检查SUT的内部状态(我们可以在Visual Basic中使用“朋友”,或者在c#中升级访问级别“内部”并使用“ internalsvisibleto”;在许多OO语言中,包括C#中的“ 测试专用子类可以用来”)然后突然类的内部状态会事-你可能会重构类作为一个黑盒子,但白箱测试将失败。假设当SUT更改状态时,单个字段被重用表示不同的意思(不是很好的做法!)-如果我们将其拆分为两个字段,则可能需要重写损坏的测试。

特定于测试的子类也可以用于测试受保护的方法-从生产代码的角度来看,这可能意味着重构是从测试代码的角度来看的重大更改。将几行移入或移出受保护的方法可能不会产生生产副作用,但会破坏测试。

如果我使用“ test hooks ”或任何其他特定于测试或条件的编译代码,由于对内部逻辑的脆弱依赖性,很难确保测试不会中断。

因此,为防止测试与SUT的内部细节耦合,它可能有助于:

  • 尽可能使用存根而不是模拟。欲了解更多信息请参阅法比奥Periera对同义反复测试博客,和我的同义反复的测试博客
  • 如果使用模拟,除非重要,否则避免验证调用方法的顺序。
  • 尽量避免验证SUT的内部状态-尽可能使用其外部API。
  • 尽量避免在生产代码中使用特定于测试的逻辑
  • 尝试避免使用特定于测试的子类。

以上所有要点都是测试中使用的白盒耦合示例。因此,要完全避免重构破坏测试,请使用SUT的黑盒测试。

免责声明:出于此处讨论重构的目的,我在广义上使用了这个词,以包括更改内部实现而没有任何可见的外部影响。一些纯粹主义者可能会不同意并仅参考Martin Fowler和Kent Beck的书《 Refactoring》,该书描述了原子重构操作。

在实践中,我们倾向于采取比此处描述的原子操作大得多的不间断步骤,尤其是那些使生产代码从外部表现出相同行为的更改可能不会使测试通过。但是我认为将“替代算法替换为具有相同行为的另一种算法”作为重构是公平的,我认为Fowler同意。Martin Fowler本人说重构可能会破坏测试:

编写模拟测试时,您正在测试SUT的呼出电话,以确保它与供应商的通话正确。经典测试仅关心最终状态-而不是最终状态的得出方式。因此,模拟测试与方法的实现更相关。更改对协作者的呼叫的性质通常会导致模拟测试失败。

[...]

与实现的耦合也干扰了重构,因为与传统测试相比,实现更改更容易破坏测试。

福勒-Mo不是存根


福勒从字面上写了《重构》。Fowler的“签名”系列中最有权威的关于单元测试的书(由Gerard Meszaros编写的xUnit Test Patterns)在Fowler的“签名”系列中,所以当他说重构会破坏测试时,他可能是对的。
完美主义者

5

如果您的测试在重构时中断,那么按照定义,您就不是重构,即“在不更改程序行为的情况下更改程序结构”。

有时您确实需要更改测试的行为。也许您需要将两个方法合并在一起(例如,侦听的TCP套接字类上的bind()和listen()),因此您的代码的其他部分会尝试并且无法使用现在已更改的API。但这不是重构!


如果他只是更改测试所测试的方法的名称怎么办?除非您也在测试中重命名它们,否则测试将失败。在这里,他没有改变程序的行为。
奥斯卡·梅德罗斯

2
在这种情况下,他的测试也会被重构。不过,您需要注意:首先重命名方法,然后运行测试。它应该因正确的原因而失败(它无法编译(C#),您收到MessageNotUnderstood异常(Smalltalk),似乎什么都没有发生(Objective-C的空值饮食模式))。然后,在知道没有意外引入任何错误的情况下更改测试。换句话说,“如果测试失败”表示“如果在完成重构后测试失败”。尝试使更改块很小!
Frank Shearar 2011年

1
单元测试与代码的结构固有地联系在一起。例如,Fowler在refactoring.com/catalog中有很多内容会影响单元测试(例如,hide方法,内联方法,将错误代码替换为异常等等)。
克里斯蒂安H

假。将两种方法合并在一起显然是一种具有正式名称的重构(例如,符合定义的内联方法重构),并且它将破坏内联方法的测试-现在一些测试用例应通过其他方式重写/测试。我不必为了破坏单元测试而改变程序的行为,我要做的就是重组与单元测试结合在一起的内部结构。只要程序的行为没有改变,这仍然符合重构的定义。
科拉

我写上面的假设是编写良好的测试:如果您要测试实现-如果测试的结构反映了被测代码的内部,那么请确定。在这种情况下,请测试单位合同,而不是执行合同。
弗兰克·希勒

4

我认为这个问题的麻烦在于,不同的人对“重构”一词的理解不同。我认为最好仔细定义您可能要说的几件事:

>  Keep the API the same, but change how the API is implemented internally
>  Change the API

正如另一个人已经指出的那样,如果您保持API不变,并且所有回归测试都在公共API上运行,那么您应该没有问题。重构应该不会造成任何问题。任何失败的测试都意味着您的旧代码有错误,测试不正确,或者新代码有错误。

但这很明显。因此,您可能意味着通过重构,您正在更改API。

因此,让我回答如何解决这个问题!

  • 首先创建一个新的API,它可以实现您希望的新API行为。如果碰巧这个新API与OLDER API具有相同的名称,则我将名称_NEW附加到新API名称中。

    int DoSomethingInterestingAPI();

变成:

int DoSomethingInterestingAPI_NEW( int takes_more_arguments );
int DoSomethingInterestingAPI_OLD();
int DoSomethingInterestingAPI() { DoSomethingInterestingAPI_NEW (whatever_default_mimics_the_old_API);

好的-在这个阶段-您所有的回归测试都通过-使用名称DoSomethingInterestingAPI()。

下一步,遍历您的代码,并将对DoSomethingInterestingAPI()的所有调用更改为DoSomethingInterestingAPI_NEW()的相应变体。这包括更新/重写需要更改回归测试的任何部分才能使用新的API。

接下来,将DoSomethingInterestingAPI_OLD()标记为[[deprecated()]]。只要您愿意,就保留不推荐使用的API(直到您安全地更新了可能依赖该API的所有代码)。

使用这种方法,您的回归测试中的任何失败都只是该回归测试中的错误,或者只是您想要的代码中的错误。通过显式创建API的_NEW和_OLD版本修改API的分阶段过程,使您可以将新代码和旧代码的一部分同时存在。


我喜欢这个答案,因为很明显,SUT的单元测试与已发布的Api的外部客户端相同。您规定的内容与SemVer协议非常相似,用于管理已发布的库/组件,以避免“依赖地狱”。然而,这是以时间和灵活性为代价的,将这种方法外推到每个微单元的公共接口也意味着外推成本。一种更灵活的方法是尽可能将测试与实现分离开来,例如集成测试或单独的DSL以描述测试输入和输出
KolA

1

我假设您的单元测试是一个粒度,我称之为“愚蠢” :),即,它们测试每个类和函数的绝对细节。远离代码生成器工具并编写适用于更大表面的测试,然后您就可以根据需要任意重构内部结构,知道与应用程序的接口没有更改,并且测试仍然可以工作。

如果您想拥有测试每种方法的单元测试,那么期望必须同时重构它们。


1
实际解决该问题的最有用的答案-不要在内部琐事的摇摇欲坠的基础上建立测试范围,或者不要期望它会不断瓦解-但大多数人对此并不满意,因为TDD规定采取完全相反的做法。这是您指出过度宣传的不便之处。
KolA

1

在重构期间和重构之后,使测试套件与代码库保持同步

使耦合变得困难的是什么。任何测试都与实现细节有某种程度的耦合,但是单元测试(无论是否为TDD)在此方面尤其糟糕,因为它们会干扰内部:更多的单元测试等于将更多的代码耦合到单元,即方法签名/任何其他公共接口单位-至少。

从定义上讲,“单元”是底层实现细节,随着系统的发展,单元的接口可以并且应该改变/分割/合并以及以其他方式变异。实际上,大量的单元测试可能会阻碍这种发展,而无济于事。

重构时如何避免破坏测试?避免耦合。在实践中,这意味着避免尽可能多的单元测试,而更倾向于高层/集成测试,而不了解实现细节。请记住,虽然没有灵丹妙药,但是测试仍然必须在某种程度上与某些事物耦合,但是理想情况下,它应该是使用语义版本控制显式版本化的接口,即通常在已发布的api /应用程序级别(您不想做SemVer用于解决方案中的每个单元)。


0

您的测试与实现紧密结合在一起,而不是要求。

考虑使用以下注释编写测试:

//given something
...test code...
//and something else
...test code...
//when something happens
...test code...
//then the state should be...
...test code...

这样,您就无法在测试之外重构含义。

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.