测试驱动开发下的软件生长

测试驱动开发下的软件生长

1.前言

最近读完了《Growing Object-Oriented Software, Guided by Tests》,这本在豆瓣上高达9.5分的好书。事实证明,群众的眼睛是雪亮的。除去中间那个很长的实际项目案例没耐下心来看完,其他部分我都看了不止一遍。虽然还没有读过那本名气很大的《Test Driven Development: By Example》,但到目前为止,这本书已经成了我心中测试驱动开发的“圣经”。

读完全书,印象深刻的地方实在太多了,比如快速反馈的重要性、软件系统的动态视角、测试代码的作用、单元测试应该是什么样子等等。除了方法论,书中关于好代码的想法与之前读完的另一本经典《Elegant Objects》也异曲同工,比如简洁短小的类、窄接口、尊重对象的抽象、声明式编程等等。甚至书中还讲到一些“哲学”思考,比如软件像有机物一样生长而不是建造,倾听你的测试看它到底需要什么,要从对象间的关系网中动态地看软件系统。

总体来说,这是一本程序员不可不读,而且还要放在手边反复翻阅的好书。下面就来说说书中重点的内容,算是抛砖引玉了。


2.理论根基

2.1 高质量编程

软件质量一般分外部和内部两种,外部质量一般比较容易度量,因为它更容易直观的看到,比如对用户要求的功能实现得好坏。而内部质量,也就是我们经常说的代码质量,则抽象得多,比较难衡量的。一般最常说到的指标可能就是高内聚和低耦合。测试驱动开发(TDD)之所以这么流行,正是因为遵循它确实可以得到高质量的代码。

具体来说好处有三点:1)从测试开始意味着你要先描述你要做什么(What)而不是如何做(How),最终测试将作为活文档存在;2)让一个类容易测试,意味着它要有合适的大小和职责,以及清晰的接口定义,即高内聚;3)测试一个类还意味着你要为其初始化依赖,帮你做到低耦合。在TDD的过程中,伴随着大量的“无情地”重构,不断帮你发现新的接口抽象,提取出新的方法和类。

这里顺便提一个书中讲到的重要的观念转变,就是从静态的接口和类来看软件系统,进阶到从动态的对象关系中来看。作者提到这种运行时的关系声明,在目前编程语言里是欠缺的。正如书中所说,接口和对象只能告诉你这些类的对象能够适配(fit),而它们是否能一起工作(work)得到想要的系统行为,则要看运行时的通信。

2.2 快速反馈

TDD并不是简单地把调整开发和测试的顺序。它之所以好用并且流行起来,背后是有其哲学思想做支撑的。说起来有些玄,其实道理很简单,这个思想就是“实践出真知”。就好比物理学家做实验来验证其假设,我们程序员也通过实践来验证设计是否可行。

那可能很多人会说:编码本身不就是实践吗?设计好了就按照概要和详细设计文档开发不就可以了吗?这里的关键在于:你能多快得到反馈,从而验证你的想法是可行的。开发了一大半甚至最后收尾时,发现致命问题或者组合不起来,导致项目延期、返工甚至彻底失败。你也得到了反馈,你也通过实践得到了“真知”,可是代价太大了。那如何才能避免这样的风险呢?答案就是遵循TDD的流程来开发,并且在每一步都执行最佳实践。


3.总体流程

文章后面的这两大部分,就从整体和细节上介绍一下TDD。首先,下面就是TDD的总体流程图。这张图是我在反反复复读了这本书之后,将几张散落的流程图的合并得到的。

测试驱动开发下的软件生长_第1张图片

关于TDD循环的具体内容会在下一部分介绍,这里先重点说说几个大家可能比较感兴趣的环节。

3.1 系统骨架

上面这个大循环开始的第一步就是要有一个整体的系统“骨架”(Skeleton),这样才能把集成测试的设施准备好。为了避免误解,作者解释道这并不是说要先有一个完整的设计(Big Design Up Front,BDUF),像传统瀑布式模型一样。这里想说的是,你至少要知道自己要做什么。所以一个黄金法则就是,“骨架”应该能在白板上花几分钟就画出来,是整个系统最高、最“薄”的一层。

作者还建议如果条件允许,在一块白板或者组内的网站上,动态维护一张系统的架构图,让大家对系统的理解都尽可能在一个平面上。看到这时我在思考,是否可以维护一个动态的、自动从代码中顶层类生成的架构图呢?

3.2 观察失败的测试

这是TDD循环中比较容易忽视的一环,就是写好一个失败的测试用例后,创建出空的接口和类。然后不要急着去实现功能,而是先观察,看目前的错误消息是不是足够提示你哪里出错了。比如入参对象的描述不够清楚,断言的失败消息不明确等等。

提高错误消息的明确性一般有三种方式:1)断言时手动附加一句消息;2)提取数据对象,并实现其自描述的方法,如Java里的toString;3)扩展Hamcrest等框架。通常,我们可以先提取数据对象,不得不对里面的具体属性做断言时(后面会讲到要尽可能降低断言的粒度),在硬编码一句消息。Hamcrest这种好用的框架要熟悉,这样能省去不少麻烦。

3.3 添加新功能的顺序

即便遵循TDD去开发,切入的顺序也是很重要的。添加新功能最大的忌讳就是直接针对核心的业务对象进行TDD。

正确的做法是从验收测试开始,添加好后进入TDD开发循环。具体顺序是,从系统的边界开始,逐步向内,比如从API到Service到业务逻辑类。这就像水面上的泛起的涟漪一样,从前到后,从外向内,逐渐实现这个功能所需的所有类。


4.最佳实践

4.1 用例设计:测试行为而不是方法!

这可能是在实际编码方面,对我影响最大的一点了。以前我一直无法理解这句话,因为觉得如果一个接口的几个方法要配合起来使用的话,为什么不合并隐藏到一个接口方法之后呢?直到最近反思自己写的一个单元测试才顿悟,关键问题是“时间差”。在一个测试场景里,接口的几个方法可能必须在不同的时间点调用才行。

举一个例子,数据库的执行计划,按照传统教材里的说法,每个运算符都应该是一个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() {
        ...
    }
}

4.2 测试的可读性

4.2.1 写你愿意读的测试

当你开始写测试代码时,不要在意语法,忽略代码的编译错误,专注在以最简洁和自然语言的方式(声明层)表达出要测试什么。反复读你的测试,直到你满意为止,再开始构建支撑其实现的代码(实现层)。

4.2.2 抽象程度

测试代码本质上与线上代码正相反:测试代码的输入和输出是具体的,但被测试对象的执行是抽象的。而线上代码的输入和输出是未知的、抽象的,但如何执行却是具体的。此外,测试的一个重要是展现出对象之间的关系图。

这两点也正对应前面所提的,针对方法测试导致的两个问题。正因如此,好的单元测试应该清晰地展示测试输入数据、期望结果,依赖对象的交互,同时弱化被测试对象的执行细节。

4.2.3 测试方法名

同时测试的名字也很有学问,要能清晰地描述出被测试的功能(Feature)。书中提到了一种叫做TestDox的命名方式。这里有两点要注意的:1)不要担心方法名字过长,比如JUnit,运行时会利用反射调用它;2)想象每个测试方法名字的主语都是当前被测试对象。

下面几个测试方法的名字,好坏一目了然:

@Test public void test1(), test2(), test3()...
@Test public void isReady(), add()...
@Test public holdsItemsInTheOrderTheyWereAdded()...

4.2.4 测试代码结构

尽管测试内容不同,大多数测试代码都具有如下的基本结构:

  1. 准备:准备测试所需的上下文环境,包括依赖和输入数据。
  2. 执行:执行目标方法(可能是多个),触发被测试的行为。
  3. 验证:验证被测试行为产生的外部可见的效果,包括返回值和对依赖的调用。
  4. 清理:清理所有可能影响后续测试的状态。

经过不断地重构,最终测试代码会逐渐分化成两个层次:声明层(Declarative layer)和实现层(Implementation layer)。前者在后者基础上,通过各种语法糖,去除语言中的语法杂音,简洁地描述要测试“什么”。而实现层则是具体的实现逻辑。声明层类似编译器的前端,负责语言语法的解析,而实现层则类似解释器去解释执行。从这种角度来看,每个测试的声明层都可以看作是一个迷你的领域特定语言(Domain-specific language,DSL)。

测试驱动开发下的软件生长_第2张图片

4.3 测试数据准备

4.3.1 输入

有时被测试对象要求的输入对象会比较复杂,导致测试数据的构建也变得冗长,直接模糊了一个测试用例的用意。这时我们要想尽办法简化测试数据的构建,同时还不能让其太抽象。设计模式中的Builder模式能帮我们大忙。

4.3.2 常量

此外,因为前面讲到测试代码的具体性,所以不可避免地会出现很多数字、字符常量。一定要确保这些常量的含义是明确的,必要时将其提取为局部变量或者全局的静态变量。

4.4 断言与期望

4.4.1 断言

写断言(Assertions)经常犯的毛病就是一个方法的每个测试用例都很像,都直接断言了整个返回值。这将会导致两个问题,一是测试的目的不清晰,无法当成类的活文档;二是难以定位错误和维护修改,因为用例之间有太多重复,修改一点代码就会导致很多测试失败。

所以,我们要做到:1)避免去断言返回结果中,不是由当前测试输入驱动的部分;2)避免重复断言其他测试中已经涵盖的部分。其实这两条做起来并不难,因为通常情况下,返回结果是一个对象,我们只需对其中的某个或某几个属性断言即可。

关于断言的可读性,Hamcrest应该就是最好的帮手了。虽然准备时会显得代码很多,因为要扩展其Matcher,但最后写出的断言的确是非常漂亮,可读性极高的描述式的语句。

4.4.2 期望

类似地,我们也要有准确的期望(Expectations),即依赖的外部对象会被如何调用,按照什么顺序调用,调用几次,消息(参数)是什么样的。期望可能是最容易被忽视的,因为像我Mock时经常会“偷懒”,入参全都匹配全部,执行后也不会验证调用的其他信息。但期望恰恰是测试里很重要的部分,别忘了我们前面说的,测试的一个重要作用就是当作文档,明确运行时的对象关系。

最近发现Mockito不知道哪个版本开始,如果你mock了一样东西,但是它并没有被调用的话,它会让测试失败。要么就是你的测试的确多mock了,要么就是你的代码有问题,有的地方没有执行到。这实际上就是自动化了期望的验证,对写好测试还是很有帮助的。

4.5 倾听你的测试

4.5.1 假如你是一个对象

当我们在不断重构中发现新的接口时,要从对象的视角去想“我”到底需要什么。以当前被测试对象作为用户,将自己代入到情境中去提取新的抽象,而不是从外面作为测试它的人认为它应该有什么。

4.5.2 为什么难测

当你发现前面所讲的任何一点,包括依赖、测试数据、断言和期望等,要么需要非常多的代码,要么就是很难测试。这时我们要做的不是一味地堆代码,而是思考这个问题产生的原因是什么。是被测试的类就应该这么复杂,还是我们没有做好高内聚和低耦合。这种反思其实也是通用的解决问题思路里的一环,即在定义问题后思考这是不是一个问题,要不要解决,有没有方法绕过。


5.总结

5.1 原则

下面就总结一下,提取出前面内容中最重要的原则:

  1. 系统骨架
    1. 尽可能早地确定系统骨架,实现集成测试自动化。
    2. 骨架要尽可能简单,只包含最明显的模块,足以启动持续集成即可。
  2. 验收测试
    1. 新功能要以添加一个新的验收测试开始。
    2. 新功能要以通过这个验收测试作为结束条件。
  3. 单元测试
    1. 测试驱动应从系统边界向核心领域对象,逐步实现。
    2. 单元测试要从最简单的成功用例开始,而不是异常用例。
    3. 开始编写测试时忽略编译错误,专注于可读性。
    4. 开始开发前,仔细观察失败用例的错误消息。
    5. 要测试行为,而不是方法。
    6. 测试的名字要描述被测试的功能。
    7. 测试数据的构建要尽可能简洁。
    8. 用局部或者全局静态变量命名常量。
    9. 断言要尽可能“窄”、准确,避免重复。
    10. 除了断言,还要有准确的期望。
    11. 只有当你要对异常内容做断言时,才去捕捉它。
    12. 最终测试代码应由声明层和实现层两部分组成。
    13. 当前面任何一项难以施行或过度冗长时,思考是否需要重构被测试对象。

5.2 坏味道

最后,再列举几条测试代码的坏味道:

  1. 测试名字没有清晰地描述出被测试的功能,以及它与其他测试侧重点的不同。
  2. 一个测试看起来在测试多个功能。
  3. 测试代码没有统一的结构,读者无法快速得到每个测试的意图。
  4. 测试里有太多的测试数据构建和异常处理代码,模糊了核心逻辑。
  5. 测试里有很多硬编码的常量,含义不明。

你可能感兴趣的:(读书笔记)