Soul Orbit

I'll take a quiet life. A handshake of carbon monoxide.

0%

测试,你写代码时最好的朋友(上篇)

最近在做一个多团队合作的项目,需要将我们的服务的配置管理和自动化部署流水线从一个平台迁移到另外一个平台,但是这个项目很要命的是,另外一个团队的成员一心只想赶快完成任务,代码的改动从来就不加测试,每次做了两行修改就开始在自己的分支上创建Official build,然后上运营环境测试。劝了好几次也无动于衷,总觉得测试是一件麻烦的事情,拖了他们的后腿。其实这个项目本来并不复杂,偏偏他们做了两年都没做出来,还弄出不少线上事故,真的是事百倍功半,所以这次就想单独写一点关于测试的事情。


1. “写测试真的好烦”

"Hey! It compiles! Ship it!"

每次提到写测试,很多人会觉得很烦,原因都大同小异:

  • 额外的工作,而且大部分时候极度耗时,甚至比开发的时间还多
  • 开发新功能带来的快感很多时候在功能相关的代码写完之后就结束了
  • 写测试总感觉是个体力活,写了一堆也不一定能发现一个问题
  • 为某个功能写了一堆测试,然后功能变了,所有的测试都要重新写,拖慢开发速度

有上面这些疑问和抱怨,基本上都是因为没弄明白测试的真实意图,最后陷入了为了测试而测试的误区。而当我们被迫做一个事情的时候,我们是永远都不会觉得做这个事情有什么好处的,即便它真的有。


2. 为什么要写测试

那么这么多种类型的测试,到底是用来干什么的呢?

2.1. 保证代码不会出现回归(Regression)

很多人觉得测试当成一个一次性用品,和一次性筷子一样,除了为了应付代码审查,其他并没有什么意义。其实恰恰相反,没有什么比测试更划算的了:一次投资,终身保固!只要测试写完,它就能保证我们的代码一直都按照我们预想的方式来执行。这也是测试最重要的作用之一,而如果实现合理,它的边际成本几乎为0!

千万不要小看了这一条性质,正是因为它,我们才能安心的对代码进行改动。它是我们代码重构的基石(参见《重构》一书),也是一切自动化的前提(参见《持续交付》一书),而可安全重构和自动化才是我们真正要达到的目的

2.2. 确认功能改动

当然,我们还可以反向的利用上面这条性质,那就是当我们改变了代码行为之后,测试应该能如实并且完整的反应这个改变对系统带来的所有变化,而且这些变化应该越清晰越好,这样才方便自查和代码审查。

这里我们先不说具体的方法论,在后面一节我们再来讨论。

2.3. 测试是强制的文档

"Programs must be written for people to read, and only incidentally for machines to execute."
— Harold Abelson and Gerald Jay Sussman, from "The Structure and Interpretation of Computer Programs"

文档最大的问题就是没有约束力,所以文档从写完后的第一秒开始,就开始过时。这也是为什么代码本身应该就能充当自己的文档,因为这样才能保证文档才能永远不会过时。测试也是代码,而且比起其他代码而言,测试是更好的知识库。

  1. 首先,测试演示了被测试的模块的使用方法。熟悉测试驱动开发(TDD)的朋友肯定知道,编写测试的其中一个目的就是帮助我们站在使用者的角度设计并改善接口。虽然我们不一定必须使用测试驱动开发,但是它却侧面反映了这一条作用。而像golang等语言则做的更好,直接在语言层面上对这种测试进行了单独的区分和支持
  2. 其次,测试中的断言保证了它说的所有结论永远为真!这相当于给了我们读代码的捷径,我们不用费力的去整理代码的逻辑,了解所有的细节,就能直接看到绝对为真的结论,没有什么比这种文档更好的了。

正因如此,每当我想了解一个模块时,在简单的阅读设计文档之后,我更多的时间会花在阅读相关的测试上。


3. 测试的原则

现在我们了解了测试的目的,也许你已经燃起一点写测试的兴趣,但是现实总是骨感的,上面我们提到的问题依旧摆在我们的面前,所以写测试的方法论就变得格外重要了。要记住,千万不要为了测试而测试,而是应该在合理的地方进行合理的测试,不要让测试成为我们的累赘,或者成为测试的奴隶,而应该让它变成我们的帮手。

为了帮助我们更好的进行测试,无论是写功能还是写测试都应该遵守以下几条原则:

  • 最短距离:错误检查和执行的操作的距离应该最短。换句话说,出了问题就应该立刻报错。
  • 可观察:程序的行为和状态能被方便和清晰的观察。比如:日志,错误上下文等等信息。
  • 可重复:程序的行为和错误能被方便的重现。不稳定的测试最让人闹心。

我们下面就来讨论一下具体的方法论,并说明如何应用这几条原则。


4. 好的代码本身就是好的测试

很多人听到写测试就会联想到写单元测试里一个一个的测试用例(Test Case),但是其实测试不一定需要通过这样来实现。我们知道越早发现问题,修复代价就越低,所以如果代码本身就能帮我们找到错误,岂不是更加的方便?

所以,不要忘记:好的代码本身就是好的测试。这也是最短距离原则的应用。

4.1. 合约编程(Design by contract)

虽然很多项目非常复杂,但是和我们现实的人类社会相比,可能并不能算什么。那到底是什么能让如此错综复杂依然可以稳步前进呢?这便是合约(Contract)的力量。小到一句口头约定,大一点到上班签的合同,无处不在的规章制度,再大到指导我们生活的法律,这些都是合约,违背了合约就一定会受到惩罚。

写代码也是一样,每一个函数都有自己的合约,它的前置条件是什么(比如参数不为空),后置约束是什么(比如某文件一定会被创建),执行过程中的不变量又是什么(比如,二分查找中,left一定不会大于right),合约一旦被打破,就应该受到惩罚(返回错误或者异常)。这就是合约编程(Design by contract)

本质上合约编程就是一种代码内的测试,它之所以这么著名,不仅仅是因为它能帮我们找到问题,更重要的是它还能帮我们确定问题在哪。这就和现实中的合约一样,你迟到了就是你迟到了。如果一个函数执行的前置条件没有达到从而导致了错误,那么这个函数就不用看了;如果一个函数中间的不变量出错了,那么问题就一定在这个函数或者它相关的函数里(比如,同一个类的其他函数),而它的调用方我们就不用查了。这样我们调试问题的速度将大大的提高!

其实很多编程语言都已经提供了对合约编程的支持,比如C/C++中的SAL和为了支持C++ Core GuidelinesGSL,C#中的CodeContract。而且这个思想影响之大,即便语言没提供或者提供的稍稍不那么方便,社区也会帮忙提供第三方的包来实现,比如C#的Dawn.Guard和Go的ozzo-validation

4.2. 要约束,不要放任

"Don't assume it, prove it." — David Thomas, from "The Pragmatic Programmer: Your Journey to Mastery"

根据最短距离原则,我们要尽早报错,所以一个非常直观的结论便是:如果能用更强制的办法对代码进行约束时,就一定要进行约束。

常用的约束由强至弱依次如下:

  1. 语法级编译错误
  2. 编译期断言
  3. 静态代码检查
  4. 运行期错误,运行期断言或异常
  5. 运行期检查并返回错误码,再由测试对错误码进行判断
  6. 运行期检查,但不返回错误码,但是可以通过调用其他函数访问内部状态,最后用测试进行检查
  7. 没有任何形式的错误检查

第1-3级约束都是编译期约束,而4-6级约束都是运行期约束,他们各有特点。这里举个简单的栗子,下面这样的函数相信大家肯定看到过不少,我们现在就来看看怎么样对其进行修改:

1
2
3
4
5
6
7
8
9
10
struct Data {
int FieldA;
char FieldB[6];
}

bool ProcessData(Data *p) {
if (p == nullptr) { return false; }
// Do something here
return true;
}

4.2.1. 运行期约束

4.2.1.1. 返回错误码

根据上面的定义,我们知道这个函数是第五级的约束。由于这种约束主要是使用错误码来判断出错类型,所以最好使用表达力更强的错误码。比如C++中的std::error_code,Windows编程中的HRESULT或者System Error Code

这里使用bool值就是一个不好的例子,因为返回值false完全没有办法告诉我们可能出错的原因,所以我们可以使用HRESULT进行替换:

1
2
3
4
5
HRESULT ProcessData(Data *p) {
if (p == nullptr) { return E_INVALIDARG; }
// Do something here
return S_OK;
}

这里大家可以看到,返回码的问题非常明显,那就是如果没有针对的测试用例,这种错误我们不会马上发现,直到其他地方出错了,调试之后,才会被我们发现。所以为了尽快发现问题,我们可以继续提升这段代码的约束等级。

4.2.1.2. 断言和异常

断言和异常大家都应该都不陌生,无论是使用类似ArgumentNullException的异常,或者是通过Assert进行断言,都非常的直接。但是断言和异常其实也能玩出花来,那就是上面我们已经提到过的合约编程。

如果不用任何第三方的库,最简单的,我们可以自己定义一些宏,比如,如下代码就实现了合约编程中的Requires关键字,帮助我们对代码进行约束:

1
2
3
4
5
6
##define REQUIRES(cond) assert(cond)

void ProcessData(Data *p) {
REQUIRES(p != nullptr);
// Do something here
}

这里需要注意的是:

  • 如果这里的参数是用户输入,我们必须谨慎使用断言。用户的输入可以是任何样子,没有合约限制,这个时候我们必须要将错误处理好。断言只有当问题确实不符合我们预期时,才可以使用,比如,内部代码调用,不然会导致意外的崩溃,影响用户体验。(经典笑话:测试工程师逛酒吧)。
  • 如果这个空指针是这个函数里唯一一个出错的地方,那么我们就不需要再返回错误了,因为已经有了断言了。

就和上面提到的一样,合约编程已经有很多成熟的库了,合理的应用这些库,可以让我们写代码和查错的效率倍增。

4.2.2. 编译期约束

运行期约束的好处是它可以处理变化的数据,但是他们产生的错误也需要运行程序才能发现,所以我们要触发这些约束,就一定需要编写并运行一个特定的测试,而这些测试的错误都是需要一定时间的调试的,这对我们来说都是开销。那有没有更强更方便的约束呢?有,这就是编译期约束。

编译期约束,都是在代码编译的过程中被执行,并且出现问题就会产生编译错误,这个好处是:

  • 编译无法通过,我们就不可能发布软件,也就从根本上杜绝了发布bug的可能。
  • 编译错误通常带有非常详细的信息,哪一个文件哪一行代码出了什么错,所以相比运行期错误,编译错误几乎不用怎么调试(咳,C++的模板推导除外……)。

但是编译期约束最大的问题就是,如果编译过程中状态无法确定,就无法对其进行约束(比如用户的输入)。下面,我们就来看看如何应用编译期约束。

4.2.2.1. 静态代码检查

静态代码检查是一个很有用的工具,它能帮我们分析代码并且找出其中可能出现的问题,比如,内存申请了没有释放,变量还没有被初始化就被使用了等等。这里将其列为第三级的原因是因为,静态代码检查通常需要我们在开发环境中加入或启用代码检查工具,所以经常被遗忘。另外代码检查需要花费时间,使得编译速度明显下降,所以很多人在开发过程中也会将其禁用掉,直到合并代码时才会打开跑一次,所以强制性低,发现问题的时候也通常比较晚。

静态代码检查工具很多,比如CppCoreCheckNetAnalyzer等等。另外这些工具很多时候也需要我们对代码进行相应的修改,告诉他,我们的期望是什么,比如SAL

还是同样的代码,我们来应用第三级约束,加入SAL:

1
2
3
4
void ProcessData(_In_ Data *p) {
// REQUIRES(p != nullptr); // This line can be removed, if p is not a third-party input, e.g. user input.
// Do something here
}

这里可以看到,加入的SAL中的_In_关键字不仅仅为提供代码检查的依据,它还是一个合约,告诉调用方,这个函数不接受空指针。于是根据合约编程,我们可以假定调用方遵守合约,并将空指针检查的代码删除了。这样处理之后,代码就变得更加清晰和精简了,同时效率也变高了!(想一想传递指针时,调用栈上每一层函数都来检查一次的样子吧……)

4.2.2.2. 断言,不是只有运行期才有

很多语言还提供了编译期的断言,比如C++中的static_assert。只要其表达式能在编译期被求值(Evaluate),那么就可以使用此断言。

比如,我们可以对上面的代码做如下断言:(这两个断言会成功吗?:D)

1
2
3
4
5
6
7
8
struct Data {
int FieldA;
char FieldB[6];
}

// Making sure structure and fields are aligned when dealing with binary buffers.
static_assert(sizeof(Data) == 10);
static_assert(FIELD_OFFSET(Data, FieldB) == 4);

除此以外,还有一些其他的常用法,比如:

1
2
// Making sure every enum value will have its name.
static_assert(_countof(enum_names) == enum_value_max));
4.2.2.3. 语法级约束和不变性

最后,也是最高等级的约束便是语法错误了。通过合理的运用语言提供的关键字,我们可以让语言本身来帮助我们避免错误的代码。

比如上面的代码,我们可以就可以简单的使用引用来完全避免空指针的问题,这样任何形式的检查都不需要了。另外由于其是输入参数,我们还可以加上const的约束,保证其不会被该函数修改。

1
void ProcessData(_In_ const Data &p) { /* Do something here */ }

类似这样帮助我们进行约束的语法还有很多,比如常见的final关键字,c++中的override关键字,等等。这里有一个关键字是我最喜欢的,那就是const

C++中const可以出现在很多地方,用来修饰变量,修饰函数参数,修饰成员函数。每当const出现,我就变得特别的安心,因为我知道,它修饰的东西不会被任何东西所改变(或者不会改变任何东西),那么在多线程并发的时候,它是安全的(当然也有例外,不过可能性就小了很多)。而这也是函数式编程(或者实现线程安全程序)的最重要的基石之一:不变性(Immutability)

对于一个对象来说,不变性指的是对象的状态在其构造完成之后就不可改变,也就是说,构造函数是唯一一个能改变对象状态的地方。这样的对象也叫不可变对象。不可变对象对多线程的亲和非常的好理解,因为它不能被任何人所改变,所以你怎么并行操作它都没有问题。

这里还是拿同样的函数来举例子,如果说,ProcessData是一个回调函数,而参数p会被多线程访问,那么我们可以采用如下方法实现这个函数,这样我们就能避免很多多线程访问可能导致的错误:

1
void ProcessData(_In_ std::shared_ptr<const Data> p) { /* Do something here */ }

现在不可变对象已经不是函数式语言的专利了,比如C#中的Immutable Collection,这些都能帮助我们更好的进行多线程编程。

4.3. 死程序,不说谎

4.3.1. 断言还是错误码?

"Defensive programming is a waste of time. Let it crash!" -- Joe Armstrong, the inventor of Erlang
"Crash, don't trash." -- David Thomas, from "The Pragmatic Programmer: Your Journey to Mastery"

经过上面这一节,也许你会觉得很奇怪,使用断言比错误码更好吗?断言失败,程序可是会直接崩溃啊,这不是应该避免的吗?

恰恰相反,正因为崩溃冷酷无情,我们才应该更多的使用崩溃,特别当出现重大问题时,我们应该尽早的崩溃,而不应该悄无声息的继续执行。

  • 首先,崩溃绝不说谎,而且崩溃还会生成崩溃转储文件(Crash Dump),它可以提供出错时的堆栈,甚至完整的内存数据来告诉我们为什么出错了,这些信息量是一两行错误日志远远不能比的,这可以大大增加程序出错时的可观察性。
  • 其次,当程序处于一种不确定的状态下时,直接崩溃通常比继续执行更好,因为我们无法预测接下来程序要做什么,它也许会胡乱的修改用户数据,也许会执行本完全不应该执行的逻辑,调用别的模块或者服务,这些错误造成的影响通常都很难恢复。(想想如果因为我们服务中一个的bug,导致用户在文件中或者数据库中的数据被一个“好的”请求损坏了……恢复起来就真的要自求多福了)
  • 最后,崩溃在任何时候都无法被忽略,无论是测试,预发布,还是最终发布。这个给了我们两个特别好的性质:
    • 断言保证的事情,一定为真。这可以帮助我们精简代码,并且增加我们对代码正确执行的信心。想一下你的程序中某个断言,在好几百万台机器上执行确从未触发过,是不是很有自信?
    • 断言失败很难不被发现。我们希望任何的错误都能被尽早的暴露,而不是最后到了最终用户的手里,被用户发现,然后收到一大堆故障单。

当然,断言虽然好,但是也不要忘了上面已经提过的使用断言的场景:简单的说就是代码上而言不可能的事情,就应该用断言,不然就应该要合理的处理,比如用户的输入。

4.3.2. 自杀点(Suicide Point)

"Let there be light!"

由于崩溃有如此好的性质,我们还可以利用其来更好的观察我们程序中的行为。

在我刚加入现在的组时,给我负责的服务是一个规模超大,但是非常老旧错误百出的服务,所以为了了解这个服务真正在做什么,我在所有模块中的关键函数上都打上了追踪点(追踪点不会触发崩溃),比如核心系统API调用失败的时候等等,然后版本发布了之后,我惊讶的发现,这个服务的核心系统API调用错误率高到惊人,所有实例加起来每分钟错误数可以达到数百万次(是的,你没看错)。有一些错误,就算加了日志也看不出来为什么,因为信息量太有限。于是我便在代码里面加入了自杀点,这样我能随意控制任意节点,在任意追踪点被触发的时候崩溃。之后,通过慢慢的收集信息和迭代修复,现在核心系统API的错误量已经下降到了每小时甚至每天个位数。最后,我们还给这些追踪点加上了报警,帮助我们第一时间发现新的问题。


5. 好代码是写好测试的基石

写测试之前,我们千万不要忽略代码本身的质量,如果代码本身恶臭无比,测试也将变的一塌糊涂。

请不要幻想测试可以帮助我们提高多少代码的可维护性,再烂的代码都可以加测试,只不过加的测试也会变得极烂而已。

5.1. 让测试支持重构:找到分界点

很多公司或者项目,为了追求所谓的100%代码覆盖率,想都不想的就要求给所有的类和函数都给加上测试。这个我个人是非常讨厌的,因为这和我们想利用测试达到的其中一个目的相冲突 —— 帮助我们重构。写代码就像是规划城市,只要我们项目还在发展,重构就不可避免,而重构又必然要在一定范围之内改变代码结构。所以这种蛮力测试,只会导致我们浪费大量的时间,拖慢开发进度,而收益几乎为0。

所以,测试需要给代码一定的活动空间,更好的方法是先观察代码中模块的分界点或者代码分层的点在什么地方,然后在这些分界点上添加测试。我们重构代码,大部分都是在这些分界点的内部进行的,很少会跨越他们,所以这样加入的测试在重构时几乎不用做任何的修改!

而这也会带来代码重构流程的变更,这也是我在我现在项目里推荐大家的一种做法:

  1. 代码重构前,先添加测试并提交,固定代码行为,如果发现了bug,先修bug。
  2. 开始代码重构,并且每一个提交都不宜过大。最重要的是:重构的提交中不能出现任何对现有测试中行为的修改。如果需要添加测试,则返回第一步,先添加好测试,再继续第二步进行重构。

这样做的好处是,首先,在做出任何修改之前,系统的当前行为会被了解的一清二楚;其次,代码审查时一目了然:我改了代码,但是对系统没有任何副作用,所以这是一个安全的提交。

5.2. 增加可测试面:高内聚,低耦合

高内聚和低耦合可能大家都已经听到耳朵起茧了,但是这里却不得不提,因为它真的对写测试非常有帮助。

高内聚低耦合的代码,都有一些特点:模块化非常好,每个部分功能单一(单一职责原则(SRP))且相互正交。这样的代码非常的利于测试。所以,如果你发现自己正在维护的代码乱七八糟,那么可以考虑将其进行一些重构,提高内聚减少耦合。这样可以带来更好和更细粒度的模块分割或代码分层,从而为我们提供更多可测试的面(参考上一条)。

我之前接手过一个模块,就是由于违反了单一责任原则而导致完全无法测试。这个模块要做的事情非常简单,它是一个多线程多协议的健康状况的探针,但是这个模块里面,底层api,多协议实现和异步并发管理的实现都被塞在了一起,结果就导致测试的时候要么没办法做同步(探针请求发出去了,不知道什么时候能结束,只能盲目的等待30秒),要么就是没办法模拟出错流程,特别是底层api的异常错误和多线程竞争。最后,这个模块几乎无法测试和维护,而这导致其线上问题接连不断。于是,为了提高服务质量,我只能先耐着性子添加和改进测试,哪怕他们跑一次要20分钟,然后改进日志和指标数据,再对代码进行小步重构,使用组合模式分离api,多协议实现和异步并发。就这样,两周的时间里,我发现并修复了数不清的问题,异常行为和多线程问题遍地都是。最后,不仅测试数量大大增加,测试的时间也从20分钟下降到了3分钟,所有中间版本都可随时发布或回滚,而从修复后的模块上线到现在已经快一年的时间了,除了一个已知但是属于行为变化的“bug”不好直接修复以外,这个模块已经从一个问题重灾区变成了一个几乎没有出过问题的模块,日志和指标数据也从之前的莫名其妙变成了调试线上问题时的重要依据。

5.3. 避免不稳定的代码

上面提到写测试的一个原则就是要可重现:没出问题时不乱报错,但出了问题就要能稳定重现。如果一个测试总是运行十次有两三次过不了,很快就没有人把这个测试当回事了,因为没有人知道这个错误是不是自己引起的,反正多重试两次就过了。这种情况是测试中的大忌。

这种不稳定的问题多半是由于代码本身逻辑就不是稳定的:

  • 逻辑或测试对时间敏感,比如:timer,sleep,多线程竞争,等等。
  • 随机逻辑,比如:jitter,随机数等等。

这些情况,我们都可以先把不稳定的部分分离出来,抽象成一个触发器,然后分别对触发器和其他逻辑进行单独测试。这样我们就可以很容易的使用各种同步机制,比如Event,锁,将其变稳定了。

5.4. 测试小帮手:测试桩

相信大家在写测试的时候都或多或少会遇到以下这些尴尬的情况:

  • 某一个类会调用一个系统的API,但是这个API却无法在测试环境中工作
  • 某个成员变量是私有的,但是很重要,我们想通过测试保证其工作正常
  • 我们想要测试处于特定条件下的某一个类的行为,但是这个条件不好达到
  • 等等等等

这个时候测试桩就能提供帮助了:

  • 将系统API进行一层薄薄的封装,让我们能轻松模拟成功和失败的情况
  • 如果是我们自己的代码,那么请考虑代码是否能进行分层。分层一旦完成,便可以进行稳定的抽象,并将其作为我们的测试桩,帮我们对其他层的情况进行模拟
  • C++中被大家诟病的friend class可以很好的帮助我们解决对私有成员变量进行检查的问题:friend class FooTests;

6. 休息一下

好了,上篇到这里就结束了。如果你看到了这里,你会发现,我们居然还没有开始真正讨论如何写测试!是的,这里重复一下我的观点,写测试不仅仅是写测试,它不是我们的目的。测试不是一剂万能药,加几个测试并不能帮我们改善多少代码质量,把代码本身写好才是根本。

这里也阶段性的总结一下:

  1. 首先我们总结了测试的作用:保证代码不会出现回归(Regression),帮助我们确认代码改动,并且充当具有约束力的文档和知识库。
  2. 然后讨论了测试的原则:最短距离,可观察,可重复。
  3. 接下来开始讨论方法论,这一篇里我们主要关注在被测试的代码本身上:
    1. 好的代码本身就是好的测试:使用合约编程(Design by contract);尽量约束代码行为;合理的利用崩溃增加程序的可观察性。
    2. 好的代码是写好测试的基石:利用分界点让测试更好的支持重构;通过提高内聚度降低耦合度来为我们增加更多可测试的面;避免不稳定的代码;通过测试桩帮助我们进行更深度的测试。

下篇,我们再来讨论如何写出好的测试。

同系列文章:
原创文章,转载请标明出处:Soul Orbit
本文链接地址:测试,你写代码时最好的朋友(上篇)