TDD 要不要做提前设计呢?
Kent Beck 不做提前设计,他会选一个最简单的用例,直接开写,用最简单的代码通过测试。逐渐增加测试,让代码变复杂,用重构来驱动出设计。在这个需求里,最简单的场景是什么呢? 那就是文件内容为空,输出也为空。
当然,对于复杂问题,可能要一边写一边补充新的用例,但对于这种简单的题目,基本可以提前就想清楚用什么用例驱动去什么产品代码。大概可以想到如下的用例:
· "" => ""
· "he" | "he 1",一个单词,驱动出格式化字符串的代码
· "he is" | "he 1\r\nis 1",两个不同单词,驱动出分割单词的代码
· "he he is" | "he 2\r\nis 1",有相同单词,驱动出分组代码
· "he is is" | "is 2\r\nhe 1",驱动出分组后的排序代码
· "he is" | "he 1\r\nis 1",多个空格,完善分割单词的代码
Martin Fowler 的观点是,以前我们写代码要做 Big Front Up Design ,在开始写代码前要设计好所有细节。而我们有了重构这个工具后,做设计的压力小了很多,因为有测试代码保护,我们可以随时重构实现了。但这并不代表我们不需要做提前设计了,提前设计可以让我们可以和他人讨论,可以先迭代几次再开始写代码,在纸上迭代总比改代码要快。
我个人比较认同 Martin Fowler 的做法,先在脑子里(当然,我脑子不够用,所以用纸画)做设计,迭代几次之后再开始写,这样,我还是会用最简单的实现通过测试,但重构时就有了方向,效率更高。
回到这个程序,我发现目前的封装不在一个抽象层次上,更理想的设计是:
main() {
String words = read_file('words.txt')
String output = word_frequency(words)
print(output)
}
word_frequency(words) {
String[] wordArray = split(words)
Map<String, Integer> frequency = group(wordArray)
sort(frequency)
return format(frequency)
}
这时候,又有两种选择,有人喜欢自顶向下,有人喜欢自底向上,我个人更倾向于前者。
现在开始,只要照着 红-绿-重构 的循环去做就可以。 大部分 TDD 做不好,就是没有前面的任务分解和列 Example 的过程。 想看 TDD 过程的话,可以参考我之前做的一个直播: TDD hangman in Java 或者如果需要,我也可以录一个这个题目的视频。
FAQ
为什么一定要先写测试,后补测试行不行?
行,但是要写完实现后,马上写测试,用测试来验证实现。如果你先手工测试,把代码都调试好了,再补单元测试,你就会觉得很鸡肋,还增加了工作量。 不管测试先行还是后行都可以享受到快速反馈,不过如果测试先行,你就可以享受另一个好处,使用意图驱动编程减少返工。因为你的测试代码就是产品代码的客户端(调用者),你可以在测试代码里写成你理想的样子(方法名,参数,返回值等),再去实现产品代码,比起先写实现后写测试,前者返工更少。
刚写了一个测试,还没写实现。明知道运行测试一定会报错,为什么还要去运行?
其实测试的运行结果并非只有通过与不通过两种,因为不通过时有很多种可能。所以在明知道一定失败的情况下去运行测试,目的是看看是不是报了期望的那个错误。
小步快走确实好,但真的需要这么小步吗?
步子迈太大,容易扯着蛋。练习的时候需要养成小步的习惯,工作的时候可以自由切换步子的大小。当你自信的时候步子就可以大点,当你不太自信的时候就可以立即切换到小步的模式。如果只会大步,就难以再小步了。
测试代码是否会成为维护的负担?
维护时也遵循 TDD 流程,先修改测试代码成需求变更后的样子,让测试失败,再修改产品代码使其通过。 这样你就不是在维护测试用例,而是在利用测试用例。
为什么要快速实现?
其实是用二分查找法隔离问题,通过 hardcode 实现通过测试后,就基本确定测试是没有问题,这时再去实现产品代码,如果测试不通过,就是产品代码的问题。所以小步快走主要是为了隔离问题,也就是你可以告别 Debug 了。
为什么测试代码要很简单?
如果一个测试失败了,修复的时候是改测试代码而不是产品代码,那就是测试代码写的不好。 当测试代码足够简单时,如果一个测试失败了,就有足够信心断定一定是产品代码的问题。
什么时候不适合 TDD?
如果你是做探索性的技术研究(Spike),不需要长期维护,而且测试基础设施搭建成本很高,那还是手工测试吧。另外还有「可测试性极差的遗留系统」和「使用测试不友好的技术栈」的系统,做 TDD 可能得不偿失。