TDD 使用心得
TDD(测试驱动开发)使用心得 本文迁移自作者 CSDN 账号下文章
*在学习《自己动手写Java虚拟机》时,使用 TDD 的过程、心得。本文内容不介绍 TDD *
初见
在《代码整洁之道 程序员的职业素养》这本书中,作者用了一章来介绍 TDD ,并在该章及其后续章节分析了一些例子,让人心动不已,跃跃欲试。于是在不久后的一次练习(学习《自己动手写Java虚拟机》并按照章节做),开始初次将该思想真正用于使用,下文即是我在学习该书时,使用 TDD 的经历。
开始(1~4章)
开始时似乎一切顺利,严格遵循测试驱动开发,在开始新的一章前首先通读全章,把握了该章节的大体内容后,开始写的第一行代码是单元测试代码,然后写能通过测试的最少的代码,重复这个过程。因为写程序的模式类似递归式,通常会用上多个书签,在章节内跳来跳去,配合IDE提供的功能、TODO注释、再加上来自测试的错误提示,写的代码比较正确。很快几章过去了。开始感觉到了这种开发模式的爽,尤其是一大堆测试用例一个个通过的时候,就像在在线评测上斩获了一列 AC 一样。到章节结束,书中给出的测试示例,通常调试两三次就会通过,没有太大障碍。
后来(5~8章)
随着学习的推进,程序规模逐渐增大,单元测试的难度也越来越大,我开始发现测试代码的代码量开始超过产品代码,耗在测试上的时间也越来越多。而且很多时候,我不得不绞尽脑汁来编写测试用例,使之可以驱动我的开发,我不得不改写书中给出的代码结构、变量权限控制来构造满足测试需要的运行环境。
我的测试代码开始变乱,为了配合测试,我在产品代码中混杂了一些辅助测试的函数。我不知道这种做法是不是明智,因为这些代码在后来给我造成了一些麻烦。
我开始怀疑这种开发模式是否真如所说会降低整个系统的开发时间,在那之后的部分代码中,我放弃了测试驱动开发,变成了被动测试。即先写产品代码,然后补充写单元测试。到了这个时候,写的单元测试早已不像刚开始那几章追求 100% 覆盖率,不过基本会覆盖所有函数,语句测试覆盖率勉强达到 80% 左右。
再后来,我开始忘记单元测试这回事,速度似乎真的快了很多,毕竟之前写测试花费的时间太多。于是,我放弃了单元测试,完全跟着书的节奏走。自然,错误率上去了,但是似乎影响不大,只是在通过每章结尾的测试上遇到一些麻烦,通常不会太久。我开始觉得测试也就是那么回事。
即将结束(9章)
该书第九章讲解 JVM 的本地方法调用,章节难度而言并不是太难,与前几章相比难度相当,但是却直接导致了程序的失败。
这章给出的测试稍微多一些,章节一半的时候即有测试,在这个测试上,我卡住了,一直在报空指针异常,我仔细核对了本章写过的所有代码,大概两三次,没有发现任何问题。通过断点得到的虚拟机栈中的对象引用仍然是空指针,我开始怀疑是不是书的问题,于是加了读者群,没人说这个问题。查了书籍的错误列表,将相关的部分进行了修正,再次运行,没有任何变化,仍然是空指针,这时其实就已经很清楚了,我之前写的那些代码里不一定哪里出了问题,但是搜索范围太广,毕竟我不可能再去一章一章核对代码,其工作量不亚于重写一遍,而且没有意义。调试到这个时候,我已经在第九章中间的这个 bug 上卡住接近两天了。除了借助外力或重写,我感觉我不可能找出问题出在哪里,当前程序的这个规模来看,继续调试的话,估计找出问题的时候,重写也差不多了。
我选择了借助外力,但实际开发中并没有这个选项。
我到 Github 上把随书代码搞了下来,作者很友好地把每一章节的代码都分别打了包,我找到第九章代码,导入到 IDE 中,同时运行两个程序(作者给出的程序和我自己那已经废了的程序)一个指令一个指令地调试,对照栈帧中的数据。根据程序计数器的值调整断点的条件,终于定位到问题,发现是第五章遗留的拼写错误导致了这个空指针,因为这里的拼写错误太隐蔽,语法没有错误,单词也没拼错,属于逻辑错误,错误原因是将未初始化的对象引用直接推入了操作数栈,IDE 不可能发现这种问题,直接调试也很难定位。
但是改了这个之后,又出现了其他问题,可以确定第九章代码没有问题,因为写得也不多,还逐行对照了两遍。我继续采用这个方法,先后发现了在 3、5、6、7 章中的多个拼写错误,甚至有的函数只实现了一半,还没加 TODO 注释,估计是写的时候遇上啥事了,或者只是单纯的忘了,其造成的症状可以用诡异来形容。一个真正有问题的代码造成的问题,会在几个循环后在其他地方报出来,症状十分奇怪,根据 log 出来的异常栈信息,在异常抛出前的断点根本分析不出来问题。
举个特殊的例子,有个 bug 是我在第四章写指令时留下的,bug 的原因看似是因为测试代码造成的。在程序写到这里时还有单元测试,我并没有发现我把一处方法名的 and 写成了 add,这其实导致 and 这个方法并没有被使用,按理说 IDE 会给出警告,但是这个方法(and)却在测试代码中使用了,所以 IDE 并没有警告,这导致在运行时,把两个数的按位与运算愣是按照加法运算给算了,本来应该是 1 的结果变成了一个很大的数,算完之后推入操作数栈不管了。之后若干个指令后用到这个数据的指令是一个数组的指令,这个指令取出这个操作数发现太大,直接抛出数组下标越界异常。运行过程中操作数栈中有一些陌生的数很正常,根本不知道哪个数有问题、而且发现了错误的数也很难发现它是咋错的,想定位到这个问题十分困难,当然我也是通过与正确的程序逐指令对照才找到的问题。
第九章调试的时间可以说很长了,总共三个测试,每个测试里都栽了不止一次,不作弊的话,解决一个这种问题的时间完全不可预知。而我用了作弊的方法,让本该失败的程序可以继续进行。在那之后,我试着进行补救,为第九章剩下的部分写了被动的单元测试。
结束(10章+)
实际上,第十章仍然遇到第九章类似的问题,并终于磕磕绊绊的走到了书的结尾。没有发现在9、10章的 bug ,估计应该是书中在这两章给出的测试覆盖比较到位的原因。
总结
在具有规模的程序中,单元测试的确会使系统的总开发时间降低,这是很多人亲历过的,我也姑且算是经历了一次,虽说目前来看,如果全部按照 TDD 来做这个,估计在我写这篇总结的时候还没有搞完,但是我的这个程序规模毕竟还算不上大,而且还做了弊,所以不太具有可比性。
文章中提到了一个例子,看似是由于测试导致的 bug ,但实际上是因为测试不够导致的,那个例子的逻辑是这样的:根据分析得到的字节码,到一个包含特别长的 switch 语句的函数中取指令,而我在其中的两个 case 中返回了相同的指令。实际上我在测试时发现这个函数太长,分支太多,难以覆盖(毕竟200多个case)所以针对它的测试就比较敷衍。
至于被动测试和测试驱动开发的选择,还拿上述的例子,如果至始至终都遵循 TDD ,那么也就不存在这种情况,因为按照 TDD 的说法,那么这里的每一个 case 将是由测试驱动出来的,肯定都会有对应的测试,这个 bug 也会在萌芽时被消灭。因此,如果选择,在具有规模的程序中,TDD 还是具有优势的。