TDD如何使重构更容易?

30

我听说使用TDD开发的项目更容易进行重构,因为这种实践会产生一组全面的单元测试,如果任何更改破坏了代码,这些测试用例(希望如此)将会失败。然而,我所看到的所有示例都涉及到重构实现 - 比如用更高效的算法进行更改。

但是,在早期设计仍在进行的阶段,重构架构要更加常见。接口会改变,新类会被添加和删除,甚至函数的行为也可能稍微改变(我认为它需要做这个,但实际上它需要做那个),等等... 但是,如果每个测试用例都与这些不稳定的类紧密耦合,难道你不必每次更改设计时都不断重写测试用例吗?

在TDD的哪些情况下修改和删除测试用例是可以的?您如何确保修改测试用例不会破坏它们?此外,似乎需要将全面的测试套件与不断变化的代码进行同步会很麻烦。我理解,一旦软件建立,稳定并运行良好,单元测试套件在维护过程中可以非常有帮助,但这是在游戏晚期,而TDD应该在早期也有所帮助。

最后,好的TDD和/或重构书籍是否会解决这些问题?如果是,您会推荐哪一本?


我一直在思考同样的问题。从某种意义上说,测试可以被认为是违反DRY原则的,因为一段代码的行为既反映在该代码中,也反映在测试它的代码中。 - Boris
8个回答

10
需要记住的一件事是,TDD主要不是测试策略,而是设计策略。你首先编写测试,因为这有助于你想出更好的解耦设计。而更好的解耦设计也更容易进行重构。
当您更改类或方法的功能时,测试自然也必须更改。实际上,按照TDD的方法,您应该首先更改测试。如果您必须更改许多测试来仅更改单个功能位,那么通常意味着大多数测试正在过度指定行为-它们正在测试超过应该测试的内容。另一个问题可能是您的生产代码中没有很好地封装职责。
无论是什么原因,当您遇到许多测试因小的更改而失败时,您都应该重构代码,以便今后不再发生这种情况。虽然不总是很明显,但总是有可能做到这一点。
对于更大的设计更改,情况可能会变得有些复杂。是的,有时编写新测试并丢弃旧测试会更容易。有时,您至少可以编写一些集成测试,测试被重构的整个部分。并且您希望仍然拥有您的验收测试套件,这些测试通常不受影响。
我还没有阅读过它,但我听说过书籍 "XUnit Test Patterns - Refactoring Test Code" 很不错。

当一个算法有许多边界条件时,你会怎么做?使用非平凡的BNF语法解析文本是一个很好的例子。你已经完成了一半的解析器,但需要稍微改变语法。由于语法树发生了变化,所有的测试都会失败。该死的300字符限制。 - Cybis
所有的测试不需要全部失败。你需要改变你的设计,使其遵循单一选择原则 - 在你的解析器中应该只有一个受到这个小改变影响的地方 - 只有这部分解析器的测试才需要改变。 - Ilja Preuß
当你谈论设计策略时,你如何考虑编写代码,使其可以进行测试 - 但不一定从一开始就编写测试呢? - Dirk Boer

8
此外,似乎需要将全面的测试套件与不断变化的代码同步会很麻烦。我理解单元测试套件可以在维护期间提供巨大帮助,一旦软件构建、稳定和运行,但这是在游戏晚期,而TDD应该在早期帮助。

我确实同意,在进行重大架构更改时,已经有一个单元测试套件会感受到额外负担,但我的观点是,拥有单元测试的好处远远超过这个缺点。我认为问题通常是心理上的——我们倾向于认为我们的单元测试是代码库中次等公民,并且我们不喜欢去处理它们。但随着时间的推移,当我依赖它们并欣赏它们的用处时,我开始认为它们和代码库中的任何其他部分一样重要,也同样值得维护和工作。

主要的架构“变化”是否真的只是重构?如果你只是进行重构,不管有多大改动,测试开始失败了,这可能表明你无意中改变了某个功能。而单元测试就是帮助你发现这些问题的。如果你同时对功能和架构进行全面的更改,你可能需要考虑放慢速度,并进入红/绿/重构的节奏:没有额外的测试就不添加新的(或更改的)功能,而在重构时不改变功能(并破坏测试)。
更新(基于评论):
@Cybis对我声称重构不应该破坏测试的反驳很有趣,因为重构会改变API,因此测试会“破裂”。
首先,我鼓励任何人访问关于重构的权威参考:Martin Fowler's bliki。刚才我回顾了一下,发现了一些事情:
  • 更改接口是否算重构? 马丁称重构为“保留行为”的更改,这意味着当接口/API更改时,所有调用该接口/API的调用方也必须更改。包括测试。
  • 这并不意味着行为已经改变。同样,Fowler强调他对重构的定义是改变是保持行为不变

鉴于此,如果在重构过程中需要更改一个或多个测试,我不认为这会“破坏”测试。这只是重构的一部分,是为了保留整个代码库的行为。在重构过程中,我认为测试需要和代码库的任何其他部分一样被重视。(这又回到了我之前所说的将测试视为代码库中的一等公民。)

此外,我希望测试,即使是修改后的测试,在重构完成后仍然能够“继续通过”。无论那个测试在测试什么(可能是测试中的assert(s)),重构完成后应该仍然有效。否则,这表明在重构过程中行为发生了变化或退化,这是一个警示信号。
也许这听起来像胡言乱语,但请思考一下:我们认为在生产代码库中移动代码块并期望它们在新的上下文中继续工作是理所当然的(新类、新方法签名等)。我对测试的看法也是如此:也许重构会改变测试必须调用的API或测试必须使用的类,但最终测试的目的不应因为重构而改变。
(我能想到的唯一例外是测试低级实现细节的测试,你可能希望在重构时更改这些细节,例如将LinkedList替换为ArrayList之类的操作。但在这种情况下,可以说测试过度测试且过于僵化和脆弱。)

1
我从来不明白为什么人们会说“重构不应该破坏测试,因为行为不会改变”。接口是会改变的!如果你有一个大型函数和几十个单元测试,测试不同的边界条件,当你将该函数重构成更小的函数时,所有这些测试都会失败! - Cybis
1
为什么我不能编辑评论?烦死了。我的意思是,如果您重构接口以便调用不同的函数来访问相同的行为,则测试会中断(这种重构可以提高可读性,不应避免)。 - Cybis
感谢@Cybis的评论,我已根据他们的建议更新了我的回答。 - Scott Bale
那是一份很好的解释。谢谢。更改被接受的答案是否公平呢?哈哈。 还有一件事,单元测试与产品代码紧密耦合是否很典型?例如,为仅使用一两次的东西撰写一打(或更多)测试用例? - Cybis
感谢@Cybis。我认为重要的不是某个方法被使用的频率有多高。但是,如果一个方法需要数十个测试来测试所有边缘情况,也许这个方法正在做太多的事情,应该进行重构。在这种情况下,通常,测试也必须移动到新的位置。 - Scott Bale

7
TDD(测试驱动开发)对重构的主要好处在于,开发人员更有勇气改变他们的代码。准备好单元测试后,开发人员敢于更改代码,然后运行它。如果xUnit栏仍然是绿色的,他们就有信心继续前进。
个人而言,我喜欢TDD,但不鼓励过度使用TDD。也就是说,不要编写太多的单元测试用例。单元测试应该足够。如果您进行了过多的单元测试,则可能会发现当您想进行架构更改时,陷入困境。生产代码中的一个大更改将带来许多单元测试用例的更改。因此,请确保您的单元测试足够。

1
然而,有人认为部分TDD并不是真正的TDD。直接编写代码而没有先为其编写测试会打破整个TDD口号。如果所有代码都是先写测试,那么如何避免过度TDD呢? - Cybis

4

TDD的意思是先写一个失败的测试。这个测试被写出来是为了展示开发者理解用例/故事/场景/流程的目的。

然后你编写代码以满足测试。

如果需求变化或被误解,先编辑或重写测试。

红灯,绿灯,对吧?

Fowler的《重构》是重构的参考书,很奇怪。

Scott Ambler在《Dr. Dobb's》杂志上的系列文章(“敏捷边缘?”)是TDD实践的很好的演示。


3

例如使用更有效的算法来替换原有算法,这就是性能优化,而不是重构。重构是关于改进现有代码的设计,即更改其形态以更好地满足开发者的需求。 改变代码以影响外部可见行为并不是重构,包括为了提高效率而进行的更改。

TDD 的价值之一在于您的测试可以帮助您在改变产生结果的方式时保持可见行为不变。


我知道。切换算法是一种优化问题,而不是重构问题。重构是重新排列代码 - 将行为移动到更合适的类中,将大型类拆分为更小、更具凝聚力的类等等……只是许多在线文章没有展示这方面的现实例子。 - Cybis

2

1
在TDD中,什么情况下可以更改和删除测试用例?如何确保更改测试用例不会破坏它们?此外,似乎需要将全面的测试套件与不断变化的代码同步可能会很麻烦。
测试和规范的目的是定义系统的正确行为。因此,非常简单:
if definition of correctness changes
  change tests/specs
end

if definition of correctness does not change
  # no need to change tests/specs
  # though you still can for other reasons if you want/need
end

因此,如果应用程序/系统规格或期望的行为发生变化,改变测试是必要的。在这种情况下仅更改代码而不更改测试显然是错误的方法。你可能认为这很痛苦,但没有测试套件更加痛苦。:) 正如其他人所提到的,拥有那种“敢于”更改代码的自由确实非常有力和解放。:)


1

肯特·贝克的TDD书。

先测试。遵循S.O.L.I.D面向对象编程原则和使用良好的重构工具是必不可少的,如果不是必需的话。


什么是S.O.L.I.D?请解释一下。 - Tilendor
SOLID是Robert Martin在《敏捷软件开发:原则、模式和实践》一书中提出的设计原则,包括单一职责原则、开闭原则、里氏替换原则、接口隔离原则和依赖倒置原则。 - quamrana

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