最近读完了《Growing Object-Oriented Software, Guided by Tests》,这本在豆瓣上高达9.5分的好书。事实证明,群众的眼睛是雪亮的。除去中间那个很长的实际项目案例没耐下心来看完,其他部分我都看了不止一遍。虽然还没有读过那本名气很大的《Test Driven Development: By Example》,但到目前为止,这本书已经成了我心中测试驱动开发的“圣经”。
读完全书,印象深刻的地方实在太多了,比如快速反馈的重要性、软件系统的动态视角、测试代码的作用、单元测试应该是什么样子等等。除了方法论,书中关于好代码的想法与之前读完的另一本经典《Elegant Objects》也异曲同工,比如简洁短小的类、窄接口、尊重对象的抽象、声明式编程等等。甚至书中还讲到一些“哲学”思考,比如软件像有机物一样生长而不是建造,倾听你的测试看它到底需要什么,要从对象间的关系网中动态地看软件系统。
总体来说,这是一本程序员不可不读,而且还要放在手边反复翻阅的好书。下面就来说说书中重点的内容,算是抛砖引玉了。
软件质量一般分外部和内部两种,外部质量一般比较容易度量,因为它更容易直观的看到,比如对用户要求的功能实现得好坏。而内部质量,也就是我们经常说的代码质量,则抽象得多,比较难衡量的。一般最常说到的指标可能就是高内聚和低耦合。测试驱动开发(TDD)之所以这么流行,正是因为遵循它确实可以得到高质量的代码。
具体来说好处有三点:1)从测试开始意味着你要先描述你要做什么(What)而不是如何做(How),最终测试将作为活文档存在;2)让一个类容易测试,意味着它要有合适的大小和职责,以及清晰的接口定义,即高内聚;3)测试一个类还意味着你要为其初始化依赖,帮你做到低耦合。在TDD的过程中,伴随着大量的“无情地”重构,不断帮你发现新的接口抽象,提取出新的方法和类。
这里顺便提一个书中讲到的重要的观念转变,就是从静态的接口和类来看软件系统,进阶到从动态的对象关系中来看。作者提到这种运行时的关系声明,在目前编程语言里是欠缺的。正如书中所说,接口和对象只能告诉你这些类的对象能够适配(fit),而它们是否能一起工作(work)得到想要的系统行为,则要看运行时的通信。
TDD并不是简单地把调整开发和测试的顺序。它之所以好用并且流行起来,背后是有其哲学思想做支撑的。说起来有些玄,其实道理很简单,这个思想就是“实践出真知”。就好比物理学家做实验来验证其假设,我们程序员也通过实践来验证设计是否可行。
那可能很多人会说:编码本身不就是实践吗?设计好了就按照概要和详细设计文档开发不就可以了吗?这里的关键在于:你能多快得到反馈,从而验证你的想法是可行的。开发了一大半甚至最后收尾时,发现致命问题或者组合不起来,导致项目延期、返工甚至彻底失败。你也得到了反馈,你也通过实践得到了“真知”,可是代价太大了。那如何才能避免这样的风险呢?答案就是遵循TDD的流程来开发,并且在每一步都执行最佳实践。
文章后面的这两大部分,就从整体和细节上介绍一下TDD。首先,下面就是TDD的总体流程图。这张图是我在反反复复读了这本书之后,将几张散落的流程图的合并得到的。
关于TDD循环的具体内容会在下一部分介绍,这里先重点说说几个大家可能比较感兴趣的环节。
上面这个大循环开始的第一步就是要有一个整体的系统“骨架”(Skeleton),这样才能把集成测试的设施准备好。为了避免误解,作者解释道这并不是说要先有一个完整的设计(Big Design Up Front,BDUF),像传统瀑布式模型一样。这里想说的是,你至少要知道自己要做什么。所以一个黄金法则就是,“骨架”应该能在白板上花几分钟就画出来,是整个系统最高、最“薄”的一层。
作者还建议如果条件允许,在一块白板或者组内的网站上,动态维护一张系统的架构图,让大家对系统的理解都尽可能在一个平面上。看到这时我在思考,是否可以维护一个动态的、自动从代码中顶层类生成的架构图呢?
这是TDD循环中比较容易忽视的一环,就是写好一个失败的测试用例后,创建出空的接口和类。然后不要急着去实现功能,而是先观察,看目前的错误消息是不是足够提示你哪里出错了。比如入参对象的描述不够清楚,断言的失败消息不明确等等。
提高错误消息的明确性一般有三种方式:1)断言时手动附加一句消息;2)提取数据对象,并实现其自描述的方法,如Java里的toString;3)扩展Hamcrest等框架。通常,我们可以先提取数据对象,不得不对里面的具体属性做断言时(后面会讲到要尽可能降低断言的粒度),在硬编码一句消息。Hamcrest这种好用的框架要熟悉,这样能省去不少麻烦。
即便遵循TDD去开发,切入的顺序也是很重要的。添加新功能最大的忌讳就是直接针对核心的业务对象进行TDD。
正确的做法是从验收测试开始,添加好后进入TDD开发循环。具体顺序是,从系统的边界开始,逐步向内,比如从API到Service到业务逻辑类。这就像水面上的泛起的涟漪一样,从前到后,从外向内,逐渐实现这个功能所需的所有类。
这可能是在实际编码方面,对我影响最大的一点了。以前我一直无法理解这句话,因为觉得如果一个接口的几个方法要配合起来使用的话,为什么不合并隐藏到一个接口方法之后呢?直到最近反思自己写的一个单元测试才顿悟,关键问题是“时间差”。在一个测试场景里,接口的几个方法可能必须在不同的时间点调用才行。
举一个例子,数据库的执行计划,按照传统教材里的说法,每个运算符都应该是一个Iterable的类,并实现打开、关闭以及取下一条数据的方法。
Class TableScan {
Void open();
Row next();
Void close();
}
Class TableScanTest {
Void open();
Void openWithIOException();
Void fetchData();
Void close();
Void closeWithIOException();
}
初看之下,这个单元测试没什么大问题。而且每个方法的正常和异常情况都覆盖到了,测试覆盖率应该不错。可它最大的问题就是测试的是方法而不是行为,这样的单元测试:1)无法看到动态的关系全图,因为它没有一个完整的场景;2)无法充当类的文档,因为同样的原因。
一个比较好的单元测试可能是这个样子的,模拟了这个类的使用者是如何逐行获取数据的:
class TableScanTest {
Void executePlanOfTableScan() {
TableScan plan = ...
Plan.open()
Row row = plan.next()...
Plan.close();
}
Void executePlanOfTableScanWithIOException() {
...
}
}
当你开始写测试代码时,不要在意语法,忽略代码的编译错误,专注在以最简洁和自然语言的方式(声明层)表达出要测试什么。反复读你的测试,直到你满意为止,再开始构建支撑其实现的代码(实现层)。
测试代码本质上与线上代码正相反:测试代码的输入和输出是具体的,但被测试对象的执行是抽象的。而线上代码的输入和输出是未知的、抽象的,但如何执行却是具体的。此外,测试的一个重要是展现出对象之间的关系图。
这两点也正对应前面所提的,针对方法测试导致的两个问题。正因如此,好的单元测试应该清晰地展示测试输入数据、期望结果,依赖对象的交互,同时弱化被测试对象的执行细节。
同时测试的名字也很有学问,要能清晰地描述出被测试的功能(Feature)。书中提到了一种叫做TestDox的命名方式。这里有两点要注意的:1)不要担心方法名字过长,比如JUnit,运行时会利用反射调用它;2)想象每个测试方法名字的主语都是当前被测试对象。
下面几个测试方法的名字,好坏一目了然:
@Test public void test1(), test2(), test3()...
@Test public void isReady(), add()...
@Test public holdsItemsInTheOrderTheyWereAdded()...
尽管测试内容不同,大多数测试代码都具有如下的基本结构:
经过不断地重构,最终测试代码会逐渐分化成两个层次:声明层(Declarative layer)和实现层(Implementation layer)。前者在后者基础上,通过各种语法糖,去除语言中的语法杂音,简洁地描述要测试“什么”。而实现层则是具体的实现逻辑。声明层类似编译器的前端,负责语言语法的解析,而实现层则类似解释器去解释执行。从这种角度来看,每个测试的声明层都可以看作是一个迷你的领域特定语言(Domain-specific language,DSL)。
有时被测试对象要求的输入对象会比较复杂,导致测试数据的构建也变得冗长,直接模糊了一个测试用例的用意。这时我们要想尽办法简化测试数据的构建,同时还不能让其太抽象。设计模式中的Builder模式能帮我们大忙。
此外,因为前面讲到测试代码的具体性,所以不可避免地会出现很多数字、字符常量。一定要确保这些常量的含义是明确的,必要时将其提取为局部变量或者全局的静态变量。
写断言(Assertions)经常犯的毛病就是一个方法的每个测试用例都很像,都直接断言了整个返回值。这将会导致两个问题,一是测试的目的不清晰,无法当成类的活文档;二是难以定位错误和维护修改,因为用例之间有太多重复,修改一点代码就会导致很多测试失败。
所以,我们要做到:1)避免去断言返回结果中,不是由当前测试输入驱动的部分;2)避免重复断言其他测试中已经涵盖的部分。其实这两条做起来并不难,因为通常情况下,返回结果是一个对象,我们只需对其中的某个或某几个属性断言即可。
关于断言的可读性,Hamcrest应该就是最好的帮手了。虽然准备时会显得代码很多,因为要扩展其Matcher,但最后写出的断言的确是非常漂亮,可读性极高的描述式的语句。
类似地,我们也要有准确的期望(Expectations),即依赖的外部对象会被如何调用,按照什么顺序调用,调用几次,消息(参数)是什么样的。期望可能是最容易被忽视的,因为像我Mock时经常会“偷懒”,入参全都匹配全部,执行后也不会验证调用的其他信息。但期望恰恰是测试里很重要的部分,别忘了我们前面说的,测试的一个重要作用就是当作文档,明确运行时的对象关系。
最近发现Mockito不知道哪个版本开始,如果你mock了一样东西,但是它并没有被调用的话,它会让测试失败。要么就是你的测试的确多mock了,要么就是你的代码有问题,有的地方没有执行到。这实际上就是自动化了期望的验证,对写好测试还是很有帮助的。
当我们在不断重构中发现新的接口时,要从对象的视角去想“我”到底需要什么。以当前被测试对象作为用户,将自己代入到情境中去提取新的抽象,而不是从外面作为测试它的人认为它应该有什么。
当你发现前面所讲的任何一点,包括依赖、测试数据、断言和期望等,要么需要非常多的代码,要么就是很难测试。这时我们要做的不是一味地堆代码,而是思考这个问题产生的原因是什么。是被测试的类就应该这么复杂,还是我们没有做好高内聚和低耦合。这种反思其实也是通用的解决问题思路里的一环,即在定义问题后思考这是不是一个问题,要不要解决,有没有方法绕过。
下面就总结一下,提取出前面内容中最重要的原则:
最后,再列举几条测试代码的坏味道: