Goto Fail、心脏出血和单元测试文化
2014 年初发现了两个计算机安全漏洞:Apple 的“goto fail”漏洞和 OpenSSL 的“Heartbleed”漏洞。这两个漏洞都可能导致广泛而严重的安全性故障,我们可能永远无法了解其全部范围。鉴于其严重性,软件开发行业必须反思如何才能检测出这些漏洞,以便提高我们将来预防此类缺陷的能力。本文考虑了单元测试可以发挥的作用,展示了单元测试(更重要的是单元测试文化)如何识别出这些特定漏洞。本文继续探讨了这种文化所带来的成本和收益,并描述了 Google 如何灌输这种文化。
2014 年 6 月 3 日
2014 年初,互联网安全因两个严重漏洞而受到冲击:Apple 的“goto fail”漏洞 (CVE-2014-1266) 和 OpenSSL 的“Heartbleed”漏洞 (CVE-2014-0160)。这两个漏洞都是安全套接字层技术中的漏洞,而互联网上的大多数安全通信都依赖于该技术。这些漏洞具有破坏性,同时也很有启发性:它们根源于所有规模和领域的项目中都会出现的程序员乐观主义、过度自信和匆忙。
这些漏洞激起了我的热情,因为我亲眼目睹并体验了单元测试的益处,这种深刻的经历促使我思考单元测试方法如何防止像这些 SSL 漏洞一样影响巨大且备受瞩目的缺陷。单元测试是寻找代码块的过程,这些代码块构成了方便的“单元”,可以对其应用自动化的 单元测试,这些小程序旨在验证低级实现细节并及早检测编码错误。这些缺陷的性质激发我编写了自己的概念验证单元测试来重现错误并验证其修复。我编写这些测试是为了验证我的直觉,并向他人展示单元测试如何及早且毫不费力地检测出这些缺陷。
编写单元测试产生的好处不仅仅是检测低级编码错误。在本文中,我探讨了单元测试是否有助于防止“goto fail”和 Heartbleed 漏洞的问题。在此过程中,我希望为将单元测试作为日常开发的一部分而采纳单元测试建立一个令人信服的案例,以便 自测代码 的体验变得普遍。我提供我的见解,希望它们有助于避免将来出现类似的故障,本着 验尸 或 项目回顾 的精神。我的经历并不意味着我应该根据 我的权威 而受到尊重,但我希望提出一个足够令人信服的案例,促使更多的人和组织考虑单元测试文化的益处。
许多流行和技术媒体报道都对这些缺陷的起源、它们如何在广泛部署之前绕过现有保护措施以及应该采取哪些措施来防止此类漏洞再次发生进行了解释。令我不安的是,大多数这些分析都诉诸于肤浅的借口,这些借口偏离了目标,并由于现代软件系统的复杂性不断增加而促进了对此类缺陷的无奈接受。就好像整个软件行业以及依赖它的公众都急于接受此类故障是不可避免的命运,这是我们为技术为我们提供的现代便利所付出的代价。这是最简单的解释,它让我们能够理解糟糕的情况并作为一个社会继续前进。
我不认为这种缺陷是不可避免的。相反,我们必须抓住这个机会思考我们作为开发者如何才能做得更好,而不是依靠命运、更多的资金或任何数量的外部因素来防止安全漏洞或其他由低级编码错误导致的高影响缺陷。错误会发生,但软件开发者和公众都不应该满足于将这种大规模的缺陷作为回应。深入、真诚的思考是困难的,并且会遇到很多阻力,因为它要求开发者对他们的人类局限性承担责任——这通常是对程序员自身形象的挑战。这使得深入研究这两个特定错误变得尤为重要,以寻找真正的解决方案并避免设定危险的先例:如果在“goto fail”和 Heartbleed 之后,短期内一切都好转,那么为什么还要改变当前的软件开发实践呢?
goto fail
“goto fail”错误首先出现在 2012 年 9 月的 iPhone、iPad 和 AppleTV 中,出现在 iOS 7.0 和 OS X Mavericks 中,直到 2014 年 2 月才修复——在引入后十七个月。一个跳过 SSL/TLS 握手算法最后一步的短路使用户容易受到中间人攻击,恶意系统在受影响系统和其他系统之间中继流量,可以使用虚假凭据呈现安全连接的假象,并随后拦截其他两个系统之间的所有通信。
该错误得名于这个现在臭名昭著的代码片段
if ((err = SSLHashSHA1.update(&hashCtx, &signedParams)) != 0) goto fail; goto fail;
一些人认为所有 goto 语句都是不好的,基于 Edsger Dijkstra 的著名文章 反对 GO TO 语句,总结为流行的格言“Goto 被认为是有害的”。然而,goto fail
语句表达了 C 程序员熟悉的惯用法。在遇到无法恢复的错误时,此类语句会立即将控制权传递给函数末尾的恢复块,在该块中正确释放局部分配的资源。其他语言内置了对这种“中止子句”的支持,正如 Dijkstra 在他文章的结论中所称:C++ 中的析构函数;Java 中的 try/catch/finally
;Go 中的 defer()/panic()/recover()
;Python 中的 try/except/finally
和 with
语句。在 C 中,在这种情况下使用 goto
没有本质问题或混淆。换句话说,这里不应该认为 goto
有害。
C 程序员也会立即认识到第一个 goto fail
语句绑定到它前面的 if
语句的结果,但第二个 goto fail
却没有:这两个语句的匹配缩进在 C 中没有意义,因为需要周围的大括号将多个语句绑定到 if
条件。如果第一个 goto fail
没有执行,那么第二个肯定会被执行。这意味着握手算法的后续步骤将永远不会被执行,但是任何成功通过此点的交换都将始终产生一个成功返回值,即使最后的验证步骤失败。更简单地说:算法被额外的 goto fail
语句短路了。
有人声称,要求所有if
语句都使用大括号的编码风格或启用不可达代码编译器警告可能会有所帮助。但是,代码中存在更深层次的问题,单元测试可以帮助解决这些问题。
单元测试如何提供帮助?
在寻找要应用“单元”测试的“单元”时,包含错误算法的整个代码块及其条件逻辑集群就脱颖而出,成为这样的一个单元(来自 Apple 安全传输库的 版本 55471 中的 SSLVerifySignedServerKeyExchange()
函数)
if ((err = ReadyHash(&SSLHashSHA1, &hashCtx)) != 0) goto fail; if ((err = SSLHashSHA1.update(&hashCtx, &clientRandom)) != 0) goto fail; if ((err = SSLHashSHA1.update(&hashCtx, &serverRandom)) != 0) goto fail; if ((err = SSLHashSHA1.update(&hashCtx, &signedParams)) != 0) goto fail; goto fail; if ((err = SSLHashSHA1.final(&hashCtx, &hashOut)) != 0) goto fail;
当将此类代码块提取到其自身函数中时,更容易对其进行测试。提取此类代码块是编写单元测试人员的习惯做法,并且可以帮助一次测试现有代码库的一部分。仔细查看算法中使用的变量和数据类型,可以清楚地看出此代码块正在对哈希执行握手。通过查找 SSLHashSHA1
的类型,我们还可以看到它是 HashReference
“跳转表”的一个实例,这是一个包含函数指针的结构,使 C 程序员能够实现类似虚拟函数的行为(即 可替代性和运行时多态性)。我们可以将此操作提取到一个函数中,其名称表示其意图(省略额外的 goto fail
)
static OSStatus HashHandshake(const HashReference* hashRef, SSLBuffer* clientRandom, SSLBuffer* serverRandom, SSLBuffer* exchangeParams, SSLBuffer* hashOut) { SSLBuffer hashCtx; OSStatus err = 0; hashCtx.data = 0; if ((err = ReadyHash(hashRef, &hashCtx)) != 0) goto fail; if ((err = hashRef->update(&hashCtx, clientRandom)) != 0) goto fail; if ((err = hashRef->update(&hashCtx, serverRandom)) != 0) goto fail; if ((err = hashRef->update(&hashCtx, exchangeParams)) != 0) goto fail; err = hashRef->final(&hashCtx, hashOut); fail: SSLFreeBuffer(&hashCtx); return err; }
现在,构成先前错误算法的语句系列可以替换为
if ((err = HashHandshake(&SSLHashSHA1, &clientRandom, &serverRandom, &signedParams, &hashOut)) != 0) { goto fail; }
单独理解此函数更容易。面对这样的自包含函数,程序员可以开始关注代码的外部效果,考虑诸如以下问题:
- 被测代码履行了哪些契约?
- 需要哪些先决条件,以及如何强制执行这些条件?
- 保证了哪些后置条件?
- 哪些示例输入会触发不同的行为?
- 哪组测试将触发每种行为并验证每项保证?
在 HashHandshake()
的情况下,契约可以描述为:五步,所有步骤都必须通过。成功或失败通过返回值传播给调用者。HashReference
预计会正确响应一系列调用;它是否使用 HashHandshake()
传递的任何函数或数据,这是 HashHandshake()
本身不透明的实现细节。
对于这种简单的算法,测试用例将非常“镜像”实现:一个成功用例,五个失败用例。对于更高级别或更复杂的操作,这种紧密的“镜像”会导致测试脆弱,通常应避免这样做。在使用模拟或其他 测试替身从其协作者中隔离测试代码时,这一点尤其重要。
可以说,测试代码不执行不应该执行的操作更为重要。
无论被测代码的范围如何,都至关重要的是尽可能详尽地测试失败用例。测试代码是否执行了它应该执行的操作并就此打住很有诱惑力,但可以说,测试它不执行不应该执行的操作更为重要。
概念验证单元测试
尽管 C 不是面向对象编程语言,但此算法的现有代码展示了一个清晰的面向对象设计,一旦将代码提取到其自身函数中,实际上可以轻松进行单元测试。 tls_digest_test.c
概念验证单元测试展示了如何使用 HashReference
存根有效地覆盖提取的 HashHandshake()
算法的每条路径。实际测试用例如下所示
static int TestHandshakeSuccess() { HashHandshakeTestFixture fixture = SetUp(__func__); fixture.expected = SUCCESS; return ExecuteHandshake(fixture); } static int TestHandshakeInitFailure() { HashHandshakeTestFixture fixture = SetUp(__func__); fixture.expected = INIT_FAILURE; fixture.ref.init = HashHandshakeTestFailInit; return ExecuteHandshake(fixture); } static int TestHandshakeUpdateClientFailure() { HashHandshakeTestFixture fixture = SetUp(__func__); fixture.expected = UPDATE_CLIENT_FAILURE; fixture.client = FAIL_ON_EVALUATION(UPDATE_CLIENT_FAILURE); return ExecuteHandshake(fixture); } static int TestHandshakeUpdateServerFailure() { HashHandshakeTestFixture fixture = SetUp(__func__); fixture.expected = UPDATE_SERVER_FAILURE; fixture.server = FAIL_ON_EVALUATION(UPDATE_SERVER_FAILURE); return ExecuteHandshake(fixture); } static int TestHandshakeUpdateParamsFailure() { HashHandshakeTestFixture fixture = SetUp(__func__); fixture.expected = UPDATE_PARAMS_FAILURE; fixture.params = FAIL_ON_EVALUATION(UPDATE_PARAMS_FAILURE); return ExecuteHandshake(fixture); } static int TestHandshakeFinalFailure() { HashHandshakeTestFixture fixture = SetUp(__func__); fixture.expected = FINAL_FAILURE; fixture.ref.final = HashHandshakeTestFailFinal; return ExecuteHandshake(fixture); }
HashHandshakeTestFixture
保存作为测试中代码的输入所需的所有变量,并检查预期结果
typedef struct { HashReference ref; SSLBuffer *client; SSLBuffer *server; SSLBuffer *params; SSLBuffer *output; const char *test_case_name; enum HandshakeResult expected; } HashHandshakeTestFixture;
SetUp()
将 HashHandshakeTestFixture
的所有成员初始化为默认值;每个测试用例仅覆盖与该特定测试用例相关的那些成员
static HashHandshakeTestFixture SetUp(const char *test_case_name) { HashHandshakeTestFixture fixture; memset(&fixture, 0, sizeof(fixture)); fixture.ref = SSLHashNull; fixture.ref.update = HashHandshakeTestUpdate; fixture.test_case_name = test_case_name; return fixture; }
ExecuteHandshake()
执行 HashHandshake()
函数并评估结果,如果结果与预期不同,则打印错误消息并返回错误值
/* Executes the handshake and returns zero if the result matches expected, one * otherwise. */ static int ExecuteHandshake(HashHandshakeTestFixture fixture) { const enum HandshakeResult actual = HashHandshake( &fixture.ref, fixture.client, fixture.server, fixture.params, fixture.output); if (actual != fixture.expected) { printf("%s failed: expected %s, received %s\n", fixture.test_case_name, HandshakeResultString(fixture.expected), HandshakeResultString(actual)); return 1; } return 0; }
在 final()
调用之前,在 HashHandshake()
算法中的任何位置添加重复的 goto fail
语句都会导致测试失败。
此测试是在没有测试框架的情况下编写的,目的是为了证明可以使用项目中已在使用的工具编写有效的测试。即使不引用标准框架,前一段中的解释也应该相对容易理解:使用组织良好的对象和函数以及精心选择的名称来编写组织良好的测试用例意味着,如果测试失败,您通常可以仅从测试用例中的信息诊断故障,而无需深入了解测试程序的完整实现。测试框架可以帮助更有效地编写测试,但不是编写组织良好、全面的单元测试的先决条件。
编写一组测试来执行此函数非常简单,因为现在我们考虑的是具体示例,而不是条件。此外,测试充当双重检查:很容易在条件逻辑中出错,意外地反转链中的一个测试;但是,当您编写测试时,您会用示例和逻辑两次说明行为。您必须在两种不同的表示形式中犯下相同的错误,才能让错误通过。
第一次编写此算法的程序员很可能执行了该程序以检查新代码中的错误。大多数程序员会使用一些示例输入运行程序以验证它是否正在执行他们认为它应该执行的操作。问题在于,这些运行通常是短暂的,一旦代码开始工作就会被丢弃;自动化测试将这些运行作为永久的双重检查捕获下来。
此处的永久双重检查非常重要:我们不知道那个流氓的第二个 goto fail
如何进入代码;一个可能的原因是它是一个大型合并操作的结果。将分支合并到主线时,可能会产生很大的差异。即使合并可以编译,它仍然可能引入错误。即使对于经验丰富的开发人员来说,检查此类合并差异也可能非常耗时、繁琐且容易出错。在这种情况下,单元测试提供的自动化双重检查提供了一种快速且细致(但轻松!)的代码审查,因为测试很可能会在人工检查合并代码之前发现潜在的合并错误。原始作者不太可能将“goto fail”错误引入代码中,但一套测试不仅可以帮助你发现自己的错误:它还有助于发现未来程序员所犯的错误。
在“goto fail”错误的情况下,单元测试寻找和提取可测试函数的习惯具有第二个好处。
似曾相识
具有不同 HashReference
实例的相同算法的副本出现在同一函数中错误算法的上方。总的来说,该算法在同一文件中出现六次(Security-55471 中的 sslKeyExchange.c)
- 在包含错误的
SSLVerifySignedServerKeyExchange()
中两次 - 在
SSLVerifySignedServerKeyExchangeTls12()
中一次 - 在
SSLSignServerKeyExchange()
中两次 - 在
SSLSignServerKeyExchangeTls12()
中一次
Security-55471.14 中的 sslKeyExchange.c 的更新版本已从 SSLVerifySignedServerKeyExchange()
中删除了重复的 goto fail
语句,但重复的算法仍然存在。
代码重复是一种代码异味,已知会增加软件错误的可能性。从上面的函数名称中也可以明显看出,除了核心握手算法之外,还有更多的重复。这种剪切和粘贴代码重用也支持这样的假设:该错误可能是由大型合并操作引起的,因为重复代码增加了合并期间可用的“代码表面”,并加剧了未检测到的合并错误的可能性。
单元测试会施加压力以最大程度地减少复制/粘贴,因为复制/粘贴的代码也必须进行单元测试。由于更容易测试,它可以确保此算法只存在一个副本。单元测试可以轻松地验证此算法是否正确,无论是否合并,并且可以防止一开始就编写“goto fail”错误。
此外,Security-55471 版本的 ssl_regressions.h似乎列出了此库的许多 SSL 回归测试,在Security-55471.14 版本的 ssl_regressions.h中保持不变。两个库版本之间唯一的实质性差异是删除了 goto fail
语句本身,没有添加测试或消除重复
$ curl -O http://opensource.apple.com/tarballs/Security/Security-55471.tar.gz $ curl -O http://opensource.apple.com/tarballs/Security/Security-55471.14.tar.gz $ for f in Security-55471{,.14}.tar.gz; do gzip -dc $f | tar xf - ; done # Since diff on OS X doesn't have a --no-dereference option: $ find Security-55471* -type l | xargs rm $ diff -uNr Security-55471{,.14}/libsecurity_ssl diff -uNr Security-55470/libsecurity_ssl/lib/sslKeyExchange.c Security-55471.14/libsecurity_ssl/lib/sslKeyExchange.c --- Security-55471/libsecurity_ssl/lib/sslKeyExchange.c 2013-08-09 20:41:07.000000000 -0400 +++ Security-55471.14/libsecurity_ssl/lib/sslKeyExchange.c 2014-02-06 22:55:54.000000000 -0500 @@ -628,7 +628,6 @@ goto fail; if ((err = SSLHashSHA1.update(&hashCtx, &signedParams)) != 0) goto fail; - goto fail; if ((err = SSLHashSHA1.final(&hashCtx, &hashOut)) != 0) goto fail; diff -uNr Security-55471/libsecurity_ssl/regressions/ssl-43-ciphers.c Security-55471.14/libsecurity_ssl/regressions/ssl-43-ciphers.c --- Security-55471/libsecurity_ssl/regressions/ssl-43-ciphers.c 2013-10-11 17:56:44.000000000 -0400 +++ Security-55471.14/libsecurity_ssl/regressions/ssl-43-ciphers.c 2014-03-12 19:30:14.000000000 -0400 @@ -85,7 +85,7 @@ { OPENSSL_SERVER, 4000, 0, false}, //openssl s_server w/o client side auth { GNUTLS_SERVER, 5000, 1, false}, // gnutls-serv w/o client side auth { "www.mikestoolbox.org", 442, 2, false}, // mike's w/o client side auth -// { "tls.secg.org", 40022, 3, false}, // secg ecc server w/o client side auth - This server generate DH params we dont support. +// { "tls.secg.org", 40022, 3, false}, // secg ecc server w/o client side auth { OPENSSL_SERVER, 4010, 0, true}, //openssl s_server w/ client side auth { GNUTLS_SERVER, 5010, 1, true}, // gnutls-serv w/ client side auth
文化影响
同一算法的六个单独副本的存在清楚地表明,此错误不是由于一次性程序员错误造成的:这是一个模式。这是容忍重复、未经测试代码的开发文化的证据。
我从未在 Apple 工作过,也不认识任何 Apple 开发人员。我不知道那里的公司级开发文化是什么,也不知道此代码是具有代表性的还是例外的。即使此代码是例外而不是规范,它仍然是不可接受的。对于我来说,我的隐私和安全可能因此编码错误而受到侵犯,这并不重要,无论是什么情况“原谅”了这个特定错误,或者其他文化是什么样的。我希望看到对这些错误承担更大的责任。不是羞辱,不是谴责等,而是责任和随后的尽职调查。这是我们防止下一次“goto fail”发生的更深层次策略。
我知道开发文化是可以改变的。像这样的错误给了我们一个机会来反思我们自己的开发文化,如果单元测试还不是一个至关重要的部分,并且开始理解为什么单元测试是一个如此重要的开发实践。我将在本文的后面部分详细讨论我改变开发文化的经验,并提供有关如何影响其他开发文化(从单个团队到整个公司)的建议。
我针对“goto fail”编写的概念验证单元测试可能很容易被视为在事后诸葛亮的情况下编写的单次测试。我宁愿它作为一种可访问的单元测试方法的示例,各地的开发团队都可以立即将其应用于现有代码,以避免类似的令人尴尬(且可能造成灾难性后果)的错误。重视单元测试并努力提高其技能的开发文化将产生测试,这些测试很可能会在“goto fail”等编程错误有机会影响任何用户之前很早就捕获到这些错误。
接下来,让我们看看 Heartbleed 错误,以了解如何在该上下文中应用单元测试。
心脏出血
Heartbleed 是一个同样令人痛心的未经测试的安全关键代码案例,它作为无处不在的 OpenSSL 库的一部分出现。它于 2012 年 1 月作为在 OpenSSL-1.0.1-beta1 中实现 TLS 心跳的大型未经测试的更改的一部分引入的。该错误使攻击者能够发送一个空握手请求并声明它发送了高达 64k 的数据;易受攻击的系统将读取但不验证声明的大小,并将响应其内存中与请求缓冲区相邻高达 64k 的任何内容。没有记录此交换;绝对不会有任何攻击痕迹。
引入该错误的更改是经过代码审查的;显然,审阅者没有坚持要求该更改包括单元测试。该错误直到 2014 年 4 月才被发现并修复,并作为 1.0.1g 的一部分发布。
它同时出现在 dtls1_process_heartbeat()
(ssl/d1_both.c
) 和 tls1_process_heartbeat()
(ssl/t1_lib.c
)
int dtls1_process_heartbeat(SSL *s) { unsigned char *p = &s->s3->rrec.data[0], *pl; unsigned short hbtype; unsigned int payload; unsigned int padding = 16; /* Use minimum padding */
局部指针变量 *p
初始化为心跳请求缓冲区的开头。第一个字节将标识请求的类型,存储在 hbtype
中。接下来的两个字节指定客户端提供的请求数据的长度,客户端希望将该数据复制并作为响应发回;此长度将存储在 payload
中。(payload_size
或 payload_len
将是更匹配变量意图的更好的名称。)接下来是客户端提供的要复制并返回给客户端的数据或“有效负载”的开头,pl
将指向该数据。(此变量应该命名为 payload
。)
数据被读入相应的变量
/* Read type and payload length first */ hbtype = *p++; n2s(p, payload); pl = p;
n2s()
是一个宏(来自 ssl/ssl_locl.h
),它读取指针 p
的后两个字节,将值存储在 payload
中,并将 p
前进两个字节。
如果 hbtype
为 TLS1_HB_REQUEST
,则受影响的系统将分配一个响应缓冲区,并将 payload
字节复制到其中(s2n()
是 n2s()
的伴随宏,它将有效负载长度复制到响应缓冲区中)
unsigned char *buffer, *bp; int r; /* Allocate memory for the response, size is 1 byte * message type, plus 2 bytes payload length, plus * payload, plus padding */ buffer = OPENSSL_malloc(1 + 2 + payload + padding); bp = buffer; /* Enter response type, length and copy payload */ *bp++ = TLS1_HB_RESPONSE; s2n(payload, bp); memcpy(bp, pl, payload);
memcpy()
不好,因为长度值 payload
未被验证为与从请求中实际读取的长度匹配。该请求可能包含空字符串,但指示的长度高达 64 千字节。因此,进程内存中的多达 64 千字节作为响应返回,而不是请求缓冲区的内容。同样,没有记录此事件;它实际上没有留下任何痕迹。
修复程序提供了对缓冲区大小的缺失检查
/* Read type and payload length first */ if (1 + 2 + 16 > s->s3->rrec.length) return 0; /* silently discard */ hbtype = *p++; n2s(p, payload); if (1 + 2 + payload + 16 > s->s3->rrec.length) return 0; /* silently discard per RFC 6520 sec. 4 */ pl = p;
第一个检查涵盖了客户端已将空字符串作为有效负载发送的情况,确保从套接字读取的数据的实际大小与此最小请求大小匹配;第二个检查确保客户端提供的有效负载大小不超过包含有效负载数据的缓冲区的大小。
在 dtls1_process_heartbeat()
中,添加了一个检查以确认有效负载大小不超过响应允许的最大值
unsigned int write_length = 1 /* heartbeat type */ + 2 /* heartbeat length */ + payload + padding; int r; if (write_length > SSL3_RT_MAX_PLAIN_LENGTH) return 0;
单元测试如何提供帮助?
与“goto fail”错误的情况相反,无需提取新函数:dtls1_process_heartbeat()
和 tls1_process_heartbeat()
已经是大小合适的单元,不需要大量复杂的设置来进行测试。我们可以直接回答前面在“goto fail”上下文中提出的相同问题
- 被测代码履行了哪些契约?
- 需要哪些先决条件,以及如何强制执行这些条件?
- 保证了哪些后置条件?
- 哪些示例输入会触发不同的行为?
- 哪组测试将触发每种行为并验证每项保证?
鉴于心跳函数处理包含外部提供的数据的请求缓冲区,习惯于自测的程序员会发现习惯性地探测处理此类输入的弱点——尤其是在与内存缓冲区的读取和分配有关时。
除了这种自然单元测试人员的本能之外,以下是 定义心跳请求的协议部分 的摘录
payload_length: The length of the payload. [...snip...] If the payload_length of a received HeartbeatMessage is too large, the received HeartbeatMessage MUST be discarded silently.
在这种情况下,协议规范实际上为我们定义了适当的单元测试。它没有明确表示应该验证 payload_length
与实际读取的内容匹配,但强烈暗示 payload_length
应该受到特别关注。
概念验证单元测试
概念验证单元测试 heartbleed_test.c
比“goto fail” 复杂一些,但仍遵循类似的结构。以下是 dtls1_process_heartbeat()
的测试用例
static int TestDtls1NotBleeding() { HeartbleedTestFixture fixture = SetUpDtls(__func__); /* Three-byte pad at the beginning for type and payload length */ unsigned char payload_buf[] = " Not bleeding, sixteen spaces of padding" " "; const int payload_buf_len = HonestPayloadSize(payload_buf); fixture.payload = &payload_buf[0]; fixture.sent_payload_len = payload_buf_len; fixture.expected_return_value = 0; fixture.expected_payload_len = payload_buf_len; fixture.expected_return_payload = "Not bleeding, sixteen spaces of padding"; return ExecuteHeartbeat(fixture); } static int TestDtls1NotBleedingEmptyPayload() { HeartbleedTestFixture fixture = SetUpDtls(__func__); /* Three-byte pad at the beginning for type and payload length, plus a NUL * at the end */ unsigned char payload_buf[4 + kMinPaddingSize]; memset(payload_buf, ' ', sizeof(payload_buf)); payload_buf[sizeof(payload_buf) - 1] = '\0'; const int payload_buf_len = HonestPayloadSize(payload_buf); fixture.payload = &payload_buf[0]; fixture.sent_payload_len = payload_buf_len; fixture.expected_return_value = 0; fixture.expected_payload_len = payload_buf_len; fixture.expected_return_payload = ""; return ExecuteHeartbeat(fixture); } static int TestDtls1Heartbleed() { HeartbleedTestFixture fixture = SetUpDtls(__func__); /* Three-byte pad at the beginning for type and payload length */ unsigned char payload_buf[] = " HEARTBLEED "; fixture.payload = &payload_buf[0]; fixture.sent_payload_len = kMaxPrintableCharacters; fixture.expected_return_value = 0; fixture.expected_payload_len = 0; fixture.expected_return_payload = ""; return ExecuteHeartbeat(fixture); } static int TestDtls1HeartbleedEmptyPayload() { HeartbleedTestFixture fixture = SetUpDtls(__func__); /* Excluding the NUL at the end, one byte short of type + payload length + * minimum padding */ unsigned char payload_buf[kMinPaddingSize + 3]; memset(payload_buf, ' ', sizeof(payload_buf)); payload_buf[sizeof(payload_buf) - 1] = '\0'; fixture.payload = &payload_buf[0]; fixture.sent_payload_len = kMaxPrintableCharacters; fixture.expected_return_value = 0; fixture.expected_payload_len = 0; fixture.expected_return_payload = ""; return ExecuteHeartbeat(fixture); } static int TestDtls1HeartbleedExcessivePlaintextLength() { HeartbleedTestFixture fixture = SetUpDtls(__func__); /* Excluding the NUL at the end, one byte in excess of maximum allowed * heartbeat message length */ unsigned char payload_buf[SSL3_RT_MAX_PLAIN_LENGTH + 2]; memset(payload_buf, ' ', sizeof(payload_buf)); payload_buf[sizeof(payload_buf) - 1] = '\0'; fixture.payload = &payload_buf[0]; fixture.sent_payload_len = HonestPayloadSize(payload_buf); fixture.expected_return_value = 0; fixture.expected_payload_len = 0; fixture.expected_return_payload = ""; return ExecuteHeartbeat(fixture); }
HeartbleedTestFixture
、SetupDtls()
和 ExecuteHeartbeat()
项与“goto fail”概念验证单元测试中的类似项密切对应
typedef struct { SSL_CTX *ctx; SSL *s; const char* test_case_name; int (*process_heartbeat)(SSL* s); unsigned char* payload; int sent_payload_len; int expected_return_value; int return_payload_offset; int expected_payload_len; const char* expected_return_payload; } HeartbleedTestFixture; static HeartbleedTestFixture SetUp(const char* const test_case_name, const SSL_METHOD* meth) { HeartbleedTestFixture fixture; int setup_ok = 1; memset(&fixture, 0, sizeof(fixture)); fixture.test_case_name = test_case_name; fixture.ctx = SSL_CTX_new(meth); if (!fixture.ctx) { fprintf(stderr, "Failed to allocate SSL_CTX for test: %s\n", test_case_name); setup_ok = 0; goto fail; } /* snip other allocation and error handling blocks */ fail: if (!setup_ok) { ERR_print_errors_fp(stderr); exit(EXIT_FAILURE); } return fixture; } static HeartbleedTestFixture SetUpDtls(const char* const test_case_name) { HeartbleedTestFixture fixture = SetUp(test_case_name, DTLSv1_server_method()); fixture.process_heartbeat = dtls1_process_heartbeat; /* As per dtls1_get_record(), skipping the following from the beginning of * the returned heartbeat message: * type-1 byte; version-2 bytes; sequence number-8 bytes; length-2 bytes * * And then skipping the 1-byte type encoded by process_heartbeat for * a total of 14 bytes, at which point we can grab the length and the * payload we seek. */ fixture.return_payload_offset = 14; return fixture; } static HeartbleedTestFixture SetUpTls(const char* const test_case_name) { HeartbleedTestFixture fixture = SetUp(test_case_name, TLSv1_server_method()); fixture.process_heartbeat = tls1_process_heartbeat; fixture.s->handshake_func = DummyHandshake; /* As per do_ssl3_write(), skipping the following from the beginning of * the returned heartbeat message: * type-1 byte; version-2 bytes; length-2 bytes * * And then skipping the 1-byte type encoded by process_heartbeat for * a total of 6 bytes, at which point we can grab the length and the payload * we seek. */ fixture.return_payload_offset = 6; return fixture; } static void TearDown(HeartbleedTestFixture fixture) { ERR_print_errors_fp(stderr); SSL_free(fixture.s); SSL_CTX_free(fixture.ctx); } static int ExecuteHeartbeat(HeartbleedTestFixture fixture) { int result = 0; SSL* s = fixture.s; unsigned char *payload = fixture.payload; unsigned char sent_buf[kMaxPrintableCharacters + 1]; s->s3->rrec.data = payload; s->s3->rrec.length = strlen((const char*)payload); *payload++ = TLS1_HB_REQUEST; s2n(fixture.sent_payload_len, payload); /* Make a local copy of the request, since it gets overwritten at some * point */ memcpy((char *)sent_buf, (const char*)payload, sizeof(sent_buf)); int return_value = fixture.process_heartbeat(s); if (return_value != fixture.expected_return_value) { printf("%s failed: expected return value %d, received %d\n", fixture.test_case_name, fixture.expected_return_value, return_value); result = 1; } /* If there is any byte alignment, it will be stored in wbuf.offset. */ unsigned const char *p = &(s->s3->wbuf.buf[ fixture.return_payload_offset + s->s3->wbuf.offset]); int actual_payload_len = 0; n2s(p, actual_payload_len); if (actual_payload_len != fixture.expected_payload_len) { printf("%s failed:\n expected payload len: %d\n received: %d\n", fixture.test_case_name, fixture.expected_payload_len, actual_payload_len); PrintPayload("sent", sent_buf, strlen((const char*)sent_buf)); PrintPayload("received", p, actual_payload_len); result = 1; } else { char* actual_payload = strndup((const char*)p, actual_payload_len); if (strcmp(actual_payload, fixture.expected_return_payload) != 0) { printf("%s failed:\n expected payload: \"%s\"\n received: \"%s\"\n", fixture.test_case_name, fixture.expected_return_payload, actual_payload); result = 1; } free(actual_payload); } if (result != 0) { printf("** %s failed **\n--------\n", fixture.test_case_name); } TearDown(fixture); return result; }
tls1_process_heartbeat()
测试几乎相同,只是它们调用 SetUpTls()
来初始化 HeartbleedTestFixture
,并且不涵盖 ExcessivePlaintextLength
情况。ExecuteHeartbeat()
和其他测试辅助函数比“goto fail”测试的辅助函数复杂一些,但只是稍微复杂一些。
与“goto fail”测试一样,此测试是在没有测试框架的帮助下编写的。它可以被直接复制到 1.0.1-beta1 到 1.0.1g 的任何 OpenSSL 发行版的 test/
目录中,无需任何修改并执行。当针对版本 1.0.1g 执行时,测试通过并且不产生输出。对于其他版本,名称中带有“Heartbleed”的测试用例将失败,输出类似于
TestDtls1Heartbleed failed: expected payload len: 0 received: 1024 sent 26 characters "HEARTBLEED " received 1024 characters "HEARTBLEED \xde\xad\xbe\xef..." ** TestDtls1Heartbleed failed **
失败测试中返回的缓冲区的内容将取决于执行测试的机器上的内存内容。kMaxPrintableCharacters
的值(在测试文件顶部默认为 1024)可以增加,以查看返回的更多内存内容。
打破它,分解它
在 Heartbleed 示例中,我们可以解决另一个问题,而“goto fail”示例中无法解决。使用“goto fail”,我们无法了解引入该 bug 的确切更改;现有证据表明,这可能是一次大规模合并操作,加上代码重复。不过,“复杂合并”理论只是一种猜测。使用 Heartbleed,我们可以看到引入 TLS 心跳功能和埋藏在其中的 Heartbleed bug 的确切更改,并且它已经过代码审查。
非常熟悉单元测试的开发人员会制作或坚持一系列经过良好测试的更改,以构建一个功能,而不是像本例中的那样进行单一的、整体性的更改。一个较小、经过良好测试的更改只包含上述函数,可以更好地使作者、审阅者或感兴趣的旁观者注意到使用外部提供的值来读取一块内存,并验证该值是否已得到妥善处理。明确引用定义心跳请求的结构和处理的协议的特定部分也可能有助于集中测试和审查。
编码标准文档也可以帮助完成此过程。除了指定命名、空格和花括号放置的具体信息之外,这样的标准还可以要求请求和缓冲区处理代码附带测试,以验证不存在缓冲区溢出问题。除了要求所有提交审查的代码都应作为一项政策被新的或现有的单元测试覆盖之外,还需要这样做。
如果它没有经过测试,它就没有被修复
上述概念验证测试表明,可以想象,如果有人尝试对代码进行单元测试,他们有可能发现并阻止历史上最严重的计算机 bug 之一。概念验证单元测试的存在消除了认为这是不可能的断言。 遗憾的是,为该 bug 提交的修复 也缺少单元测试来验证它并防止回归。
如果没有自动化回归测试,则任何错误都不会被认为已得到妥善修复。
在单元测试文化中,当发现错误时,自然反应是编写一个测试来暴露该错误,然后修复代码以消除它。为了扩展在“转到失败”讨论中提出的观点,即手动测试运行以验证代码更改被证明是短暂的,没有测试的修复容易被撤消。自动化回归测试可以防止未来错误,就像最初为代码编写的测试一样。
鉴于现代版本控制系统的强大功能以及越来越普遍的分叉、合并和挑选操作,测试比以往任何时候都更重要,以防止意外更改,尤其是导致已知灾难性错误回归的更改。在挑选或合并期间明显删除回归测试应触发警报,如果测试与修复包含在同一更改中,则更是如此,因为修复也可能被撤消。
似曾相识
最后一点:通过在单独的浏览器选项卡中打开每个 dtls1_process_heartbeat()
(ssl/d1_both.c
) 和 tls1_process_heartbeat()
(ssl/t1_lib.c
),并在它们之间切换,我们再次看到对重复的、未经测试的代码的明显容忍,就像我们在“转到失败”示例中看到的那样。有了概念验证测试,就可以通过提取一个具有额外参数集的公共函数(可能是一个小的“跳转表”)来消除重复,以实现算法之间的细微差别。
重新审视 Linus 定律
现在应该清楚的是,“转到失败”和 Heartbleed 错误都是相当简单的编程错误,这是单元测试非常擅长及早发现的错误类型。从上述讨论中也应该清楚,通过概念验证单元测试的实施得到支持,很有可能可以防止这些错误,如果产生每个错误的团队采用了单元测试实践的话。
这些灾难性缺陷还展示了“Linus 定律”的局限性——只要有足够多的眼睛,所有错误都是浅显的——同时它也展示了该定律的真正潜力。
只要有足够多的眼睛,所有可利用的错误都会被发现——但不一定是好人发现的。
不知道这两个错误是否曾经被成功利用过,但这些代码作为开源代码在 Apple 和 OpenSSL 的服务器上已经存在多年,这为恶意攻击者提供了发现这两个错误并利用其知识为自己谋利的机会,而无需通知其他人。鉴于这一认识,我们提出 Linus 定律的一个推论
并非所有注意到开源代码中错误的眼睛都属于圣人,他们会出于公共利益报告或修复这些错误。
同时,提供对源代码的开放访问意味着,在这两种情况下,世界上任何拥有互联网访问权限的人都可以事后检查代码,以了解错误的性质和严重性,报告其技术细节和影响,并就吸取的教训和防止再次发生的适当应对措施进行辩论。当然,这些报告的质量各不相同,但开源软件提供的透明度使公开辩论成为可能,最终理想情况下,这将导致对社会有益的客观的教训。如果类似的漏洞发生在闭源软件中,则很难进行这种有价值的讨论——实际上,类似的漏洞很可能已经存在,而且整个软件开发社区可能永远没有机会从中吸取教训。
能够访问开源代码使我能够将我的单元/回归测试提交到 OpenSSL 中心源代码存储库。
此外,在这两种情况下,能够访问开源代码使我能够深入研究每个代码库,并在几个小时内为每个 Bug 编写结论性的概念验证单元测试。它还使我能够与 OpenSSL 开发人员合作,并提交一个 概念验证 Heartbleed 单元测试的拉取请求(当然是从 Google 调整为 OpenSSL 编码风格),最终作为 ssl/heartbeat_test.c
被纳入 OpenSSL 中心源代码存储库。
当然,这就提出了一个问题:为什么负责代码的团队没有在多年前 Bug 引入时编写或坚持此类测试?
责任在于代码审查流程,通过该流程,控制对规范源代码存储库的访问权限的开发人员接受将更改纳入代码库。如果代码审查员不要求进行单元测试,那么粗制滥造的代码就会堆积如山,从而增加了另一个“goto fail”或 Heartbleed 漏洞的几率。正如“goto fail”的情况一样,许多公司的开发团队专注于高级业务目标,缺乏提高代码质量的直接动机,并且认为对代码质量的投资与按时交付相矛盾。正如 Heartbleed 的情况一样,许多开源项目都是由志愿者推动的,而中心开发人员要么没有时间,要么没有技能来强制执行每项代码更改都必须附带全面、精心设计的单元测试的政策。没有人支付、奖励或施压他们来维持高水平的代码质量。
因此,产生 Bug 的开发文化要么根本没有考虑单元测试,要么考虑过并基于某种理由拒绝了它,我认为这可以描述为感知到的“机会成本”。这意味着单元测试被认为无法为投资提供足够的价值,从而从其他优先事项和机会中耗尽宝贵的资源。这可能不是一个有意识的决定,但该选择是由团队决定采用其他工具和实践来体现的。
无论如何做出此类决定,但事实是,开发和维持高效的单元测试文化并不是一项免费的主张。在下一节中,我将探讨这些成本并考虑它们是否值得。
单元测试文化的成本和收益
虽然单元测试可以极大地减少低级缺陷的数量,包括像“goto fail”和 Heartbleed 一样高可见性和高影响的缺陷,并对代码质量和开发过程的其他方面产生积极影响,但构建和维护单元测试文化是有代价的。没有免费的午餐。
启动成本
将有一个学习曲线。就像任何依赖于工艺而不是死记硬背过程的技能一样,学习编写单元测试的程序员必须经历学习和发展、反复试验、反思、实验和集成的阶段。这会占用其他活动的时间、精力和资金。随着人们习惯这种做法,它将导致开发过程的最初放缓。
也就是说,这是一次性成本。如果团队已经制定了良好的单元测试实践,那么让某人了解单元测试的成本相对较低,并且单元测试技能可以从一个项目移植到另一个项目。因此,对于根本没有任何单元测试实践的团队来说,学习曲线最陡峭。
单元测试,就像任何其他工具、语言或流程一样,都可能应用不当——尤其是在刚开始的时候,如果既没有好的示例可供参考,也没有导师指导,那就更糟了。脆弱、庞大、缓慢、持续中断(随后被忽略)或不稳定的单元测试会树立不良示例,这些示例可能会像病毒一样在整个测试套件中复制。编写不当的测试实际上可能比根本不进行测试还要糟糕,给人留下测试是浪费时间的印象。构建仍然中断且被忽略,测试信号被持续故障的噪音淹没。对测试环境不感兴趣的开发人员会愿意忍受缓慢而痛苦的更改带来的恐惧。最终结果是拖累生产力、增加缺陷风险,以及一个确信测试是为其他人准备的团队。
培训
为了弥补这种知识和经验的缺乏,积极主动的开发人员可以联合起来,以提高彼此的单元测试技能,并随着时间的推移增加代码库的测试覆盖率。在本节中,我将描述 Google Web 服务器团队如何建立其测试覆盖率并实现高度的整体生产力;在后面的章节中,我将解释 Google 作为一个整体如何能够采用单元测试文化,以及从该经验中吸取的教训如何应用于各个团队。但是,自学需要时间和精力,全局收益可能不会立竿见影,因此需要耐心、诚实努力和承诺才能坚持到底。然而,随着时间的推移,随着代码库的增长和更多开发人员加入团队,价值变得越来越明显。一个两人团队可能无需单元测试就能管理,但一个二十人团队会更困难,因为功能和通信复杂性会加剧。
如果开发人员没有动力去研究可用材料和提高他们的技能,或者只是不知道如何开始,这可能意味着需要投资内部培训计划或聘请外部帮助提供培训。如果资源紧张、截止日期迫在眉睫,并且未来的收益不明确,这可能会导致一些价格冲击。学习必要技能所需的时间不应超过培训开发人员任何其他技能或技术的所需时间;但如果开发人员抵制,这个过程可能会变得更加漫长、痛苦和昂贵。
把自己逼入绝境
有时,测试本身会成为维护负担;它们似乎会将项目逼入死角,限制进度而不是最大化进度。对于缺乏单元测试经验且不了解其价值的新团队来说,这是一个特别的危险。模拟对象容易被缺乏经验的从业者滥用,从而导致价值可疑的脆弱测试。随着经验的积累,这种情况发生的可能性会降低。你最终会学会退后一步,重新评估代码和测试的目标,并重写其中一个、另一个或两者。在此期间,有时可能需要替换过于严格的测试,而不是花费精力来挽救它。
说到新项目或团队或公司或领域,按照敏捷实践逐字逐句地进行并始终实践纯粹的测试驱动开发(TDD)可能是理想的,但有时开发人员或团队需要探索、尝试,然后再认真定义期望和行为。(有人认为,始终逐字逐句地遵循所有敏捷实践表明你并不理解敏捷。)虽然在项目中尽早获得测试经验总是好的,但有时你只需要编写一次性原型代码;在这种情况下,彻底的单元测试可能是矫枉过正。对于尽可能快地推出产品的初创公司来说,这一点尤其正确。
另一方面,请注意这句话:“没有比一次性代码更永久的东西。”权衡利弊在于,在没有配套测试的情况下实现的功能越多,团队积累的技术债务就越多,这些债务必须在以后偿还。如果你没有从一开始就设计可测试性,单元测试可能会很困难——使用依赖注入,编写专注于一件事的定义良好的类,等等。由团队来衡量此类债务的可接受限度,以及在何种情况下必须偿还债务以避免在维护和新特性开发变得过于繁琐时进行更昂贵的重写。
谁来测试测试?
无法保证单元测试本身没有错误。考虑以下示例(基于Google Test框架的类似 C++ 的伪代码)
TEST_F(FooTest, IfAPresentFilterB) { setup input and add "A:" , "B:" run call EXPECT_TRUE(PresentInOutput("A:")) EXPECT_FALSE(PresentInOutput("B")) }
此测试中的第二个期望应检查带有冒号的"B:"
,而不仅仅是"B"
。如果被测代码意外地过滤了不带冒号的"B"
,则测试将在应该失败时通过。
有人认为测试在这种情况下会让事情变得更糟,提供一种错误的安全感。然而,即使没有编写测试,该错误也可能存在;鉴于存在错误的测试,修复代码和测试等同于为该错误提供回归测试。修复测试并从错误中吸取教训是有价值的;责怪测试并删除它是一种倒退。为了避免将来出现错误的测试,一种可能的措施是负责此类错误的团队可以努力仔细查看作为未来代码审查的一部分提交的测试代码,以使其与“生产”代码具有相同的优先级和谨慎性。
实际上,错误的单元测试往往是例外。如果实践纯粹的测试驱动开发,则应在使测试通过的代码之前编写一个失败的测试;这有助于防止此类错误。如果没有实践纯粹的 TDD,则还可以暂时向被测代码中添加一个错误以确保测试将失败。在任何一种情况下,编写多个测试用例来检查代码不会执行它不应该执行的操作(而不是仅仅检查所有输入都有效的正常路径)可能会发现其他测试用例中的错误。尽管如此,单元测试本身仍然可能包含错误,特别是如果没有小心确保它们在应该失败时失败。
测试是给傻瓜的
过去曾有过成功的团队或公司,其中充满摇滚明星程序员,他们敲出改变世界的代码。谷歌在其成立的最初几年中肯定符合这一描述。在这种情况下,有人认为,在那个时代,花在单元测试上的时间将是浪费的,因为它可能会不必要地减慢那些顶级开发人员的速度,尤其是如果他们还没有习惯编写单元测试。由于公司和代码库较小,并且代码审查已经是强制性的,因此公司可以有效地通过只聘用能够在该环境中快速跟上的“最聪明”的程序员来管理复杂性。
那么问题就变成了:为什么这种情况没有永久持续下去?
Google Web 服务器的故事
尽管存在风险和成本,但重要的是要认识到单元测试的好处不仅仅是最大限度地减少发布灾难性错误的机会。
当我于 2005 年加入谷歌时,它已经非常成功,许多“长期员工”认为这是因为我们做的一切都是正确的。因此,在当时和此后的几年里,人们对变革有很多抵触情绪。然而,随着用户群和灾难的可能性激增,随着成功和随之而来的增长追赶谷歌,很明显,更多制作“摇滚明星”代码的“摇滚明星”从长远来看只会产生一堆噪音和混乱。最终,大量新谷歌开发人员的涌入帮助加速了向单元测试采用的文化转变,既是因为这些新开发人员对这个想法持开放态度,也因为测试最终被证明在帮助这些新人快速上手和避免犯错方面是有效的。
举个具体的例子,让我们来看看互联网上最受欢迎的页面:Google 的主页。Google Web 服务器 (GWS) 团队的单元测试故事在整个公司广为人知。GWS 团队在 2000 年代中期陷入了一种境地,即难以对 Web 服务器进行更改,这是一个 C++ 应用程序,为 Google 主页和许多其他 Google 网页提供服务。尽管存在这种困难,但集成新功能对于 Google 作为一家企业的成功至关重要。阻止人们尽可能快地进行更改的障碍与大多数成熟代码库中减缓更改的障碍相同:一种相当合理的担心,即更改会引入错误。
恐惧是思想杀手。它阻止新团队成员更改事物,因为他们不了解系统,它也阻止经验丰富的人更改事物,因为他们对此了如指掌。
Google Web 服务器团队采取了强硬路线:不接受没有附带单元测试的代码。
决心克服这种恐惧,GWS 团队引入了一种测试文化。他们采取了强硬路线:不接受任何代码,不批准任何代码审查,除非附带单元测试。这常常让试图推出其功能的其他团队的贡献者感到沮丧,但 GWS 团队坚持自己的立场。
随着时间的推移,单元测试覆盖率和开发势头不断上升,而缺陷、生产回滚和紧急发布数量却在下降。新团队成员发现自己的工作效率提高得更快,因为测试使他们能够一次一个单元地更深入地了解系统,并开始做出更改,确信现有测试可能会检测到任何意外的副作用。他们在早期工作中导致任何测试失败都会加速他们对系统的掌握。团队中经验丰富的成员已经变得谨慎地进行更改和接受贡献者的更改,他们能够出于同样的原因快速进行和接受更改,并且不再需要主要依赖于大型且昂贵的系统或手动测试,反馈周期以小时或天为单位。增加更多新开发人员实际上可以让团队更快地行动并做更多事情,避免了布鲁克斯定律中描述的情况,即“在软件项目后期增加人手会使项目延期”。
此外,缓解恐惧导致了他们对编程的喜悦的扩大,因为他们可以看到在不受到高优先级错误的慢性爆发阻碍的情况下,朝着激动人心的新里程碑取得的切实进展。基于保持创造性流动状态的能力,高昂士气对生产力的影响不可低估。在我还在 Google 的时候,GWS 团队展示了理想的测试文化,集成了来自外部贡献者的大量复杂更改,同时进行了他们自己的持续改进。
得益于 GWS 示例激发了测试小组(一个由志愿促进单元测试采用的开发者团队,本文后面部分会进行描述)的努力,Google 的许多团队都能够转变为单元测试文化,并受益于减少恐惧和提高生产力。克服惯性、冷漠、过时工具的摩擦和阻力确实需要时间,因为最初单元测试感觉像是一种成本,有些人担心花在编写行为的第二个表示上的时间本可以用来编写新代码(这会让他们获得晋升)。最终,随着人们体验到抛开对改变的恐惧意味着什么,他们开始认为这种副作用很容易超过那些代码行,因为它对他们的幸福感、团队的幸福感和生产输出的底线产生了影响。
紧密的反馈循环
随着时间的推移,单元测试纪律让 Google Web 服务器团队能够更快地行动并做得更多。单元测试与发现错误一样,在提高生产力方面也同样重要。
如果您错过了,关于 GWS 团队故事的重要一点是,随着时间的推移,单元测试纪律让团队能够更快地行动并做得更多。单元测试与发现错误一样,在提高生产力方面也同样重要,因此适当的单元测试加快了他们的速度,而不是减慢了他们的速度。让我们重点介绍一些促成这一结果的因素。
单元测试与集成测试、系统测试或任何类型的对抗性“黑盒”测试不在同一类别中,后者尝试仅基于其接口契约来演练系统。这些类型的测试可以以与单元测试相同的样式自动执行,甚至可以使用相同的工具和框架,这是一件好事。然而,单元测试将特定低级代码单元的意图编纂成代码。它们专注且快速。当在开发过程中自动测试中断时,可以快速识别并解决负责的代码更改。
这种快速反馈周期在开发过程中产生了一种流畅的感觉,这是解决复杂问题所需的理想专注和动力状态。将此与相反的现象进行对比,使用上下文切换的熟悉操作系统隐喻。上下文切换要求以某种方式保存当前操作状态,并在启动新活动之前交换新的操作状态;然后是切换回所需的时间和精力。此外,还有每个操作必须管理多少状态的问题。如果没有单元测试,我们必须使用更多的大脑来记住奇怪的特殊情况和奇怪的副作用,这让我们没有更多的时间和精力去做我们比计算机做得更好的事情:推进新问题的解决方案,而不是权衡所有已经解决的问题的重量。
换句话说,您可以提高生产力,因为您可以更快地迭代代码:如果您只需运行单元测试,则不必启动一些重量级服务器。因此,如果需要几次尝试才能获得正确的代码,如果您必须一次又一次地启动服务器,那么这几次尝试可能需要几分钟(或更长时间),而如果您只需要每次重新运行单元测试,则只需几秒钟。
提高代码质量
就像在产品层面进行自用一样,编写使用您自己代码的代码可以带来更好的设计。
代码质量很重要,远非学术纯洁性的演练。糟糕的代码为错误提供了大量可以隐藏的阴影;好的代码增加了尽早发现并消除错误的机会。当一段代码的作者为该代码编写测试时,作者实际上就成为了第一个用户。就像自用在整体产品层面是良好的软件开发实践一样,编写使用您自己代码的代码可以带来更好的设计,这些设计更具可读性、可维护性和可调试性。
考虑你正在编写的代码想要解决什么问题;然后考虑作为客户端,你希望编写哪些代码来利用该解决方案。理想的客户端代码可以表示为单元测试用例,这些用例使用你正在开发的代码的界面。
当以这种方式处理代码级设计时,构成更大系统的所有较小部分不仅变得更加可靠,而且更容易理解。这使得每个人都更高效,因为理解特定代码片段的作用所需的精神努力会降至最低。
可执行文档
单元测试名称可以作为代码行为的规范;测试本身作为每种行为案例的代码样本。要实现此目的,请为测试代码设置与生产代码相同的质量标准。
编写良好的单元测试可以提供两种类型的文档:测试名称作为代码行为的一种规范;测试本身作为每种行为案例的代码样本。甚至比典型的应用程序编程接口 (API) 文档更好,维护良好的单元测试根据定义是实际行为的最新表示。单元测试的作者有效地向其他开发人员传达了如何使用一段代码以及对其有何期待。这些“其他开发人员”可能是团队中的新人,或者尚未被聘用(甚至尚未出生)。此类文档可帮助开发人员理解不熟悉的代码,甚至是整个系统,而无需在没有单元测试的情况下打扰其他人。
编写不当的单元测试缺乏这种质量,通常是因为与“生产”代码相比,对测试代码考虑得较少。解决方案:为测试代码设置与生产代码相同的质量标准。如果你不这样做,你的测试将变得难以维护并拖慢团队进度。
加速理解
每次测试失败时,都是加深你对系统理解的机会。
这样想:每次测试失败时,都是加深你对系统理解的机会。如果你是一个团队的新人,当你开始对系统进行更改时,打破许多测试可以帮助你更快地提高效率,因为这些事件中的每一个都会使你对系统的认识更接近现实。如果你已经在团队中呆了很长时间,现有的测试将回答新贡献者可能提出的许多问题,从而节省你的时间和精力。它们还会提醒你过去可能编写的代码的所有细微差别,并且一段时间内不必考虑它们,如果你必须重新深入研究它们。换句话说,当你为你的代码添加一套精心设计的测试时,你将使未来的自己受益,最大程度地减少了重新切换回以前心态所需的时间。
想想相反的情况,就像 GWS 单元测试之前的时代:当你参与一个没有充分单元测试覆盖范围的项目时,你不敢做任何事情,因为你不知道你可能会破坏什么。
更快的错误查找
想象一下在集成或系统测试中发现了一个错误,或者在将新版本推送到数据中心后,或者一段时间后由用户发现。负责错误代码的开发人员已经转向其他任务,并且可能面临交付的截止压力。如果错误足够严重,至少其中一名开发人员将不得不停止处理错误,从而减慢正在进行的新开发工作的进度。
如果错误代码被一组自动化测试很好地覆盖,尤其是小型单元测试,那么分配给修复错误的开发人员可能不需要花费太多时间。现有的测试充当受影响代码意图的文档。开发人员添加一个新测试来重现错误,在尝试修复错误之前验证缺陷是否已得到很好的理解。此新测试验证了错误修复,并且现有测试提供了高度的信心,即修复没有意外的副作用。新测试成为测试套件的永久部分,以防止回归,修复已发布,并且新版本的开发仍在继续。中断已结束。
与错误代码未被单元测试很好地覆盖的情况形成对比。开发人员必须花时间了解受影响的代码,并更仔细地找出错误并确保其修复没有副作用。修复的验证可能需要几天甚至更长时间,具体取决于任何预发布测试的性质(如果存在的话)。中断时间延长,并且从新版本中耗尽了更多开发和测试时间。
或者更糟的是:团队可能决定将错误保留在原处,因为害怕破坏其他内容。这当然不会激发用户的信任,更不用说开发人员的信心和生产力了。
你是否有经验?
在所有这些话语、话语、话语之后,你仍然不相信单元测试的价值和力量吗?不能说我责怪你。老实说,就像生活中的其他美好事物一样,在你真正尝试之前,你无法真正知道它是什么样子。最重要的是,在有人帮助你学习如何做好它之前,你可能根本不会喜欢它。
我自己的单元测试经验并不是从一些广泛的理性论证或令人信服的客观证据开始的,这些证据让我相信尝试它。我在诺斯罗普·格鲁曼担任团队成员,刚刚完成了满足所需认证截止日期的残酷努力;在接下来的几个月中,在出于性能和稳定性原因重写子系统时,我尝试了单元测试。这两种体验之间的差异再明显不过,更具说服力。我可以看到并感受到新系统在添加每项新功能时的进度,并且成品完全按照预期的那样。当罕见的错误确实发生时,只需几个小时即可找出它、重现它、修复它并发布修复程序,而在此过程中不会添加任何新缺陷。
没有比单元测试的实际体验更有力的支持单元测试的论据了。最棒的是,单元测试技能可以跨领域、跨语言和跨公司移植,就像任何其他基本的编程技能一样。
我要说的是,没有比单元测试的实际体验更有力的支持单元测试的论据了。你无法衡量生产力,但你可以感受到它。即使你的第一个单元测试被证明是丑陋的、复杂的和脆弱的,相信我,你可以做得更好,而回报将非常值得。
最棒的是,单元测试技能可以跨领域、跨语言和跨公司移植,就像任何其他基本的编程技能一样。这是一项在一生中都会带来回报的投资。记住:过去的单元测试经验使我能够为“goto fail”和Heartbleed快速编写概念验证单元测试,而我对代码不熟悉,并且多年来没有定期编程。
亲自动手
本文的前两部分包含指向“goto fail” 单元测试包和Heartbleed 单元测试的链接。如果您尚未执行此操作,请下载代码,在系统上构建并运行它。确保测试通过。然后,更改测试代码或被测代码中的内容,使其中断。查看输出。接受它,反思它。然后修复代码以使测试再次通过。
您认为自己已经理解了“goto fail”和 Heartbleed 代码,但现在您已经实际感受到了它的工作原理。
您(应该)体验到的是对真实系统的一部分进行更改并几乎实时地看到该更改的影响所带来的智力刺激,而无需构建和启动整个产品并在用户界面中四处查找。想想看:到目前为止,您认为自己通过阅读它、阅读本文前面的解释或您可能读过的其他资料,理解了“goto fail”和 Heartbleed 代码。但现在您已经实际感受到了代码的工作原理。在 Heartbleed 测试中,您实际上可以看到计算机内存的内容溢出到屏幕上。(在我的计算机上,我可以清楚地看到我的 PATH
和其他环境变量。)
立即验证您刚刚添加或更改的代码确实执行了您打算执行的操作的兴奋感就是其本身的奖励。您的代码将正确处理任何输入的(相对)确定性令人振奋。当测试检测到您刚刚编写的代码中的错误时,一种兴奋感涌上心头,您(或其他一些可怜的人)不必花数小时来调试、修复、验证和清理它,这是令人上瘾的。
并且在您从未见过的代码中重现重大错误?无价。
即时满足感是我们这些对单元测试发誓的人真正着迷的原因。无需合理的论据、数据、图表或金额。
即时满足感是我们这些对单元测试发誓的人真正着迷的原因。对于其他人来说,这是对回归不会发生的极高程度的信任。在任何一种情况下,单元测试都会基于前进的意识和无所畏惧的生产力意识创造一种纯粹的高潮,而没有其他令人讨厌的成瘾副作用。无需合理的论据、数据、图表或金额。
没有测试是孤立的
然而,尽管有其所有好处,单元测试不应该是您开发工具箱中用于确保高质量、几乎无错误代码的唯一工具。接下来,让我们考虑一些其他可用工具和实践,这些工具和实践可以与单元测试一起用作日常开发的一部分,以及为什么在考虑可以用来及早发现缺陷的其他项目时,仍然值得采用单元测试。
其他有用的工具和实践
尽管单元测试在及早检测编程错误方面很有效,并且尽管它有其他生产力优势,但它远非灵丹妙药,一种神奇的疗法,可以保证在发布之前消除所有软件缺陷。没有工具可以有效地做到这一点,因为开发一个工具来保证软件没有错误等同于解决停机问题。正如著名的引言所说
...程序测试可以非常有效地用来显示错误的存在,但永远无法显示错误的缺失。
-- 艾兹格·W·迪科斯特拉
事实上,某些类别的错误很难通过单元测试有效地进行测试,至少在一般意义上是这样。一个典型的例子是并发错误,它可能由于共享可变内存而发生,例如竞争条件和死锁。这可能涉及单一程序中共享数据的线程、同一台机器上的进程共享磁盘上的文件,或必须确保数据库中存储的信息一致性的分布式系统。虽然对单线程环境中的每一小段逻辑进行单元测试是确保多线程环境中正确性的第一步,但还远远不够,无法确保不存在并发错误。需要其他工具、测试层、暂存环境以及监控和日志记录形式来检测和调试此类问题。(不过,当发现此类错误时,理想的做法是提供单元测试来可靠地重现错误并验证其修复。)
鉴于这些事实,不仅建议,而且至关重要的是,使用其他工具和实践来解决早期检测缺陷的问题,以确保代码质量高,最大程度地提高产品成功的可能性,并最大程度地降低其失败的可能性。但是,在考虑其他工具时,请记住这一点:正如本文前面部分的原理验证测试所证明的那样,单元测试可以应用于任何语言,使用现有的开发工具,在任何其他开发实践的环境中,对现有代码进行测试。某些工具、框架、语言和实践可以使单元测试更容易、更高效,但不是先决条件。所涉及的主要成本是教育开发人员、经理和高管有关单元测试的知识,并说服开发人员进行单元测试。
当单元测试和其他工具协同使用时,就会发生真正的奇迹。使代码更易于编写和维护的工具和实践也有助于使单元测试更易于编写和维护。同时,设计可测试性(如果做得很好,并且没有推向逻辑极端)通常会导致更易于审查、维护、扩展、调试、使用其他工具分析和记录的代码。每种工具和实践都有其优点和缺点;每种工具和实践集成到开发文化中,都会降低错误进入产品的可能性,并减少解决仍然存在的错误所需的时间和精力。
编程是一种工艺,与大多数工艺一样,专家精通为工作选择合适的工具,并为每个单独的产品创建所需的定制工具。当建筑工人在浇筑水泥地基时,建筑工人创建的第一件事是将包含和塑造该地基的木结构。一位专家木匠可能会从构建一个框架开始,该框架将固定所有部件。在软件中,情况也是如此。在制作应用程序时,我们会选择为开发环境提供基础的平台、语言和工具,然后为该应用程序构建我们需要的定制工具:允许我们隔离部件并集中注意力于部件的存根和假象;将代码片段置于灯光下以便我们检查的单元测试。如果没有合适的工具来完成这项工作,则产生粗制滥造结果的风险仍然很高。
静态分析/编译器警告
静态分析和编译器警告是即使应用于经过充分测试的代码也是非常棒的工具。从不同角度确保代码质量的补充保障措施始终是一个好主意,因为这些工具可能会突出显示现有测试当前遗漏的问题点。即便如此,单元测试也可以阐明机器可能永远不会抱怨的潜在问题。“goto fail”错误可能已被静态分析或不可达代码编译器警告捕获;然而,虽然警告或强制大括号可以阻止这一行代码产生缺陷,但单元测试文化会鼓励负责代码的开发人员根除为其提供掩护的重复。在提取一个新函数以编写测试来彻底演练此关键算法后,程序员随后可以用对单个隔离函数的六次调用替换在同一文件中出现的同一算法的全部六个副本,从而提高长期代码质量并降低长期潜在错误的潜入和隐藏风险。
静态分析工具在检测重复代码方面越来越好,但编写单元测试的程序员仍然是第一道也是最有效的防线。此外,虽然此类工具可以检测死代码,但如果“goto fail”确实是错误合并的结果,请想象如果合并错误是以下内容
- if ((err = SSLHashSHA1.final(&hashCtx, &hashOut)) != 0) - goto fail;
换句话说,如果合并结果是意外删除算法中的最后一步。同样的错误,但世界上所有的花括号、静态分析和编译器警告都无济于事。单元测试可以捕获它。(您可以尝试使用概念验证单元测试来亲自查看。)
不过,静态分析和编译器警告可以帮助检测被测代码和测试本身中的典型错误。尤其是编译器警告是最容易应用的工具之一,因为它们已经内置在现有的工具链中。
程序员有时会在首次应用此类工具时抱怨“错误警告”,因为这可能导致大量输出。事实上,一些工具可能会抱怨诸如竞争条件或空指针之类的看似虚假的东西。可能是该工具还不成熟,或者不适用于您的特定产品。通常,静态分析工具允许抑制某些警告,从而使团队可以逐案决定是否忽略特定警告或暂时将其静音。
当问题堆积如山时,逐步解决问题。渐进式进度不仅仅适用于功能开发。
另一方面,许多工具,尤其是编译器警告,能够以相对较小的噪音检测到合法问题;当潜在问题长时间未被检测到时,它们往往会堆积起来,导致在应用工具时出现大量警告和错误。在这种情况下,真正的解决方案是逐步解决问题,就像向现有代码添加单元测试一样。修复一个文件的一个警告类别。继续下一个。尝试为代码添加一个测试(如果尚未涵盖)。渐进式进度不仅仅适用于功能开发。
现代语言
在启动不需要 C 甚至 C++ 的低级效率的新项目或应用程序时,Python、Ruby、Java、Scala、C# 或 Go 等“现代”语言可能会更具吸引力,因为这些语言具有
- 头等面向对象编程特性(例如继承/组合、封装、多态);
- 自动内存管理;
- 数组边界检查;以及
- 用于许多低级编程任务的通用库。
此决策在很大程度上与投资于构建单元测试文化的决策无关——并且大多数现代语言都具有强大的特性和库,以支持内置于其标准发行版中的单元测试,这只能有所帮助!
对于现有项目,在构建单元测试文化时,切换到新语言在很大程度上是不必要的。“goto fail”和 Heartbleed bug 属于用 C 编写的代码;但正如概念验证单元测试所示,有效的单元测试可以捕获此类错误并防止其传播,而无需诉诸更“现代、更安全”的语言。用新语言重写现有系统是一个昂贵且危险的过程,可能数年内不会产生收益。这并不是说它仍然不值得,但开发单元测试文化是你今天就可以开始的事情,其收益远远超过感知到的成本和风险。这是因为单元测试可以逐步应用于现有代码,即使必须一次更新一段代码以支持改进的可测试性,就像“goto fail”示例中演示的那样。此外,正如本文后面部分所述,Google 的测试小组帮助公司在大型项目中实现了这一点,最终证明向现有代码添加单元测试是一个已解决的问题。
如果某个团队决定冒风险用新语言重写一个系统,那么该语言不应该被视为所有潜在缺陷的解决方案。不可达代码和不安全的内存访问并不是等待修复的唯一错误,而重写提供了一个绝佳的机会,可以在重新实现功能时添加单元测试。如果该语言是动态类型的,那么拥有一套单元测试来记录预期类型并防止编译器自动捕获的其他语言的错误就更加至关重要。如果将应用程序移植到新平台需要用新语言重写,例如从 iOS 移植到 Android,那么拥有一套单元测试来移植也有助于平滑过渡并防止移植错误。
对于重写 OpenSSL 等低级系统项目,除了 C 之外,C++ 和 Go 是少数切合实际的语言选项。这两种语言中可用的测试工具和框架比 C 中可用的更强大,使得单元测试变得更加容易。Google Test 和 Google Mock 的功能和灵活性可与同类 Java 测试框架媲美。Go 内置了出色的覆盖率工具,而 ogletest 是一个深受 Google Test 影响的框架。
开源
开源代码并不能从定义上使其没有错误,这一点已由“goto fail”和 Heartbleed 证明。如上文“Linus 定律”的讨论中所述,开源为社会提供了许多积极的好处。它还可以从开发社区以及潜在客户和员工那里获得很多信任和支持。然而,尽管人们普遍认为开源代码可以自动保证高质量、无错误的代码,但事实并非如此。
如果你确实决定开源你的代码,那么除了之前提出的推论之外,你最好遵循 Linus 定律的另一个推论
如果你将代码作为开源发布,请确保对其进行单元测试,并坚持要求随附的高质量单元测试和文档对所做的更改进行贡献。
如果单元测试与功能开发一起被授予一等公民身份,“goto fail”和 Heartbleed 本可以避免。更重要的是,人们可以通过发现缺失的测试用例或向未覆盖的代码添加新测试,更容易地为项目的开发和长期健康做出贡献。新开发人员还可以更容易地掌握系统,将测试作为安全网、一种 可执行文档 和一种 加速理解 的反馈机制。
风格指南/编码标准
制定并遵守一套编码标准永远不会太晚,该标准可以为审阅者提供线索,表明一段代码应该受到更严格的审查。风格指南不仅可以避免因使用空格、大括号的位置和符号名称而产生的无数潜在争论,还可以帮助程序员通过熟悉的视觉惯例检测代码偏离特定惯例时可能表明存在缺陷。编码标准并不能消除对测试的需求;这两种做法相互强化。同样,以两种不同的表示形式犯同样的错误比只犯一次错误更难。
然而,虽然风格指南可以帮助避免许多错误,但它们无法捕获不正确的逻辑条件或数学运算。大多数编译器或静态分析器也不能,而未经测试的代码可能更难审查此类错误。仅靠风格指南可能无法施加促进所谓的 SOLID 设计原则的设计压力;为可测试性而设计实际上与良好的面向对象设计没有区别。
正如本文前面讨论 Heartbleed 的部分中提到的,OpenSSL 项目可以采用一种标准,即任何处理请求缓冲区或分配和写入输出缓冲区的代码都必须附带测试以防止常见的缓冲区漏洞,此外还要求所有提交以供审查的代码都必须由新的或现有的单元测试覆盖。
代码审查
代码审查是一种值得采用的做法,最好除了单元测试之外,而不是代替单元测试。它有助于梳理隐含的假设:我们都有想当然的知识,而且我们常常没有意识到它对其他人来说可能并不明显。在使代码对审阅者易于理解的过程中,作者常常被迫阐明他的假设并使代码的意图更加明显。此外,鉴于你的同行实际上会看到代码并公开评论它,它增加了“正确做事”的动机。这提高了代码质量,有时还会暴露错误。
代码审查对于文档也很有用。阅读审阅者的评论对于试图编写文档的人来说非常有启发,因为它揭示了对其他人来说令人困惑的内容,并突出了可能被忽视的细节。代码审查也是一个很好的机会,可以与代码一起审查文档。
开发人员可能需要一些时间来简化审查流程,花在审查代码上的时间不是花在编写代码上的时间——我也要补充一点,这不是花在调试代码上的时间——但知识转移的潜力导致团队或公司在编码、领域和产品专业知识方面达到更高的水平,这是巨大的。无论它作为正式记录的流程的一部分发生,还是作为 结对编程 的未记录的副作用发生,提交到源代码管理的每一次更改都应该进行代码审查。
较小的更改更容易进行代码审查,因为审查者一次需要检查的代码更少。经过良好测试的更改更容易进行代码审查,因为审查者可以看到作者考虑的情况,并可能受到启发提出更多建议。测试会使整体代码更改更大,但如果编写得当,应该有助于阐明被测代码的更改,并且审查起来应该相对简单。结合这两个原则,相对较小、经过良好测试的代码更改累积到一个完整的功能,比没有测试的整体更改更容易审查,例如产生 Heartbleed 漏洞的更改,该漏洞经过了代码审查。如果审查者需要一系列较小、经过良好测试的更改,那么该审查者可以验证作者是否探查了处理无效用户输入中的弱点并进行了防御。换句话说,测试可以提高代码审查的质量,就像它们提高代码质量一样。
质量的这种提高归因于“单元”范围相对较窄,使其更容易阅读和理解。良好的代码审查实践包括确保适当的单元测试涵盖成功和失败案例。
集成/系统测试
有人认为集成或系统测试应该优先于单元测试。当然,对于大型复杂项目来说,集成和系统测试至关重要,自动化程度越高越好。然而,正如所讨论的两个具体错误所示,有时最严重的错误可能在系统级别最难检测,而在单元级别最容易测试。单元测试应该是抵御错误的第一道防线,因为开发人员正在编写每一行代码并将更改发送出去进行审查;它可以在其他级别的测试中实际上不可行的特殊情况和错误处理情况下进行演练。
简而言之:你不应该在集成或系统级别发现本可以在单元级别发现的错误。当错误确实出现时,在尽可能低的级别编写一个测试来重现它并防止回归。在更高的级别编写一个等效的测试只会使测试变得比需要更复杂。
集成和系统测试的速度可能比单元测试慢几个数量级。它们通常倾向于与其他模块或系统交互,其中一些是应用程序外部的。这会增加测试时间。测试越慢,它们给开发人员的直接价值就越小,引入错误的可能性就越大。测试越慢,它们给出的直接价值就越小。在开发代码时,你需要运行得如此之快的测试,以便在完成键入后几乎立即就能让你知道错误。在你正在处理的内容在你脑海中还很新鲜时,那是打击相关错误的时刻。现在就纠正它,然后自信地继续前进。
话虽如此,集成和系统测试是必要的。单元测试本身无法确保高级组件之间的集成不会失败,或整个系统可以从头到尾成功执行完整操作。事实上,根据产品的性质,集成级别测试可能更容易编写,运行速度几乎一样快,并被证明足够可靠和可维护,以提供显著的价值。如果做得好的话,集成测试可以在组件中启用激进的重构,通常只需要对现有的集成测试进行很少的更改或根本不需要更改,而某些单元测试可能需要在过程中进行调整或重写。
无论测试在定义上是纯粹的“单元”测试还是运行迅速的受控良好的集成测试,范围狭窄的自动化测试都可以检测出可能破坏性的低级编程错误,这些错误可能会从其他工具和测试层的缝隙中溜走。不同规模的测试的平衡是可取的;缺少任何特定规模的测试都会带来麻烦。
单元测试实际上可以带来更轻松、更有效的集成和系统测试。 通过单元测试提高代码质量 可以通过设计良好的接口将系统更好地组合为有意义的组件集合。这为高级测试提供了更好的基础,并且在这些测试发现问题时更容易进行调试。如果你发现你的设计迫使你进行系统测试而不是单元测试,那么这肯定表明你的设计需要改变
文档
最终,每个有价值的系统都需要文档。这可以从应用程序编程接口 (API) 的低级技术文档到系统行为的高级文档。这些文档有效地定义了要求,即代码或系统旨在满足的合同。如果系统的某个部分难以记录,这通常是一个危险信号,表明设计出了问题。
单元测试和其他自动化测试提供了一种 可执行文档,但对于不直接负责代码的程序员来说,可能不是最容易访问的文档。然而,总体而言,良好的单元测试和良好的文档可以帮助确保高质量的产品:良好的文档定义了代码或系统的期望;良好的单元测试或其他自动化测试验证了这些期望。有助于连贯设计的编写良好的测试也有助于更准确和连贯的文档。
模糊测试
模糊测试是值得一提的另一种测试风格;Codenomicon 在对自己的产品进行模糊测试时发现了 Heartbleed。它涉及运行一个程序自动生成另一个程序的输入,以尝试发现错误。如果没有别的,Heartbleed 的发现就是对其有效性的有力证明。
这是另一种互补的安全措施。但是,模糊测试不能替代单元测试。模糊测试可以发现现有测试未发现的情况,但单元测试可以在运行模糊测试之前捕获许多错误。如果模糊测试确实发现了错误,则应将错误与适当范围内的自动化测试一起复制,以防止回归。
持续集成
持续集成是始终更新、构建和测试代码库的主干的过程,以确保它始终处于可发布状态。在每次代码更改时重建项目的 CI 系统可通过检测代码更改导致编译失败的时间来提供价值。但是,仅将它用于此目的是对其真正功能的严重忽视:检测代码更改导致整体构建失败(包括测试失败)的能力。不运行测试的持续集成系统就像一辆重型皮卡,只用来往返于杂货店。是的,它有助于发挥至关重要的作用,但你还可以用它做更多的事情!事实上,可以争论说除非你的构建是自测试的,否则它实际上并不是一个持续集成系统。
持续集成系统可能需要一些精力来设置和维护,但通常非常值得。Jenkins是一个流行的用 Java 编写的开源 CI 系统。Buildbot是另一个开源 CI 框架,由 Chromium、WebKit 和其他项目使用。Thoughtworks 的 Go 持续交付系统(不要与 Google 的 Go 编程语言混淆)是另一个开源系统,它可以管理非常复杂的依赖管道,不仅可以集成,还可以持续部署产品。
本文后面部分描述的 Google 测试自动化平台是一个极其强大的系统,它改变了 Google 进行大规模开发的许多规则。它依赖于大规模分布式构建和测试基础设施,以便在整个公司提交到中央存储库的每次更改后几分钟内提供结果。Solano CI是一项专有分布式 CI 服务,用于使用受支持语言编写的项目。
崩溃和核心转储
程序员通常会将断言插入到代码中,这会导致程序崩溃,并且根据语言和操作环境,会生成堆栈跟踪或内存映像(在 UNIX 术语中称为“core dump”),而不是冒数据损坏、进程失控或其他危险的风险。无论程序的代码是否经过单元测试,这都是一种良好的防御性做法。但是,崩溃进程应该是最后的手段;在代码已集成、手动测试、预发布到暂存区域或可能已发布到生产环境后,尝试诊断基本编码错误的成本要比通过编写单元测试来预先捕获此类错误的成本高得多。如果其他进程以相同的方式处理相同的输入,则在问题解决之前,服务处理其他流量的能力可能会下降,从而可能导致业务、收入和信任的损失。
发布工程
发布工程是跟踪特定软件版本的所有功能、错误修复和其他输入的过程,并且以一种方式进行,即所有工件都已标记和存档,并且最终产品可根据命令复制。对于基于云的软件,它还涉及对生产环境执行受控的推出,并密切关注生产监控信号,以指示成功或需要回滚。发布工程成为防止错误进入生产环境和用户手中的最后一道防线。发布工程师是最相信测试的,尤其是自动化测试,因为通过自动化测试是他们用来确定是否继续发布的最大信号之一。这是由于
- 可重复性:自动化测试本质上比手动测试更具可重复性
- 可审计性:自动化测试比手动测试产生更多可审计的记录
- 与发布自动化集成:自动化测试只是整体发布自动化故事的一部分,但手动测试会中断事物
站点可靠性工程和生产监控
一旦服务在云中运行,它就成为网络运营或 Google 术语中的站点可靠性工程的领域。如果团队缺乏专门的 SRE,则至少有一位开发人员必须负责此任务。除了用于监视正在运行的进程或进程组的外部可观察行为的工具之外,SRE 还严重依赖于正在运行的进程导出的变量和基于这些变量的计算。您可以将导出的变量视为进程运行状况的“生命体征”。
监控和 SRE 支持是必要且至关重要的,但它们不应该是发现错误的主要手段。是的,在任何足够复杂的系统中,错误都会时不时地出现;但 SRE 的利益在于最大程度地减少消耗其时间和精力的生产故障的数量,并最大程度地减少解决每个故障所需的时间。这也有利于开发人员;但鉴于 SRE 对底层代码缺乏依恋(一般而言),因此缺乏对代码与开发人员认为的一样正确的信心,他们对导致更多紧急生产工作的任何借口都不那么宽容——尤其是他们被呼叫在凌晨 3 点或周末和节假日处理的任何此类工作。
好消息是,标准监控挂钩可以成为有用的测试工具。与其尝试通过特殊接口或模拟对象设计方法来验证内部行为,不如检查程序导出的计数器或其他监控变量,这些变量可以在任何规模的自动化测试中提供可以轻松验证的输出。
成本
值得记住的是,所有这些工具和实践(包括单元测试)都会产生启动和维护成本。对于为开源项目做出贡献的个人来说,这种成本最为严重,他们没有资金、没有硬件、几乎没有正确流程的文档,而且通常还要从事其他工作。除非您拥有像 Google 这样的开发人员支持团队,否则设置持续构建通常是一项艰巨的任务。所有这些建议都应从这个角度考虑,这有力地论证了在整个组织中集中构建/测试/质量保证的许多功能。即便如此,从长远来看,什么都不做的成本将大于采用单元测试和您可以应用的任何其他工具以确保高质量代码并防止缺陷的成本。如果您的产品以某种方式对用户群的福祉至关重要,您就负担不起不这样做。
均衡早餐的一部分
还有更多值得讨论的工具和实践:防御性编程/按合同风格设计;错误报告和用户反馈机制;日志记录及其在错误检测和诊断中可以发挥的作用;自动堆栈跟踪整理和分析工具。希望很明显,所有这些工具和实践与勤奋的单元测试实践相结合,可以在提高代码质量方面产生巨大差异,无论是在编写代码时还是在编写代码后一段时间。每一种都值得考虑,但我希望我已经提出了一个有说服力的论据,即单元测试应该是首先采用的方法之一。它需要知识和经验才能做好,但并不一定要求采用任何其他工具、任何特定编程语言或任何其他实践才能开始获得好处。此外,它可以逐步添加到现有代码中,以随着时间的推移持续提高代码质量并减少缺陷的发生。
正如我在本文前面提到的,我知道这一点,因为我经历过。决定离开 Google 最困难的部分之一是知道我可能再也不会体验到另一个与它非常相似的开发环境了。此外,我正在放弃一项我无比自豪的成就:我的犯罪伙伴和我帮助推动了单元测试在很大程度上对其无知、冷漠或敌视的开发文化中的采用。在下一节中,我想分享我们努力的一些细节,以及 Google 开发环境中的一些其他元素,这些元素使大规模的高质量代码成为可能。
Google 的改装测试文化,或:似曾相识
除了“goto fail”和 Heartbleed 漏洞的知名度很高之外,将它们作为教学示例的最大原因在于,检测和预防此类漏洞是一个已解决的问题。在我加入 Google 时,开发文化在很大程度上反对单元测试。我与其他人作为 Google 测试小组的一部分所做的工作有助于使编写测试成为常态,而不是例外。以下是对测试小组如何在一家大型、不断发展、成功的公司培养强大的单元测试文化进行简要说明,该公司的大多数开发人员要么对单元测试一无所知,要么对此持敌对态度,声称“我的代码太难测试”或“我没有时间测试”。
我还将提及我在 Google 工作期间 Google 开发环境中的其他一些组成部分,以更全面地了解 Google 如何在其规模和功能开发速度不断提升的情况下,保持较高的代码质量。其中一些信息可能已过时,但我相信基于我的记忆得出的总体情况仍然可能有所帮助。提供此说明并不是为了规定一个有保证的过程,而是为了给其他个人和团队提供灵感,让他们在自己的组织中做出类似的改变。
要更全面地了解测试小组的活动以及让一切发生的幕后人员,请访问 我博客上的测试小组标签页面。
阻力
你可能认为 Google 采用单元测试文化很容易,因为 Google 是神话般的 Google,拥有无尽的资源和人才。相信我,“容易”并不是我用来描述我们努力的词。事实上,大量的资源和人才可能会阻碍我们的工作,因为它们往往会强化这样的观念,即一切都在尽可能顺利地进行,从而让问题在巨大成功的阴影下滋生。Google 无法凭借自己是 Google 而改变其开发文化;相反,改变 Google 的开发文化才是帮助其开发环境和软件产品继续扩展并达到预期,尽管开发人员和用户数量不断增加。
在 Google 中抵制单元测试在很大程度上是因为接受过单元测试教育不足的开发人员难以使用在 Google 不断增长的运营压力下负荷过重的旧工具编写新代码。向现有代码添加测试似乎非常困难,并且鉴于现状,为新代码提供测试似乎是徒劳的。关心单元测试的人们做了艰苦的工作,让其他 Google 员工相信编写单元测试不仅可以让他们确信自己编写的代码在今天是正确的,而且在六个月后,当其他人(甚至是原始开发人员)需要更改代码时,它仍然是正确的。
测试小组为我们这些关心单元测试的人提供了一个社区。测试小组及其盟友多年来一直在稳定地工作,并且成功地在整个 Google 中传播了测试知识,并推动了新工具的开发和采用。这些工具让 Google 开发人员有了测试时间,而共享的知识使他们的代码随着时间的推移更容易测试。测试小组测试认证计划参与者分享的指标和成功案例也有助于说服其他团队尝试单元/自动化测试。参与团队经常将测试认证归功于帮助改善他们最关心的生产力指标,例如在特定时间段内提交的代码更改和/或功能数量,相对于同一时间段内的错误、回滚和紧急版本。
什么是测试小组?
测试小组 由 Google 开发者团队组成,他们在 20% 的时间内(Google 提供的时间,允许开发者在主项目之外从事他们选择的与 Google 相关的项目)共同努力,以应对在整个 Google 推广单元测试采用的挑战。作为一个完全由志愿者组成的团队,资金有限,没有直接的权力,它依靠说服和创新来让 Google 开发者信服单元测试的价值,并为他们提供必要的工具和知识以做好这项工作。测试小组成功地采用了非常规策略来实现其在整个 Google 推动单元测试文化的宏伟战略,其中许多策略将在以下小节中进行描述。
这些与测试小组相关的努力代表了我们许多最佳想法,恰好是正确的时间提出的正确想法。我们尝试了很多其他事情,但没有坚持下来;重要的是我们坚持了下来。我们不断尝试新想法,从经验中学习,直到找到一套在当时的 Google 文化背景下特别有效的方法。其中一些方法可能适用于其他团队和公司;但也有可能不适用。不过,我希望它们能成为其他开发组织中可行想法的灵感来源。
测试小组只是“小组”集合中的一员,这些小组旨在通过帮助解决跨越所有团队的问题来提高 Google 日常开发生活的质量和生产力。小组通常通过提供基层反馈、倡导和其他形式的支持来补充官方专门团队的努力。例如,测试小组与测试技术和构建工具团队、EngEDU 内部培训组织以及整个工程生产力部门(在“测试认证”小节中讨论)有着密切的关系。由充满激情的志愿者组成的其他小组扩大了提高开发质量和体验的努力,其中包括:文档小组;指导小组;招聘小组;可读性小组,Google 风格指南和可读性传统的守护者;以及 Fixit 小组,它维护“fixit”传统,这是旨在解决广泛问题或推出新工具的以公司为范围的集中努力。
在厕所上测试
马桶上的测试(TotT)是一系列发布在 Google 卫生间的一页文章,是测试小组工作和成就中最引人注目的。始于 2006 年,每周都会发布剧集。每集都是对特定测试技术、工具或相关问题的概述,分发到全球 Google 开发办公室的卫生间。“广告”位于底部,类似于 Google 搜索结果广告,提供与主题相关的更多信息链接。每集都由志愿者撰写、审查、编辑和分发。多年来,它在教育 Google 开发人员了解单元测试的好处和正确应用方面非常有效,并且使用进一步丰富了测试小组工作的标准概念,在全公司范围内展开对话。这些对话通过允许非测试小组成员贡献他们的想法、论点和经验,帮助防止了回音室效应。
为什么在卫生间而不是其他公共场所张贴传单?为什么不发送电子邮件时事通讯?这个想法是在测试小组头脑风暴会议期间提出的;没有想法会被排除在外。我们尝试了许多传统方法——内部培训、特邀演讲者、分发书籍——并且正在寻找一些新角度来吸引人们的注意力。这个特殊想法的大胆性和押头韵的名字与小组产生了共鸣;它对我们有效。幸运的是,一旦我们开始行动并真正开始张贴传单,这个想法就付诸实施了。尽管少数人(正如预期的那样)提出了早期反对意见,但这种媒介的价值变得显而易见,并且它传达的信息——测试是一项可访问的技能,有利于逐步学习和改进——随着该系列的持续,产生了更深远的影响。
测试认证
测试认证是测试小组设计的一个计划,为开发团队提供了一条明确的途径,以改进单元测试实践和代码质量。它最初由三个“级别”组成,由离散步骤组成,团队可以将其作为季度目标并随着时间的推移实现。(据我所知,它最终定义了五个级别。)第一个级别专注于建立工具和基线测量(例如,持续集成服务器、代码覆盖率、识别长期中断和“不稳定”测试);第二个级别专注于采用和执行测试策略,要求对所有代码更改和新代码进行测试,并设定易于实现的测试覆盖率目标;第三个级别专注于指导团队实现高水平的测试覆盖率和随之而来的生产力优势。
让每个 Google 开发团队都达到测试认证三级状态成为所有与测试小组相关的努力的最终目标。工程生产力部门开始相信测试认证可以为测试工程师和测试中的软件工程师提供一个工具,以便更好地与开发团队沟通并更好地利用每个人的时间,并全力支持该计划。目标在 2010 年推出测试自动化平台持续集成系统后得到有效实现,此后 Google 的几乎每个开发团队都在测试认证三级运营。
测试雇佣兵
测试雇佣兵是一支软件开发团队,致力于全职帮助 Google 开发团队获得测试认证状态。测试小组提出了该团队的概念,该团队从 2006 年末存在到 2009 年初。理想情况下,至少两名雇佣兵会被分配到一个团队三个月,在此期间,雇佣兵将了解产品、代码和团队动态,然后尝试引入改进的单元测试实践,沿着测试认证设定的路径。团队之间的成功是不同的,并且难以从生产力影响方面进行衡量,但测试雇佣兵专注的全职工作极大地增加了所有其他基于志愿者的测试小组工作。测试雇佣兵的经验为许多测试认证讨论和“马桶上的测试”剧集提供了信息,并激发了对推动整个文化中单元测试采用的至关重要的工具开发。
测试修复
修复是组织的短期活动,重点是让 Google 的整个开发社区关注重要但已被搁置的问题。它们对于推出新工具和帮助解决开发人员可能遇到的任何问题也很有用。修复通常持续一天到一周,是几个小组和其他团队用于促成重大变革的最有效技术之一,这要归功于每个活动中投入的大量规划和参与。
测试小组在2006 年 8 月和2007 年 3 月组织了测试修复,重点是修复损坏的测试并为未覆盖的代码编写新测试,以及2008 年 1 月的革命修复,引入了来自构建工具团队的强大新工具,极大地提高了开发和测试速度。测试认证挑战持续了 2008 年夏天的几个月,招募了许多新项目,并帮助许多其他项目提升到更高的测试认证级别。构建工具团队 2009 年 10 月的可锻造性修复最终让几乎每个构建目标和测试都在云中构建和执行,完美地建立了整个测试修复/测试小组弧线的顶点:2010 年 3 月的 TAP 修复,在整个 Google 中引入了测试自动化平台。
这些以目标为重点的活动旨在强调测试小组发起的其他长期工作,将整体单元测试采用任务提升到一个新的水平。每次新的修复都利用了以前修复的经验和势头。事实证明,“马桶上的测试”是一个非常有价值的工具,可以提前向 Google 开发社区宣传这些活动并为他们做好准备。
运行修复不需要任何执行许可或指令。一旦一个小组决定运行一个,他们就会运行一个。(然而,工程副总裁通常愿意发送一份准备好的公告,鼓励参与。)修复小组的存在是为了帮助修复团队之间的协调,以确保他们选择最佳日期(例如,避免在 9 月初的 Burning Man 周期间进行任何修复,因为 Mountain View 的一半人都会在 Playa 上),并且不会相互蚕食各自的工作,导致一种称为“修复疲劳”的状态。修复小组还提供了工具、文档、历史记录和建议,以便新的修复可以从过去修复的经验中受益。
风格指南/编码标准
所有 Google 开发人员都必须在他们经常使用的每种语言中“获得可读性”。“获得可读性”是一个指导性流程,通过该流程,开发人员可以内化大量特定于语言的风格指南。尽管“获得可读性”涉及编写代码,但最终目的是确保你编写的代码根据公司范围内的惯例保持对其他开发人员的“可读性”。默默无闻的可读性小组是一个全志愿团队,负责维护这一宝贵的流程。源代码控制机制使得长期使用某种语言生成代码变得非常繁琐,除非获得可读性状态。这确保了风格指南保持相关性和广泛执行。
作为旨在避免错误的风格指南示例(与避免关于大括号、空格和名称的轻浮争论相反),当前的 Google C++ 风格指南坚持认为,如果被调用方要承担所有权,则堆分配的函数参数必须通过 std::unique_ptr
传递,如果调用方要保留所有权,则必须通过const 引用传递。这是必要的,因为在 C++ 中不会自动管理内存,并且与等待静态和动态分析工具捕获此类错误相比,培训开发人员通过观察识别不良内存管理是值得的。(Google 也运行此类工具,但它们成本高昂且提供了更长的反馈周期。)
几乎所有 Google 源代码存储库都可供所有开发人员浏览并检出到个人工作副本中。由于 Google 风格指南适用于给定语言中的所有项目,并且许多命名约定在语言指南中相似,因此 Google 开发人员可以轻松扫描他们从未见过的代码库部分中的代码,并相对快速地理解它。这使得 Google 员工可以轻松地为不同的项目做出贡献,将重复的代码提取到所有项目都可以重复使用的公共库中,识别并可能修补其他项目中的错误,甚至可以在不忍受适应新编码风格的摩擦的情况下切换项目。
代码审查
Google 自成立以来就制定了代码审查的实践:在代码未经作者以外的人员审查并明确批准之前,不会提交给源代码控制。存在控件以确保项目“所有者”包含在任何相关审查中。审查代码与编写代码一样是程序员的日常职责——有时甚至更多——而通用的风格指南消除了流程中的大量摩擦,使审查员能够快速标记风格错误的潜在问题,并尽可能地专注于变更本身的含义。内部工具帮助开发人员管理其传入和传出审查队列,并使每个开发人员都能了解每个代码变更的状态和讨论。
由于测试认证二级要求,几乎每个团队都有一个正式的书面开发策略,即每个代码变更都必须附带测试(除了不会更改已覆盖代码中现有行为的纯重构)。最终,构建工具和测试技术团队将测试结果(或其缺失)直接集成到代码审查工具中。审查员可以看到作者是否费心运行任何测试并确保它们已通过,特别是如果已根据以前的审查评论进行了更改。
隐藏低级详细信息的通用基础设施
鉴于大型共享源代码仓库和贯穿所有 Google 项目的统一语言风格,Google 鼓励开发通用库,以隐藏在所有 Google 项目中重复使用的底层详细信息。最广泛的示例是远程过程调用 (RPC) 的基础架构和 协议缓冲区,这是一种用于 RPC 系统和许多其他需要分层(通常是序列化)数据结构的地方的数据描述语言。如果 Google 中的任何人尝试定义序列化结构并直接操作内存缓冲区(例如包含 Heartbleed 漏洞的代码中的缓冲区操作),代码审阅员首先会说,“为什么不使用协议缓冲区?”
所有这些通用基础架构都经过了广泛的单元测试,并且存在使模拟 RPC 交互以及初始化/比较协议缓冲区值变得容易的单元测试基础架构。
测试自动化平台持续集成服务
当测试小组于 2005 年首次启动时,现有的集中式测试服务(称为单元测试框架)无法满足需求。它使用一组专用机器来构建和执行公司中的每个测试并将结果存储在数据库中。但是,由于系统负载增加,反馈周期变得越来越长,从而降低了其价值。
作为回应,两位广告开发者开发了自己的单机、特定于项目的持续集成框架,称为“Chris/Jay 持续构建”。该框架在 Google 中的普及部分归功于将其作为测试认证一级要求。它为 Google 项目提供了一个相对灵活的持续集成服务器,并且多年来很好地支持了测试小组的测试认证任务,但 C/J 构建确实需要使用它的每个团队进行大量的维护。
2008 年 1 月革命修复的结果,测试自动化平台 (TAP) 成为 Google 的集中式持续集成系统。在 2010 年 3 月的 TAP 修复期间在整个 Google 范围内推出,TAP 建立在 Google 的内部工具链之上,该工具链利用云基础架构大规模并行化构建操作和测试执行。TAP 在几分钟内执行了整个公司代码库中受每次代码更改影响的每个测试,以及受给定更改影响的那些测试。(自离开后,Google 持续增长,此时间范围现在可能已发生变化。)TAP 构建通过一个简短的网络表单进行配置,并且任何项目都可以有多个构建。TAP 的数据收集组件 Sponge 收集每次构建尝试和测试运行的结果,无论是由自动构建还是个人开发者运行的,记录其构建命令和完整的执行环境,并存档信息以供以后检查。TAP UI 提供了对公司中每个项目中影响每个更改的简单可见性。
TAP 代表了测试小组努力的最终最高成就。测试技术团队与构建工具团队紧密合作开发的 TAP 在多年的不懈努力后将巨石推到了山顶。在我离开 Google 时,几乎每个团队至少有一个 TAP 构建,并且大多数构建中断都会在大多数构建警察有机会注意到中断之前回滚或修复。
TAP 达到 11
如果最后一节还没有深入人心:集中管理的持续集成基础设施。一键式单页构建项目设置。公司中的每个变更都在几分钟内通过云中的分布式构建和执行进行集成、构建和测试(至少在我当时在那里时)。每个结果都被存储起来,并对公司中的每个开发人员可见。大多数故障在大多数受影响项目注意到之前就已经修复。天堂、涅槃、英灵殿、巨石阵,无论你想称之为任何东西,TAP 就是它。
构建监控球体
我在 Google 的第一个编码项目是编写一个脚本,该脚本将根据 Chris/Jay 持续构建的通过/失败状态改变一个发光球体(一个球形灯,小到可以一手平衡,但大到当将其放置在立方体墙或架子上时,整个团队都可以看到)的颜色和脉冲。随着时间的推移,此脚本的范围将扩展到处理在不同持续集成系统(最终包括 TAP)上运行的构建项目的令人眼花缭乱的组合,并控制包括纽约市启发的自由女神像(是的,火炬将以不同的颜色发光)在内的多个不同的硬件球体设备。最终,浏览器插件将作为更明显的提醒,提醒各个团队成员,无论他们是在自己的办公桌前还是使用笔记本电脑登录,但共享团队空间中的物理球体从未完全过时。
球体的目的是三重的:首先,它们很有趣。对于那些想要以立竿见影的方式推广测试文化的人来说,组建或扩展一个球体项目是一种有趣的方法。这有助于招募人员加入测试小组项目,并产生一种能量和进步感,从而提升士气。其次,测试小组将它们用作注册测试认证计划的团队的“奖品”,这是 Google 的悠久传统,通过奖励他们精美的礼品来说服人们采取行动。我们顺应 Google 的天性,而不是违背它。在缺乏资金和权威的情况下,测试小组必须充分利用可用资源和文化力量来影响变革。事实上,我认为这些限制迫使我们提出了比任何数量的金钱或权威所能产生的更具持久力的创造性解决方案。
最后,物理构建球体是高度可见的信息散热器,是功能齐全的公共仪表板的最佳替代品。可以说,球体仍然可能在拥有功能齐全仪表板的团队中占有一席之地,因为它鼓励了一种有趣的“羞辱”文化,团队成员开始亲自关心球体的健康,并在由于构建中断而球体显得不开心时相互追究责任。
Noogler 灌输
测试小组与 Google 的内部培训组织 EngEDU 合作,制作了一堂介绍性单元测试讲座和实验课。这有助于确保进入 Google 的每个新开发人员至少了解可用的工具和框架、单元测试背后的原理以及一些基本的单元测试原则和技术。通常,在测试小组成员讲授一小时的讲座后,Noogler 将参加由另一位测试小组成员监考的实验课,以立即获得他们刚刚学到的内容的实践经验。测试小组帮助制作和维护此实验课中使用的内部材料。
在“马桶上测试”启动后,随着公司发展和获得更多办公空间,Noogler 成为在整个山景城改善分销的主要机制。我们以承诺为本周的 TotT 剧集在他们的建筑物中发帖的任何勇敢的 Noogler 提供书籍或T 恤来结束单元测试讲座。我们称他们为“Noogler 军队”。这是让人们参与单元测试文化、获得乐趣并感受到归属感和对事业的早期贡献的另一种方式。
还有更多...
Google 拥有其他工具、流程和测试与暂存层,以确保尽可能高的代码质量并避免灾难性的可预防缺陷。他们并不能捕获所有缺陷,但许多确实溜过的缺陷相对较小,易于精确定位并快速修复,而无需担心负面副作用。更具挑战性的缺陷通常也可以更自信、更快速地解决。自动化测试(包括高水平的单元测试覆盖率)对于这种无畏环境至关重要,尽管开发操作和用户群规模庞大,但它能够实现高生产率。
但是,我不想让你产生 Google 很棒并且一切都做得很好,但你自己的团队或公司却无可救药的印象。我提供此描述是为了培养想法,而不是提醒你你的环境可能与理想相差多远。相信我,我所描述的我现在离开的 Google 的环境与我最初加入的 Google 形成鲜明对比,我和我的测试小组犯罪伙伴资金不足,而且人数严重不足。我们必须从小处着手,努力工作数年,才能影响我们决心实现的文化变革。
关键是,最终,我们做到了,尽管困难重重。为了总结这篇文章,我想阐述一些我从 Google 经历中得出的通用原则,这些原则可能会让你更清楚地了解如何随着时间的推移在自己的团队或整个公司中实施类似的变革。
如何改变一种文化
你可能确信“goto fail”和 Heartbleed 本可以通过单元测试来预防。你可能确信开源代码应该增加对单元测试的需求,而不是减少它。你可能确信单元测试除了缺陷预防之外还产生了许多好处,而且物有所值。在玩过本文中的概念验证测试并开始测试一些你自己的代码后,你可能已经对此有了兴趣。你可能确信单元测试可以用来改进现有工具和实践的应用,并且你可能受到 Google 在整个公司范围内推动单元测试的示例的启发。
现在你已经准备好开始在自己的项目、团队或公司中做出改变了……但你可能不知道如何开始。在这里,我将提供一些个人见解,可能有助于指导你。这不是要逐字遵循的处方,也不能保证结果。但是,我希望它们能够培养你自己的见解,这些见解可能有助于推动单元测试在整个环境中的采用。
成为你想看到的改变
(引用自 马哈特马·甘地)
无论您是否意识到,您已经开始了。您已经阅读了本文并内化了其论点。您已经内化了单元测试的经验。这为您提供了一个基础,一个在任何关于软件开发主题的讨论中都可以采用的观点。即使没有其他人追随您,也没有什么能阻止您现在就走上这条路。不要尝试直接改变任何人的想法;只需通过为自己的代码编写测试来展示如何做到这一点。寻找博客、杂志、书籍和研讨会来磨练您的技能,例如 延伸阅读 部分中的那些。阅读马丁网站上的所有内容。加入一个 Meetup,例如 波士顿、纽约、旧金山 和 费城 的 AutoTest Meetups,或自己开始一个。以身作则,坚持到底。
从现有代码开始,从小做起
正如“转到失败”和 Heartbleed 概念验证示例、Google Web 服务器故事以及整个 Google 故事所展示的那样,您现在就可以开始改进现有代码。您的代码库提高的唯一途径是使用它,而且没有任何讨论或争论会像实际编写测试一样有效。通过树立榜样,为他人提供可遵循的模式,您展示了这些想法甚至可以在您团队的代码中发挥作用——而工作代码就是其最佳论据。
取现有代码库的一小部分,并为其编写一个测试。如果必须,重构代码;提取可作为良好、独立单元进行测试的函数和类。在向现有代码添加新功能时,确保它是经过良好测试的单元的一部分,必要时使用新单元重构代码。
如果可以,添加一个单元测试框架;否则,研究本文中提供的示例,以了解如何在没有框架的情况下进行。逐步解决问题;假以时日,您会惊讶于您独自能够完成多少事情。
小型/中型/大型测试金字塔
单元测试不是适用于所有代码或产品质量的一刀切解决方案。您永远不应该做出这样的承诺。测试小组率先提出了 小型/中型/大型测试大小模式 的概念;迈克·科恩的 测试金字塔 与它有惊人的相似之处。确保每个人都清楚单元测试所扮演的基本角色,但不要夸大其词。
设置持续集成
尽您所能,即使您必须乞求、借用或窃取,也要建立一个持续集成环境。如果必须,使用 shell 脚本和 cron
作业自己编写一个,即使它在您自己的工作站上运行。即使它最初不运行测试,能够确保代码可以构建(对于编译语言)并且程序可以随时启动也是传播单元测试文化的一个关键先决条件;如果代码一开始无法编译,单元测试几乎毫无用处。
如果您的团队还没有养成确保代码始终处于可编译状态的习惯,那么在推动采用单元测试之前,这可能是您需要赢得的第一场战斗。如果每个人都在完全独立的分支上进行开发,并且集成是在事后很长时间进行的,那么就由您自己秘密地执行集成工作。设置您自己的 git 存储库 从这些不同的分支中提取并集成它们。当人们看到您一直在做什么以及您帮助避免了多少麻烦时,您将获得信誉,这将对您大有帮助。
最大化可见性
确保其他人可以看到构建何时中断。通过在办公桌附近设置易于观察的监控设备,曾经对持续构建和测试漠不关心或持敌对态度的人员和管理人员改变了想法。这样做有效,因为当构建中断时,人们自然会开始提问(“那东西为什么又变红了?”),久而久之,它会对每个人的态度产生重大影响。关心我们能看到的问题是人的天性,因此让人们在出现问题时能轻松地看到问题。
监控设备可以是个人浏览器中的插件、位于中央位置的发光球体、显示构建仪表板的大型显示器屏幕、专门连接的交通信号灯,应有尽有。它应该足够显眼,以至于人们必须故意努力才能忽略构建的当前状态。
可见性辅助工具还可以增添趣味性。团队可以发挥想象力,以有趣且富有竞争力的方式展示其测试状态。谷歌的一个团队有一只拍打翅膀的企鹅,当他们的构建中断时,它会发出噪音并栩栩如生地动起来。当然,所有周围的团队都必须尝试找到同样好的东西。这一切都有助于传播信息。
同伙
最终,你将不得不与一些同伙联手,这些人不需要说服。你们将互相挑战和强化彼此的想法,并在需要面对阻力时互相提供精神支持。通过相互交流,发展你们的论点、方法、惯用语等。比任何潜在的批评者都更批判这些想法,但要互相礼貌和尊重。让彼此变得更好,最终你可能会让你的团队或公司中的其他人变得更好。
当试图说服一群人(在任何事情上)时,最简单的方法始终是从那些最接近同意你的人开始。一旦你让另一个人以你的方式看待问题,你就不再是一个独行侠,不再是那个有古怪想法而没有人相信的疯子,现在有两个人在进行说服。一旦你得到第三个人,然后是第四个人,你就会有一些动力。
让其他人参与的另一种微妙而有效的方法是寻求建议。如果你的团队中有人抵制测试,甚至只是不熟悉测试,请那个人审查你的代码和测试。询问是否还有你没有想到的其他测试。大多数程序员都乐于提供意见,这是一种让他们参与测试而不强迫他们的方式。久而久之,他们可能会被说服,自愿倡导单元测试。
教育
找到一种方法在你的团队中传播知识。它可以像每周一次的便当午餐一样简单,也可以像在浴室里张贴每周传单一样疯狂。邀请人们对你的团队发表讲话,或组织团队外出参加演讲或聚会。启动一个内部邮件列表来分享和讨论想法和工具。
授权,授权,授权!
矛盾的是,你做的事情越少,你就能做更多的事情。如果你能确立愿景和方向,你就会发现有志愿者非常乐意承担具体的角色并执行,这会让他们在你所建立的社区中产生归属感和价值感,并让你可以专注于全局。
在运行了几次修复后,我意识到与其自己承担所有责任,不如创建一个明确的角色列表,让别人来填补。从那时起,预先提供角色列表就像一个灵丹妙药,可以非常、非常快速地建立一个草根组织。你现在可以为你的团队或组织考虑一些角色(其中一些名称故意很傻,以保持轻松和有趣)
- 历史学家:记录、总结和归档重要的议题或活动及其工件,并将其存放在一个中心化的可访问存储库中(例如 wiki 或团队博客)
- 信息部长:亲自征求人们发表演讲、撰写博客文章、文章等;这个人随后可以领导一个由演讲者、作者和志愿者编辑组成的子社区(类似于马桶上的测试),甚至可以培养一个社区特定的知识库(例如使用 wiki)
- 宣传部长:通过各种媒体监督团队活动的公告,例如电子邮件、传单、醒目的墙面投影、提供给高级经理、高管或其他代表的脚本等
- 通信部长:监控团队可用的通信渠道的健康状况,提出并实施改进措施(与信息部长一起);可能维护联系信息列表和工件存档(与历史学家一起)
- 文字匠人:专门处理新工件的维护和组织,例如确保帖子已标记,可能尝试使用 CSS 样式,执行 SEO 工作以确保搜索引擎可以轻松发现内容(如果工件是公开的)等
- 调度员:跟踪物流,例如谁在何时讲话,活动在哪里举行;维护合适的场地列表并寻找新的场地等
- 节日负责人:对于活动,确保啤酒、披萨和任何要分发的物品都已安排妥当。
- 心灵和灵魂:与演讲者、作者或其他贡献者和嘉宾跟进,并以各种形式代表团队亲自表示感谢:个人电子邮件、礼品券、小礼物、小型派对等
这些只是我想到的几个,但我想让你注意一些事情:现在,你可能正在承担所有这些角色,无论你是否意识到。对于一个人来说,这有很多事情要做,它既会拖累你,也会错过一个重要的机会,让团队成长为一个真正的、诚实而善良的社区。
成为海象
那么,在将所有事情委托出去之后,你的角色是什么?我称自己为“海象”,因为我是一个愚蠢的披头士狂热分子,但这个角色的本质是“组织者”。你是关注大局并管理专家团队的人。你是设定方向和优先级的人,你有权向你信任的重要责任的创意人员提供反馈,并帮助他们消除遇到的任何障碍,并且能够不断地惊叹于人们为完成任务而带来的能量和创造力,做你从未梦想过的事情。
拥抱团队合作的力量
坚持所有其他角色只会阻碍你作为组织者发展的能力,而这反过来又会阻碍社区发挥其全部潜力。因此,我鼓励你列出你已经为社区所做的事情清单,将它们编纂成一组角色,并积极参与你认为最适合每个角色的个人。
我有时甚至会制作一份角色名称列表,我的名字旁边用粗体红色字体标注,并告诉每个人,企业的成功与我的名字旁边仍然用红色标注的角色数量成反比。(唯一一个名字旁边用绿色标注的是“海象”。)当面对这样的列表,以及明确的角色时,他们会多么迅速地自愿行动,真是令人惊讶。
也就是说,应该鼓励担任此类角色的人进行互动,而无需通过你做出每一个决定;角色有助于明确职责,以便你不必参与每一个小细节,人们可以在他们之间解决许多事情。应该鼓励每个人寻找好主意,发展好主意,并在他们之间分享。当然,你应该随时了解情况,但人们应该觉得你在倾听,而不是在偷听或试图成为他们的老板。期待他们给你带来惊喜,他们会做到的。
让自己过时
从第一天开始寻找你的替代者。任何企业都不应该如此脆弱,以至于在你离开后就分崩离析。这适用于一般的生活。关于传播单元测试文化,你不想被贴上“测试人员”的标签。你希望确保人们在你需要或想要退居二线时愿意并能够挺身而出。这就是建立遗产的方式。
运行修复
说到角色和修复,一种有趣且富有成效的方法是集结你正在建立的社区来推广单元测试,那就是运行修复。你可以从一个小型团队规模的修复开始,然后在整个办公室或甚至整个公司范围内运行事件。你开始所需要的是一个明确的目标(例如修复所有损坏的代码/测试,将覆盖率提高 X%,采用一些漂亮的新工具),一组定义明确的志愿者角色(如上所述),以及某种共享电子表格来跟踪需要完成的任务以及分配给谁来处理每个任务。然后选择一天,传达信息,然后让它发生!没有什么能比得上应用一致的团队努力来提高士气和解决讨厌的、挥之不去的问题了。
以下是一个示例,说明修复不仅有趣且富有成效,而且可能对事业至关重要,考虑一个大型项目处于这样一种状态,其中一些部分甚至无法编译。这完全破坏了建立持续集成的任何努力,并鼓励人们开始选择他们要测试的项目分支,而不是在提交更改之前尝试测试所有内容。换句话说,他们将执行<tool> test subprojectyBitA/**/* anotherBitB/ohAndThis/**/* partC/**/* ...
而不是<tool> build **/*
或通过其他更合适的选定条件(如测试大小)进行测试。因此,慢性问题可能会变得更糟,并且持续集成仍然遥不可及。
这种情况非常适合修复:可以预先识别代码中的损坏区域并将其编译到电子表格中;然后,人们可以自愿处理特定的损坏以避免重复工作。团队可以在一个专门的冲刺中解决这些问题,可以使活动变得喜庆有趣,并且代码将在一天结束时处于有利于持续集成和测试的状态——希望如此。即使并非所有问题都能立即得到解决,团队也应该受到为解决慢性问题而取得的切实进展的鼓舞,并且应该获得一些见解,这些见解将激励他们最终完全解决问题。
相信自己
改变开发文化不是一个公式化的事件;我们不能仅仅插入正确的数据并获得所需的结果。也许有一天,某人会进行一项正式的学术研究,并收集普遍同意的指标,为单元测试的有效性提供合理的论据,但即使那样也不能保证人们会听取并改变他们的行为。通过类比,科学研究证实了医生洗手可以预防感染,这项研究已经进行了超过一个世纪,并且研究至今仍在继续。尽管有如此大量的证据,一些医生仍然需要被提醒为他们的患者洗手。
尽管在行业历史的这个阶段缺乏正式的研究,但(良好)单元测试带来的长期收益是可以观察到的现象,并且已经多次被复制。许多团队已经收集了缺陷率和其他因素的数据,他们认为这些因素反映了质量和生产力的提高;在下面的衡量、执行、努力小节中考虑了这些想法。一旦你获得了单元测试的经验,特别是如果你在另一个团队或另一家公司取得了成功,那么依靠“在我的经验中”的论点并没有什么错。你的经验难道不是你的团队或公司雇用你的一个重要原因吗?表面上,他们一定在某种程度上重视这种经验;不要低估它。虽然我建议“回避权威”,但你并不是说“因为我说”,而是说“因为我做过”,而且你可以指出你的具体努力和成就。大不同。
这是因为从经验的角度进行论证并不是信口开河,只要你有实际经验可以指出。例如:说“goto fail”和 Heartbleed 可以通过单元测试检测出来可能是信口开河;生成可行的概念验证代码则不是。说你的代码“太难测试”是信口开河;说它不必一直这样,因为 Google Web 服务器从可怕的陈旧变成了运转良好的服务器,这要归功于严格的单元测试实践,则不是。
拥有良心和一定程度的谦逊,并注意不要夸大单元测试或一般自动化测试的好处,这是很可爱的,但不要忘记相信自己。没有信心,良心和谦逊都是无用的,而信心最终是经验的函数,而不是数据的函数。
保持专注
无论是为了推动单元测试采用还是实现其他结果,文化变革的最终目标都是以某种方式让每个人的生活变得更好。单元测试是一种手段,其目的是减少缺陷、提高生产力和业务成功,所有这些都有望转化为开发人员和用户的快乐。当我们深入专注于技术、战术或战略讨论时,很容易忽视这一点。讨论这些事情很重要,但至少同样重要的是将单元测试与其长期利益之间的关联保持清晰。
为此,请务必与参与这项工作的所有人以及您尽可能尝试影响的人员联系。无论以何种非正式的方式进行——在喝咖啡或午餐时,在团队会议和代码审查期间发表评论等——养成抓住每一个机会征求反馈并纠正课程的习惯。培养这种习惯还会微妙地影响人们更多地思考单元测试问题和机会,随着时间的推移,慢慢地向所有人开放更大的变化。
培养指尖感觉
Fingerspitzengefühl 是起源于军事背景的一个术语,它暗示着极端的态势感知。了解如何感知谁在何处何时处理和做什么。您不必自己做所有事情,但您需要了解正在发生的事情,以便更好地指导您的团队和社区执行的行动。鼓励其他人培养同样的意识,保持对机会的开放和敏感,并在时机成熟时抓住它们。
让策略浮现
一开始没有宏伟的战略并不重要,但要尝试建立一个社区,使其能够在出现时采取行动。事实上,我认为一开始最好的策略是专注于建立社区,而不是担心社区有一天会完成什么。当正确的策略出现时,人们已经培养了他们的指尖感觉,那么社区自然会知道如何实施它。(我的参考当然是,测试小组在宏伟、统一的测试认证策略出现之前,对各种想法进行了多年的讨论。)
也就是说,寻找显示出希望的重点领域是好的。高可见性软件错误和开发项目失败是很好的错误。就像我使用“goto fail”和 Heartbleed 所做的那样,尽可能具体和深入地使用它们来说明单元测试的价值。逐案建立,并培养社区对这些机会的敏感性。鼓励社区成员撰写博客文章和演讲——甚至可以带上一些参加巡回演讲,在当地的聚会、各种公司和会议上发表演讲。(毕竟,如果不是非常本地化的定期会议,聚会是什么呢?)找到有前途的演讲者和作者,并温和地鼓励他们做得更多。
如果这个特定的角度不吸引你,那就找到另一个合适的、有希望的线索,并不断拉动它,看看它能走多远。
找一位导师
当你尝试将所有这些整合在一起时,拥有一位你可以倾诉心声并征求建议的导师总是非常非常有帮助的。询问任何你认为可以为你提供可靠指导的人;他们可能不感兴趣或没有时间,但询问一下既便宜又轻松。很多时候,人们会感到很荣幸,并会欣然接受;或者如果他们不能,他们可能会推荐其他人并安排介绍。
无论哪种方式,都要伸出你的触角。不要试图独自坚持所有这些。
顺应自然,而不是逆其道而行
充分了解你的受众。不同的人对不同的说服形式有反应;有些人永远不会被说服,必须被历史拖着走。一次影响一个人,通过为他们提供最深刻共鸣的见解和经验。如果有常见的借口,解决根本问题。如果“我没有时间测试”,那么让工具更快;如果“我的代码太难测试”,那么提供信息和示例,说明如何使其更容易测试。自愿与某人合作,为一段棘手的代码编写测试。说服总是比强制好,但寻找任何机会,在有机会时抓住机会。
一次一个团队
尽管测试小组的目标是改变整个 Google,但该目标最终是一次改变一个团队实现的。测试认证计划提供了一个逐步计划,以提高单元测试实践和单团队级别的覆盖率,并提供导师来帮助每个团队回答问题并取得进展。试图从上到下改变整个公司很可能注定要失败,即使在短期内似乎成功,也不太可能持久。
衡量、执行、努力
任何开发方法面临的挑战之一是衡量其有效性。怀疑者可能会理所当然地要求你“证明”投资于测试是值得的。就像任何其他开发选择(例如使用哪种语言、框架或 IDE)一样,有许多因素使得难以清晰地衡量这一点。相反,衡量团队根据其当前目标和问题认为有意义的内容。可能无法实现严密的指标,但你可以使用任何合理代理来取得进展,人们不太可能为了个人利益而玩弄这些代理。例如,团队可能有一组希望在本季度交付的功能;它可能希望降低报告缺陷或紧急发布的频率;或者它可能希望增加定期发布的频率。同时,制定一个类似于测试认证的计划来收集测试指标、制定政策并努力实现测试目标。随着时间的推移,类似于测试认证的目标的进展应该与其他团队目标的进展相关。在逻辑层面上,相关性不能证明因果关系,但在经验层面上,应该理解单元测试产生了积极影响。
很难衡量所有未发生的问题,因为它们被测试捕获,或者开发人员在选择和编写测试时发现的所有问题。你可以衡量发布周期速度、回滚频率和报告错误等副作用,但在没有进行测试的项目的开始阶段,你是在凭信心获得很多好处。在某些团队中,对单元测试进行几个月的试验,然后进行开发人员调查(“你是否觉得代码变得更健康?你是否不太担心你的更改会破坏生产中的某些内容?你在编写测试时会发现错误吗?”)可能会向持怀疑态度的管理层证明测试对团队有帮助。
表明立场
如果人们驳回你的结果并要求提供单元测试功效的进一步证据,那么请指出开发人员信心、生产力和幸福感增加的程度,以及如果此类数据可用,用户幸福感也会增加。怀疑论者是否可以衡量这些相同的结果,以及最终交付给客户的价值,而与其他因素无关?编程语言选择?代码编辑器选择?节日派对、会议、外出和奖金?如果我们打算对测试缺乏证明其实践和确认其价值的硬数据而挑剔,那么让我们坚持到底,并产生对所有这些其他技术和业务决策进行的学术研究,以及展示其影响的无可争辩的经验证据。
关键在于,许多因素促成了一个成功的团队、产品和业务的产生。并非所有因素都完全可衡量,但这并不是不采用它们的理由,因为我们有一种感觉,即效果的总和大于部分的总和。在这方面,单元测试与许多其他业务因素并无不同。
如果人们试图声称单元测试不起作用,因为他们的情况“不同”,那么请站起来反对这种毫无根据的驳斥。你可以从一个强有力的立场出发,认为有许多、许多不同的人拥有许多、许多不同的经验,他们都会证明单元测试的有效性。这些人、团队、产品和经验之间的差异强化了单元测试有效性的论点,而不是削弱了它。克服任何“差异”给单元测试带来的挑战的唯一要求就是尝试的勇气。不要放过任何机会,揭露伪装成理由的懦弱。
冗余并重复自己
并非每个人都会对你的第一组论点做出回应。不同的人对不同的解释会有不同的反应。进行实验,找到向不同的人传达你想法的最佳方式。不断重新整理你的想法,找到让它们吸引新受众的方法。
登月
从实现短期目标的渐进步骤开始,但不要害怕设定宏伟的目标,并在时机成熟时朝着它们迈进。测试小组从举办演讲、制作内部培训材料和分发书籍开始。最终,它变得更加大胆,推出了“马桶上的测试”并运行“测试修复”。经过几年的试验,宏伟的“测试认证”策略才得以实施,并且在“TAP”完成这项工作之前,又花了数年时间。我们的成功首先是通过招募一小群热情的志愿者实现的;共同努力建立一个牢固的社区和工具和实践的基础;然后设定一个极其激进的目标。
你的努力应该遵循类似的弧线。一旦你有了足够多的队友加入,并且为你的团队制定了衡量标准、政策和目标,你就有了“地球人”。现在,你可以把目光放得更高。你可以为你的团队组织一个修复,或者可能是你办公室的几个团队,保留一个电子表格,其中列出了目标、任务以及分配给每个任务的人员。在成功举办了一场以单元测试为重点的活动后,你现在有了“轨道人”。反思那次活动中的经验教训,并利用它产生的势头,你就可以尝试“登月”并改变你的整个公司。
一旦你的公司变得时髦,也许你就有机会“登陆火星”并改变整个行业。这是值得思考的事情。
坚持不懈
说服人们相信单元测试的价值并不总是那么容易。依靠你的支持网络;依靠他们来获得支持和缓解。你不需要独自战斗。委派。在需要时让其他人带头,同时你充电。当有人需要依靠你时,准备好退后一步。
坚持到底
即使你实现了最雄心勃勃的文化变革目标,这项工作实际上永远不会完成。健康的文化需要警惕的维护,在建立单元测试文化之后,下一步的巨大步骤就是教授良好的自动化测试美学。这不仅限于单元测试级别;应充分利用单元、集成和系统测试的全部范围,以确保代码质量高,并且需要教育人们了解每种测试级别的适当应用和最佳实践。记住,人们可能会被说服采用自动化测试,但这并不能保证他们会做得很好。
如果没有指导和反馈,人们可能会编写针对组织不当的代码或编写得很差的 API 的极其精细、复杂、难以维护的测试。重量级的集成规模测试可能会检查一些微不足道的事情。人们可能会编写成本高、价值低且自己都没有意识到的测试,例如由于检查错误的内容(例如测试特定像素是否为蓝色,而不是检查页面是否已完成加载)而失败的测试,或具有不必要的冗余的测试。总有一些人会把一个好主意做得太过火,或者朝着一个坏的方向发展。这并不意味着这个想法没有价值,只是说没有一个独立的想法能够不受滥用的影响。
一套编写良好的自动化测试可以是一个强大的工具,可以确认你的代码按预期工作,并且它组织得井井有条且解耦(这要归功于自动化测试实践施加的设计压力)。但是,自动化测试与任何其他技能一样,都需要时间和精力来开发,并且总有改进的空间。目标绝不是不惜一切代价实现完美的测试覆盖率,而是尽可能高效、有效和可靠地进行开发。一旦你让人们相信了自动化测试的价值,就帮助他们不断提高他们的技能,以确保他们的测试有助于实现这一目标。
奖励和认可
生活中很少有事情比将你的生命倾注到重要的事情上,做出改变,却让这种努力被忽视更令人失望的——或者更糟的是,让别人因此而获得荣誉。不要让这种情况发生在你与你一起实现真正变革的人身上。不要大肆宣扬,但要养成让别人知道他们的努力得到认可和赞赏的习惯。
让它变得有趣
永远不要低估乐趣在实现雄心勃勃的目标中所发挥的力量。乐趣减轻了负担,并使人们彼此联系在一起。乐趣创造了伟大的故事,你会喜欢讲给你的孙子孙女——或者至少是新加入你的团队或公司的新初级开发人员,即使在你完成这项工作很久之后。
最后的想法
如果单元测试不需要任何额外的工具,不需要用一种新语言重写所有内容,可以增强其他工具和实践的应用,可以逐步应用于现有代码,产生的成本不高于学习任何其他新技术或产品领域,已经成为世界上最复杂开发操作之一的预期文化规范,并且能够检测或防止可能通过所有其他可能的保障和测试层而出现的灾难性错误,那么最大的问题是
为什么单元测试不是每种开发文化的一部分?
有些程序员和团队根本不知道单元测试及其对他们有什么好处,缺乏这方面的经验,或者需要帮助才能开始。希望本文能提出有说服力的论据,说服他们采用单元测试实践。作为一个鼓舞人心的例子,OpenSSL 项目本身已经接受了我帮助增加单元/自动化测试覆盖率的提议,并且我正在积极招募人员来帮助这项工作。
除此之外:开发人员忽略编写单元测试,因为他们的团队或公司充其量只是容忍没有测试,最糟糕的是积极阻止他们进行测试。此类团队经常声称他们“没有时间进行测试”,或者他们的代码“太难测试”。这可能是由于故意的无知、冷漠、糟糕的过去经验、公司激励结构和压力,或刻板的牛仔编码大男子主义。无论主要动机是什么,其效果都是维持现状,为轻松逃避找借口。“出现错误”是因为接受它作为既定结论比改变自己的习惯或周围文化要舒服得多。而且公众愿意接受这个借口也很方便,因为他们没有合理的方法来了解得更多。
作为开发人员,是信息不足的公众寄予(可以说是不应得的)信任的看护者,我们能够也必须做得比这更好。轻松、方便的借口可能帮助我们所有人摆脱争议的不适,但它们并没有取得任何真正富有成效的成果。
如果我们不克服采用单元测试的文化障碍,我们就会阻止将最有效的开发工具之一应用于防止昂贵、尴尬和潜在危险的软件缺陷的真正挑战。通过让对其他程序员的同情心(以及对自己被评判的秘密恐惧)蒙蔽我们的判断力,导致我们得出快速而舒适的结论,将原本可以预防的缺陷视为理所当然,我们与在没有单元测试的情况下产生此类缺陷的任何程序员、团队或公司一样有罪。
对于技术故障,我们可以提供的最不切实际的解释就是“文化”,特别是对于没有软件开发背景的用户——因此媒体热衷于接受它可以理解并据此撰写故事的原因,因为理解力提供了对事件的控制错觉。文化也是我们能够给自己提供的最不舒适的理由,因为承认文化失败会引发基于身份的心理冲突,而指出外部因素在很大程度上可以避免这种情况。然而,回避这个令人不快的真相就是继续容忍产生“goto fail”和 Heartbleed 以及它们对在很大程度上不知情、信任的社会造成的损害的过度自信,而这个社会越来越依赖软件来高效、安全地开展业务和生活。
仅靠洗手并不能成为一名医生,也不能挽救患者,但我们不会信任不洗手的外科医生。作为软件开发人员,我们应该代表我们的用户承担类似的注意义务。没有人应该信任未经单元测试开发的软件。
进一步阅读
我的博客mike-bland.com有很多关于goto fail和Heartbleed漏洞的先前工作。最直接影响本文的先前工作列在我的Goto Fail、Heartbleed 和单元测试文化页面上。
我还广泛地写过单元测试在 Google 中产生的差异,并在我的“捕鲸”系列中描述了 Google 的许多开发和测试工具和流程。
在我参与测试小组、修复小组和测试雇佣军时,影响我思维最深的一本书是索尔·阿林斯基的激进分子的规则。我特别喜欢对他的观点进行这样的改写:如果人们不相信自己有能力解决问题,他们甚至不会考虑尝试解决问题。测试小组等组织的全部意义在于赋予 Google 开发人员解决代码质量问题的权力,并以一种非常规的自下而上的方式提供这种权力。
我也喜欢(并且仍然喜欢)罗伯特·格林的史诗巨作权力的 48 条法则和战争的 33 种策略。格林就像现代的马基雅维利,他的文笔既有令人印象深刻的学术广度和深度,又毫不掩饰地具有操纵性。我一直认为操纵性方面过于夸张,是为了娱乐和制造震惊效应;对人性的深刻洞察对于任何想要承担改变人心和思想的任务的人来说都是极其宝贵的——尤其是因为这些书为保护你自己的心和思想提供了极好的建议。
罗伯特·西奥迪尼的影响力:说服的心理学是一本非常清晰的书,解释了为什么我们经常在冲动之下屈服于说服,并在事后感到后悔。它提供了识别某人是否正在潜意识层面积极尝试影响你的工具——你可能会发现这些工具对你改变自己的环境很有用。由“说服专业人士”采用的信任机制通常使我们能够作为一个物种生存和发展,但很容易被利用来作恶或行善。阅读本书将为你提供一个广阔的框架,在其中格林的教训将变得更加有意义。
杰弗里·A·摩尔的跨越鸿沟表面上是关于如何识别技术市场的不同细分市场,直接向他们推销,以及如何避免陷入“鸿沟”,从而使原本有前途的技术产品走向灭亡。这些教训同样适用于了解软件开发社区的不同细分市场将如何对不同的说服方法做出反应。换句话说,分而治之,忘记“落后者”。他们最终会被拖累的。
孙子的《孙子兵法》教导我们如何顺应自然和地势,而不是与之对抗。最重要的是,它强调“不战而屈人之兵,善之善者也”。
回到技术轨道,David A. Wheeler 的如何防止下一次 Heartbleed深入探讨了未来防止类似错误的多种技术方法。他指出了“负面”单元测试的价值,并强烈建议许多开发人员实际上并没有考虑为预期的失败场景编写“负面”测试用例。
Sean Cassidy 的沉思录,灵感来自罗马皇帝马可·奥勒留的沉思录,是一系列值得阅读和定期重读的软件开发原则。单元测试和其他形式的自动化测试补充了许多原则。例如,“保持积极的开销”:单元测试是开销,但可以节省大量潜在的调试时间(以及失眠、失去信任、失去收入……)。
我学到的关于单元测试的大部分知识都来自个人经验以及我参与测试小组,因此我很难推荐有关单元测试的特定书籍,因为我从各个地方吸收了知识片段。话虽如此,马丁以写过一两本好书而闻名,包括他的经典之作《重构:改善现有代码的设计》。其中的思想如此有影响力,以至于如果你从其他地方了解到它们,很难相信它们都源自这本著作。我听说过很多关于 Gerard Meszaros 的《xUnit 测试模式》的好评,它作为马丁签名系列的一部分出版。我的波士顿自动化测试聚会同事 Stephen Vance 在我写这篇文章的几个月前出版了《质量代码:软件测试原则、实践和模式》。公开提供的马桶上的测试剧集也是一个非常有用的自动化测试智慧来源。
归根结底,测试的目的是帮助我们编写良好的代码,因此即使不专注于单元测试,阅读有关良好编码和设计实践的书籍也有很大的价值。事实上,将来自多个来源的想法应用到自己的代码中,并使它们全部融合在一起,而不是期望一两本书告诉你所有内容,这样做是有道理的。为此,作为一名新兴的 C++ 开发人员,我花了很多宝贵时间阅读 Herb Sutter 的Exceptional C++系列;Scott Meyers 的Effective C++系列;Bjarne Stroustrup 的The C++ Programming Language;Brian Kernighan 和 Dennis Ritchie 的The C Programming Language;Cormen、Leiserson 和 Rivest 的Introduction to Algorithms(在 Stein 成为合著者之前);以及前面提到的Gang Of Four一书,又名Design Patterns: Elements of Reusable Object-Oriented Software。我的 Northrop Grumman 队友取笑我每天都用一个运动包带去工作的“图书馆”。我沉迷于所有这些书中,不仅开始将他们的想法应用到生产代码中:我还开始对正在编写的新代码进行单元测试。得益于新兴单元测试提供的快速反馈,这种体验帮助我更深入地吸收了这些信息,并让我永远相信单元测试的价值。
致谢
尽管这篇文章署名是我的名字,但它是几十个人慷慨而细致努力的成果,其中一些人甚至应该分享合著者荣誉。
不过,我必须说,他们中的许多人都对这篇文章的长度负责。尽管预期我写得太多了,我的审阅者会帮助我缩短它——公平地说,有很多地方他们帮助我大幅精简了语言——但他们的许多热情而有见地的建议我都完全采纳了。
查尔斯·巴洛维向我提出质疑,让我澄清我的观点,即虽然单元测试用例设计应由接口契约驱动,但人们应利用实现知识探查临界情况和错误处理中的弱点。他提出了“错误测试”示例,并提醒我在引言中明确我的“验尸/回顾”意图。托尼·艾乌托向我展示了“指针编码”技巧,我最终在“goto fail”单元测试中使用了该技巧。约翰·佩尼克斯提醒我,谷歌的“资深人士”认为他们在测试前文化中做了一切正确的事情,并仔细检查了我对 TAP 的说法。克里斯蒂安·肯珀确保我对谷歌的评论与我在那里时的相关时间有关,以避免在我离开后出现的发展情况导致混淆。约翰·图雷克向我提出质疑,让我澄清何时需要在测试必然“镜像”实现(很少,在最低级别)与何时不需要(大多数时候)之间划清界限,并澄清单元测试应在编写代码时进行,而不是之后。斯蒂芬·吴向我提出质疑,让我澄清我在引言、“goto fail”和“谷歌改造”部分中希望呈现的确切态度和想要提出的论点。斯维尔·桑德斯达尔建议充实“单元测试如何提供帮助?”部分中的具体原则,并强调 TAP 的强大功能。亚当·索耶帮助我避免了读者可能从“goto fail”部分得出的意外政治结论。亚历克斯·布奇诺澄清了 RelEng 对过去、现在和未来所有 RelEng 的自动化测试的看法。里奇·马丁几乎逐字逐句地为我提供了“工具”部分的“工艺”段落,以及“同伙”和“提高可见性”的第二段。亚历克斯·马特利指出了“工具”部分引言中出现的并发问题单元测试的难度。肖恩·卡西迪提醒我,文档值得包含在“工具”中。莉莎·凯莉指出,难以记录系统通常表明其设计存在问题。杰西卡·托梅查克指出了代码审查对文档的好处。安娜·乌林对离开谷歌及其在新公司的经历的看法促使我制作了“如何改变文化”部分,并且我添加了“保持专注”作为她原始想法的重写。帕特里克·多伊尔除了激发“跟进”小节外,还为“如何改变文化”部分贡献了许多好主意。亚当·威尔达夫斯基在整篇文章中的评论特别透彻,提出了语法改进建议,质疑了我的一些论点,并给了我额外的材料供我纳入。
上述许多人也对文章的其他方面提出了许多其他非常有帮助的评论;我只想对他们一些最突出的贡献给予公平的评价。
其他对特定部分或整个文档提供广泛评论的人包括:肯德拉·柯蒂斯;阿兰·多诺霍;艾伦·多诺万;克里斯·乔治;拉里·霍斯肯;罗伯·科尼格斯伯格;克里斯·罗尔斯;格雷戈尔·罗斯福斯;马特·西蒙斯;安德鲁·特伦克;格伦·特雷维特;吉恩·沃洛维奇;万展勇;科尔·威利斯。
我也感谢其他审阅并提供反馈和/或批准的人:亚历克斯·艾齐科夫斯基;杰森·阿本;戴夫·阿斯特尔斯;安德鲁·博耶;RT 卡彭特;马蒂厄·加涅;克里斯·乔治;约瑟夫·格雷夫斯;保罗·哈曼特;马克·艾维;布莱恩·金尼;塔耶布·卡里姆;卡米洛·阿兰戈·莫雷诺;布莱恩·奥肯;大卫·普拉斯;C. 基思·雷;史蒂夫·希里帕;艾萨克·特鲁特;斯蒂芬·万斯。
除了直接帮助我撰写本文的人员之外,还有更多的人帮助改变了 Google 的开发文化,而这正是本文和其他我撰写的关于此主题的文章的基础。我已经尽我所能,在我的博客文章中给予他们应有的赞誉。他们包括测试小组成员、测试雇佣军、测试认证导师和团队、在厕所测试维护人员和贡献者、测试技术团队、构建工具团队以及前工程生产力部门的成员。除了这些小组之外,还有许多志同道合的人也以某种方式为这项事业做出了贡献。我特别想点名以下人员(如果您认为您的名字应该或不应该出现在此列表中,请告诉我,我会相应地进行更新)
Adam Abrons;Ulf Adams;David Agraz;Mohsin Ahmed;Tony Aiuto;Alex Aizikovsky;Vishal Arora;Dave Astels;Venuprakash Barathan;Milos Besta;Jennifer Bevan;Tracy Bialik;Carla Bromberg;Dennis Byrne;Michael Chastain;Araceli Checa;Deanna Chen;Dianna Chou;Alex Chu;Kevin Cooney;Patrick Copeland;Jay Corbett;Bradford Cross;Kendra Curtis;Pavithra Dankanikote;Kelechi Dike;Alan Donovan;Patrick Doyle;Peter Epstein;Ambrose Feinstein;Simon Quellen Field;Daniel Fireman;Ariel Garza;Nachum Goldstein;Nikhil Gore;Brad Green;Misha Gridnev;Christian Gruber;Paul Hammant;Matt Hargett;Johannes Henkel;Johannes Henkel;Miško Hevery;Gregor Hohpe;Jason Huggins;Susan Hunter;Mark Ivey;Ralph Jocham;Emily Johnston;Michał Kaczmarek;Tayeb Karim;Nitin Kaushik;Christian Kemper;Maria Khomenko;Wolfgang Klier;Erik Kline;Damon Kohler;Rob Konigsberg;Nicolai Krakowiak;David Kramer;Archana Krishna;Deepa Kurian;Jonny LeRoy;Mike Lee;Flavio Lerda;Nick Lesiecki;Michelle Levesque;Kimmy Lin;Mindy Liu;Chris Lopez;David Mankin;Alex Martelli;Rich Martin;Thomas McGhan;Jim McMaster;Bharat Mediratta;Boyd Montgomery;David Morganthaler;Sam Newman;Steve Ng;Eric Nickell;Robert Nilsson;Neal Norwitz;Andy Watson Orion;Rong Ou;John Penix;Rob Peterson;Antoine Picard;James Pine;David Plass;Rachel Potvin;Simon Pyle;Kevin Rabsatt;C. Keith Ray;Tim Reaves;Thirumala Reddy;Mamie Rheingold;Phil Rollet;Gregor Rothfuss;Russ Rufer;Thomas Rybka;Nick Sakharov;Diego Salas;Thiago Robert Santos;John Sarapata;Steve Schirripa;Eric Schrock;Roshan Sembacuttiaratchy;Meghan Shakar;Craig Silverstein;Matt Simmons;Dave Smith;Matthew Springer;Kurt Steinkraus;Bill Strathearn;Mark Striebeck;Cristina Tcheyan;Jean Tessier;John Thomas;Jessica Tomechak;Andrew Trenk;Glenn Trewitt;John Turek;Scott Turnquest;Ana Ulin;Matt Vail;Gene Volovich;Zhanyong Wan;Lindsay Webster;Chris Van Der Westhuizen;Nicolas Wettstein;Adam Wildavsky;Collin Winter;Jonathan Wolter;Julie Wu;Kai Xu;Runhua Yang;Noel Yap;Jeffrey Yasskin;Catherine Ye;Nathan York;Paul Zabelin;Henner Zeller。
正如我常说的,没有一个超人能挥动魔杖让事情发生。我们所有人经过多年的共同努力,才让变革得以发生。然而,它确实发生了。在这个世界上,没有比团队合作更强大的魔力了。
当然,感谢 Martin Fowler 让我无法拒绝。我最初请求他帮助我审阅我的《ACM 队列》文章“在苹果中发现不止一条虫”。他最终让我撰写了这篇文章,并定义了文章的范围和结构,这远远超出了我最初认为他要求的范围。最终产品是他愿景和指导的直接结果,是我自己绝对无法完成的。
重大修订
2014 年 6 月 3 日:发布如何改变文化和最终想法部分以及附录
2014 年 5 月 29 日:发布 Google 部分
2014 年 5 月 27 日:发布其他有用的工具和实践
2014 年 5 月 20 日:发布成本和收益部分
2014 年 5 月 14 日:发布 Heartbleed 部分
2014 年 5 月 12 日:发布简介和 goto fail 部分