在另一个问题中,揭示了TDD的难题之一是在重构期间和重构之后使测试套件与代码库保持同步。
现在,我非常喜欢重构。我不会放弃做TDD。但是我也遇到过这样的测试问题,即以较小的重构会导致很多测试失败。
重构时如何避免破坏测试?
- 您是否将测试写得更好?如果是这样,您应该寻找什么?
- 您是否避免某些类型的重构?
- 有测试重构工具吗?
编辑:我写了一个新问题,问我要问什么(但将此问题保留为一个有趣的变体)。
在另一个问题中,揭示了TDD的难题之一是在重构期间和重构之后使测试套件与代码库保持同步。
现在,我非常喜欢重构。我不会放弃做TDD。但是我也遇到过这样的测试问题,即以较小的重构会导致很多测试失败。
重构时如何避免破坏测试?
编辑:我写了一个新问题,问我要问什么(但将此问题保留为一个有趣的变体)。
Answers:
您要尝试做的并不是真正的重构。有了重构,顾名思义,你不改变什么软件呢,你改怎么它做它。
从所有绿色测试开始(全部通过),然后在“幕后”进行修改(例如,将方法从派生类移至基类,提取方法或使用Builder封装Composite等等)。您的测试应该仍然通过。
您所描述的似乎不是重构,而是重新设计,这也可以增强被测软件的功能。TDD和重构(正如我在此处尝试定义的)不冲突。您仍然可以重构(绿色-绿色)并应用TDD(红色-绿色)来开发“增量”功能。
与其他答案相反,必须注意的一点是,如果测试是白盒测试,则在重构被测系统(SUT)时,某些测试方法可能会变得脆弱。
如果我使用一个嘲讽的框架,验证了秩序的呼吁嘲笑(当顺序无关紧要,因为调用是没有副作用)的方法; 然后,如果我的代码通过按不同顺序进行的那些方法调用而更加干净,然后我进行重构,则测试将中断。通常,模拟可以将脆弱性引入测试。
如果我通过暴露SUT的私有成员或受保护成员来检查SUT的内部状态(我们可以在Visual Basic中使用“朋友”,或者在c#中升级访问级别“内部”并使用“ internalsvisibleto”;在许多OO语言中,包括C#中的“ 测试专用子类可以用来”)然后突然类的内部状态会事-你可能会重构类作为一个黑盒子,但白箱测试将失败。假设当SUT更改状态时,单个字段被重用表示不同的意思(不是很好的做法!)-如果我们将其拆分为两个字段,则可能需要重写损坏的测试。
特定于测试的子类也可以用于测试受保护的方法-从生产代码的角度来看,这可能意味着重构是从测试代码的角度来看的重大更改。将几行移入或移出受保护的方法可能不会产生生产副作用,但会破坏测试。
如果我使用“ test hooks ”或任何其他特定于测试或条件的编译代码,由于对内部逻辑的脆弱依赖性,很难确保测试不会中断。
因此,为防止测试与SUT的内部细节耦合,它可能有助于:
以上所有要点都是测试中使用的白盒耦合示例。因此,要完全避免重构破坏测试,请使用SUT的黑盒测试。
免责声明:出于此处讨论重构的目的,我在广义上使用了这个词,以包括更改内部实现而没有任何可见的外部影响。一些纯粹主义者可能会不同意并仅参考Martin Fowler和Kent Beck的书《 Refactoring》,该书描述了原子重构操作。
在实践中,我们倾向于采取比此处描述的原子操作大得多的不间断步骤,尤其是那些使生产代码从外部表现出相同行为的更改可能不会使测试通过。但是我认为将“替代算法替换为具有相同行为的另一种算法”作为重构是公平的,我认为Fowler同意。Martin Fowler本人说重构可能会破坏测试:
编写模拟测试时,您正在测试SUT的呼出电话,以确保它与供应商的通话正确。经典测试仅关心最终状态-而不是最终状态的得出方式。因此,模拟测试与方法的实现更相关。更改对协作者的呼叫的性质通常会导致模拟测试失败。
[...]
与实现的耦合也干扰了重构,因为与传统测试相比,实现更改更容易破坏测试。
福勒-Mo不是存根
如果您的测试在重构时中断,那么按照定义,您就不是重构,即“在不更改程序行为的情况下更改程序结构”。
有时您确实需要更改测试的行为。也许您需要将两个方法合并在一起(例如,侦听的TCP套接字类上的bind()和listen()),因此您的代码的其他部分会尝试并且无法使用现在已更改的API。但这不是重构!
我认为这个问题的麻烦在于,不同的人对“重构”一词的理解不同。我认为最好仔细定义您可能要说的几件事:
> 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的分阶段过程,使您可以将新代码和旧代码的一部分同时存在。
在重构期间和重构之后,使测试套件与代码库保持同步
使耦合变得困难的是什么。任何测试都与实现细节有某种程度的耦合,但是单元测试(无论是否为TDD)在此方面尤其糟糕,因为它们会干扰内部:更多的单元测试等于将更多的代码耦合到单元,即方法签名/任何其他公共接口单位-至少。
从定义上讲,“单元”是底层实现细节,随着系统的发展,单元的接口可以并且应该改变/分割/合并以及以其他方式变异。实际上,大量的单元测试可能会阻碍这种发展,而无济于事。
重构时如何避免破坏测试?避免耦合。在实践中,这意味着避免尽可能多的单元测试,而更倾向于高层/集成测试,而不了解实现细节。请记住,虽然没有灵丹妙药,但是测试仍然必须在某种程度上与某些事物耦合,但是理想情况下,它应该是使用语义版本控制显式版本化的接口,即通常在已发布的api /应用程序级别(您不想做SemVer用于解决方案中的每个单元)。
您的测试与实现紧密结合在一起,而不是要求。
考虑使用以下注释编写测试:
//given something
...test code...
//and something else
...test code...
//when something happens
...test code...
//then the state should be...
...test code...
这样,您就无法在测试之外重构含义。