[转载] 写出优秀的单元测试:最佳与最差实践
原文出处: https://blog.stevensanderson.com/2009/08/24/writing-great-unit-tests-best-and-worst-practises/
作者: Steve Sanderson
原文发布日期: 2009-08-24
许可协议: 原文未声明显式开源协议,本文为其中文翻译,所有权利归原文作者所有。
一个好的单元测试和一个坏的单元测试,差别在哪?怎么才能学会写好的单元测试?这事一点都不显然。就算你是写了几十年代码的高手,你现有的知识和习惯也不会自动让你写出好的单元测试——因为这是另一种写代码的方式,而大多数人一开始对「单元测试到底是为了什么」就带着没什么用的错误假设。
我见过的大多数单元测试都没什么用。我不是在怪开发者:通常只是有人跟他说「开始写单元测试吧」,于是他装上 NUnit,开始批量产出 [Test] 方法。看到红灯绿灯一闪,就以为自己做对了。这个假设很要命。写出糟糕的单元测试简直太容易了——它们给项目带不来多少价值,却能让改代码的成本膨胀到天上。 你觉得这听起来敏捷吗?
单元测试不是为了找 bug
话说回来,我是单元测试的坚定支持者,但前提是你得清楚单元测试在 TDD(Test Driven Development)流程里扮演什么角色,并且彻底打消「单元测试是用来找 bug 的」这种误解。
按我的经验,单元测试并不是找 bug、查回归的有效手段。单元测试,顾名思义,是把代码的每个单元分开来检验。但应用真正跑起来时,所有这些单元得一起协作,整体比各部分单独测试之和要复杂、微妙得多。你证明了组件 X 和 Y 各自都能跑,并不代表它们彼此兼容、配置正确。而且单个组件里的缺陷,跟最终用户遇到并上报的现象可能毫无关系。再者,单元测试的前置条件是你自己设计的,所以你根本不会预见到、也就测不出那些由意外前置条件触发的问题(比如某个你没料到的 IHttpModule 横插一杠子干扰了进来的请求)。
所以真要找 bug,把整个应用按生产环境那样完整跑起来要有效得多——就像你手工测试时自然而然做的那样。如果你把这种测试自动化,好在未来出现破坏时能及时发现,这就叫集成测试(integration testing),它用的技术和工具跟单元测试通常不是一回事。难道你不想每件事都用最趁手的工具吗?
| 目标 | 最强手段 |
|---|---|
| 找 bug(东西没按你想要的方式工作) | 手工测试(有时也用自动化的集成测试) |
| 查回归(以前能用、现在莫名其妙坏了) | 自动化的集成测试(有时也用手工测试,但费时) |
| 健壮地设计软件组件 | 单元测试(在 TDD 流程里) |
(注:有一个例外,单元测试确实能有效发现 bug——那就是你在重构的时候,也就是重新整理某个单元的代码、但并不打算改变它的行为。这种情况下,单元测试常常能告诉你这个单元的行为是不是变了。)
那单元测试不找 bug,到底是干什么的?
这个答案你大概听过一百遍了,但既然「测试是为了找 bug」这个误解在开发者脑子里阴魂不散,我还是把原则再讲一遍。就像 TDD 大佬们一直在说的:「TDD 是个设计过程,不是测试过程」。展开讲:TDD 是一种稳健地、交互式地设计软件组件(「单元」)的方法,让组件的行为通过单元测试被确定下来。就这样。
好的单元测试与坏的单元测试
TDD 帮你交付那些各自都按你设计的那样去表现的软件组件。一套好的单元测试极其有价值:它记录了你的设计,让你在重构、扩展代码时,还能对每个组件的行为保持清晰的把握。然而一套糟糕的单元测试极其痛苦:它什么都证明不了,还能严重妨碍你以任何方式重构或修改代码。
你的测试在下面这条轴上处于什么位置?

通过 TDD 流程产出的单元测试,自然落在轴的最左端。它们装着大量关于单个代码单元行为的知识——那个单元的行为变了,它的单元测试就得跟着变,反之亦然。但它们对你代码库的其他部分不包含任何知识或假设,所以其他部分的改动不会让它们开始失败(如果你的会,说明它们不是真正的单元测试)。因此维护起来很便宜,作为一种开发方法,TDD 能扩展到任何规模的项目。
轴的另一头是集成测试,它不关心你的代码库是怎么拆成单元的,而是对整个系统面向外部用户的表现作出断言。它维护成本也还行(因为不管你怎么改系统内部结构,都不必影响外部观察者),而且能有力地证明哪些功能今天确实在跑。
处在中间的任何位置,你都不清楚自己在做什么假设、想证明什么。重构可能会弄坏这些测试,也可能不会——跟最终用户体验是否还正常毫无关系;换掉你用的外部服务(比如升级数据库)可能弄坏它们,也可能不会——同样跟用户体验是否正常无关。对单个单元内部做任何一点小改动,都可能逼你去修几百个看似毫不相干的混合测试,所以它们往往吞噬大量维护时间——有时大概是你维护应用代码本身所花时间的 10 倍。更让人窝火的是,你心里清楚:加更多前置条件让这些混合测试变绿,其实啥也证明不了。
写出优秀单元测试的几条建议
空泛的讨论够了——来点实在的建议。下面这些建议,帮你写出稳稳落在上面那条轴「甜蜜点 A」的单元测试,同时在其他方面也更出色。
让每个测试与其他测试正交(也就是相互独立) 任何一种行为,应当在一个、且仅在一个测试里被定义。否则以后你改这个行为,就得改好几个测试。这条规则的推论包括:
- 别做多余的断言
你到底在测哪个具体行为?对别的测试已经断言过的东西再
Assert()一遍是有害无益的:它只会让无意义的失败更频繁,对单元测试覆盖率一点帮助都没有。多余的Verify()调用也一样——如果它不是被测的核心行为,就别再去观察它!TDD 圈的人有时会说「每个测试只有一个逻辑断言」。记住,单元测试是「某种行为该怎么工作」的设计说明,不是「这段代码碰巧做的所有事情」的观察清单。
一次只测一个代码单元 你的架构必须支持把单元(也就是单个类,或很小的一组类)独立地测试,而不是全串在一起。否则测试之间会有大量重叠,改一个单元引发的失败会向外连锁反应、到处炸。如果你做不到,那就是你的架构在拖你工作质量的后腿——考虑用控制反转(Inversion of Control)。
把所有外部服务和状态都 mock 掉 否则,那些外部服务的行为会跨多个测试重叠,而状态数据意味着不同的单元测试会相互影响彼此的结果。如果你非得按某个特定顺序跑测试,或者只有数据库、网络连着的时候测试才过,那你肯定走错路了。(顺便说一句,有时你的架构会让代码在单元测试里碰到静态变量。能避就避,避不开的话,至少保证每个测试跑之前把相关静态变量重置到一个已知状态。)
避免不必要的前置条件
别让一大段公共的 setup 代码在很多互不相关的测试开头都跑一遍。否则每个测试到底依赖什么假设会变得不清楚,也说明你测的不止一个单元。例外:有时我觉得,让很小一撮单元测试(最多就那么几个)共用一个 setup 方法是有用的——但前提是所有这些测试都需要所有那些前置条件。这跟 context-specification 单元测试模式有关,但如果你想把同一套 setup 代码复用到一大堆测试上,它仍然有变得难以维护的风险。(另外,我不会把「让同一个测试跑多组数据」——比如用 NUnit 的 [TestCase] API——算作违反这条正交规则。改了东西之后测试运行器可能会显示多个失败,但要维护的仍然只是一个测试方法,所以没问题。)
别对配置做单元测试
配置项,按定义,不属于任何代码单元(你就是因为这个才把它从单元代码里抽出来的)。就算你能写个单元测试去检查配置,它也只是逼你在另一个多余的地方再把同一份配置写一遍。恭喜:它证明了你会复制粘贴!我个人把 ASP.NET MVC 里 filter 这类东西也视作配置——像 [Authorize] 或 [RequiresSsl] 这种 filter,就是固化在代码里的配置选项。你完全可以为那些外部可观察的行为写集成测试,但试图单元测试「源码里到底有没有这个 filter 特性」是毫无意义的——它又一次证明了你会复制粘贴。这对设计没有任何帮助,也永远不会发现任何缺陷。
清晰、一致地给单元测试命名
比如你在测「库存为零时 ProductController 的 Purchase action 会怎样」,那就不妨建一个叫 PurchasingTests 的测试夹具类,里面放一个叫 ProductPurchaseAction_IfStockIsZero_RendersOutOfStockView() 的单元测试。这个名字描述了主题(ProductController 的 Purchase action)、场景(库存为零)和结果(渲染「缺货」视图)。我不知道这种命名法有没有现成名字,虽然我知道有人在这么用。要不叫它 S/S/R?避免用 Purchase() 或 OutOfStock() 这种不知所云的测试名。连自己要维护的是什么都不清楚,维护起来就难了。
结论
毫无疑问,单元测试能显著提升你项目的质量。业内很多人声称「有单元测试总比没有强」,我不同意:一套测试可以是巨大的资产,也可以是几乎没啥用、还死沉的负担。这取决于这些测试的质量,而质量似乎又取决于开发者对单元测试的目标和原则理解得有多到位。
顺便提一句,如果你想补集成测试这块(和单元测试技能互补),可以看看 Watin、Selenium 这些项目,还有作者当时发布的那个 ASP.NET MVC 集成测试辅助库。
译者补充:C 与 C++ 示例
原文用 C# / NUnit 举例。下面用一个无外部依赖的纯计算单元——「百分制转字母等级」——在 C 和 C++ 里演示上面几条建议:单一逻辑断言、测试之间正交、S/S/R 命名(主题 _ 场景 _ 结果)。这类没有协作者的单元,最容易稳稳落到「甜蜜点 A」。
C 用 Unity,C++ 用 GoogleTest。注意每个测试名都写成
主题_场景_结果,而且每个测试只断言一个行为——改某条边界(比如把 A 的门槛从 90 调到 85),只需要动一个测试,其余测试不受影响,这就是「正交」。
/* ===== SUT:把百分制分数转成字母等级 ===== */
char grade_to_letter(int score) {
if (score >= 90) return 'A';
if (score >= 80) return 'B';
if (score >= 70) return 'C';
if (score >= 60) return 'D';
return 'F';
}
/* ===== 测试:Unity ===== */
#include "unity.h"
void setUp(void) {}
void tearDown(void) {}
/* 每个测试名遵循 S/S/R:主题(grade) _ 场景 _ 结果(返回值)。
* 每个测试只断言一个行为、彼此正交,改一条边界只需改一个测试。*/
void test_grade_Above90_ReturnsA(void) { TEST_ASSERT_EQUAL('A', grade_to_letter(95)); }
void test_grade_Between80and89_ReturnsB(void) { TEST_ASSERT_EQUAL('B', grade_to_letter(85)); }
void test_grade_Boundary90_ReturnsA(void) { TEST_ASSERT_EQUAL('A', grade_to_letter(90)); } /* 边界:90 也算 A */
void test_grade_Boundary60_ReturnsD(void) { TEST_ASSERT_EQUAL('D', grade_to_letter(60)); } /* 边界:60 也算 D */
void test_grade_Below60_ReturnsF(void) { TEST_ASSERT_EQUAL('F', grade_to_letter(30)); }
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_grade_Above90_ReturnsA);
RUN_TEST(test_grade_Between80and89_ReturnsB);
RUN_TEST(test_grade_Boundary90_ReturnsA);
RUN_TEST(test_grade_Boundary60_ReturnsD);
RUN_TEST(test_grade_Below60_ReturnsF);
return UNITY_END();
}// ===== SUT:把百分制分数转成字母等级 =====
char grade_to_letter(int score) {
if (score >= 90) return 'A';
if (score >= 80) return 'B';
if (score >= 70) return 'C';
if (score >= 60) return 'D';
return 'F';
}
// ===== 测试:GoogleTest =====
#include <gtest/gtest.h>
// 测试名遵循 S/S/R:主题(GradeTest) _ 场景 _ 结果。
// 每个测试只断言一个行为、彼此正交,改一条边界只需改一个测试。
TEST(GradeTest, Above90_ReturnsA) { EXPECT_EQ('A', grade_to_letter(95)); }
TEST(GradeTest, Between80and89_ReturnsB) { EXPECT_EQ('B', grade_to_letter(85)); }
TEST(GradeTest, Boundary90_ReturnsA) { EXPECT_EQ('A', grade_to_letter(90)); } // 边界:90 也算 A
TEST(GradeTest, Boundary60_ReturnsD) { EXPECT_EQ('D', grade_to_letter(60)); } // 边界:60 也算 D
TEST(GradeTest, Below60_ReturnsF) { EXPECT_EQ('F', grade_to_letter(30)); }
// 补充:若想用同一套断言跑多组数据,GoogleTest 的参数化测试(TEST_P)
// 对应原文提到的 NUnit [TestCase]——测试运行器会报多个用例,
// 但要维护的仍只是一个测试方法,不算违反正交规则。