为什么优秀的开发者会写出糟糕的单元测试
原文由 Michael Lynch 于 发布,订阅该博客
恭喜!你写的代码已经多到足以买下一栋海景别墅。你请来了彼得·基廷——一位以摩天大楼闻名于世的建筑师,他向你保证,已经为你的海滨地块准备了绝妙的设计方案。
几个月后,你来到新居的落成揭幕仪式。眼前的房子是一座由钢铁、混凝土和反光玻璃构成的五层庞然大物,气势逼人。你穿过旋转门走进去,脚下的沙子落在了华丽的大理石地面上。屋内是一张接待台,后面是一排电梯。上了楼,你发现所谓的主卧和三间客房,不过是四个相连的办公格子间。
专家建筑师彼得·基廷完全不理解你为何失望。“我可是遵循了所有最佳实践,”他辩解道。墙体足有三英尺厚,因为结构完整性至关重要。因此,你的房子比旁边那些通透、明亮的房子更好。你也许没有面向大海的大窗户,但基廷告诉你,那种窗户并不符合最佳实践——会降低能效,还会让办公人员分心。
很多时候,软件开发者对待单元测试时也抱着同样错误的想法。他们机械地套用在生产代码中学到的所有“规则”,却不去思考这些规则是否真的适用于测试。结果,他们就在海边盖起了摩天大楼。
测试代码和其他代码不一样
生产环境的应用程序通常包含成千上万甚至数百万行代码,规模大到人类无法一次性在脑海中完整把握。为了应对这种复杂性,语言设计者提供了函数、类层级等机制,让开发者能够以抽象的方式思考。
优秀的生产代码实现了封装。它让读者能够轻松地在庞大系统中穿梭,需要时深入细节,需要时上升到更高的抽象层面。
测试代码则完全是另一回事。一个好的单元测试往往小到开发者可以一次性理解全部逻辑。给测试代码增加抽象层只会让它变得更复杂。测试是一种诊断工具,因此应该尽可能简单、直观。
想想一把尺子。几百年来它都保持着同样的形态,正因为它简单、易读。假设我发明了一把用“抽象尺子单位”来度量的新尺子,要把“尺子单位”换算成英寸或厘米,你还得去查一张单独的换算表。
如果我把这样的尺子递给一位木匠,他会直接拿它抽我脸。给一个本就提供清晰、明确信息的工具再加一层抽象,实在是荒谬。
好的测试代码也是如此。它应该直接给出清晰的结果,而不是迫使读者在多层间接跳转中兜圈子。开发者常常忽略这一点,因为这与他们学习编写生产代码的方式截然不同。
优秀开发者写出的糟糕测试
我经常看到一些原本很有才华的开发者写出这样的测试:
def test_initial_score(self):
initial_score = self.account_manager.get_score(username='joe123')
self.assertEqual(150.0, initial_score)这个测试做了什么?它获取了用户名为 joe123 的用户的“分数”,并验证分数是 150。到这里,你应该会产生以下疑问:
joe123这个账号是从哪来的?- 为什么我会预期
joe123的分数是 150?
也许答案藏在 setUp 方法里,测试框架会在执行每个测试函数之前调用它:
def setUp(self):
database = MockDatabase()
database.add_row({
'username': 'joe123',
'score': 150.0
})
self.account_manager = AccountManager(database)好吧,setUp 方法创建了分数为 150 的 joe123 用户,这就解释了为什么 test_initial_score 会预期这些值。现在一切都说得通了,对吧?
不,这是一个糟糕的测试。
让读者停留在测试函数内
写测试时,想想下一个看到测试失败的开发者。他们不想去读完整套测试套件,更不想去翻遍一整棵测试工具类的继承树。
如果一个测试失败了,读者应该能够自上而下、线性地阅读测试函数就能诊断出问题。如果他们不得不跳出测试去阅读其他辅助代码,那这个测试就没有尽到职责。
考虑到这一点,下面是上一节那个测试的重写版本:
def test_initial_score(self):
database = MockDatabase()
database.add_row({
'username': 'joe123',
'score': 150.0
})
account_manager = AccountManager(database)
initial_score = account_manager.get_score(username='joe123')
self.assertEqual(150.0, initial_score)我所做的不过是把 setUp 方法里的代码内联进来,但效果却大不相同。现在,读者需要的一切都在测试里一目了然。它还遵循了准备、执行、断言的结构,让测试的每个阶段都清晰分明。
敢于违反 DRY
把前置代码内联进来对单个测试来说当然很好,但如果我有很多测试呢?难道每次都要重复这些代码吗?做好心理准备,因为接下来我要提倡复制粘贴式编程。
下面是同一个测试类中的另一个测试:
def test_increase_score(self):
database = MockDatabase() # <
database.add_row({ # <
'username': 'joe123', # <--- Copy/pasted from
'score': 150.0 # <--- previous test
}) # <
account_manager = AccountManager(database) # <
account_manager.adjust_score(username='joe123',
adjustment=25.0)
self.assertEqual(175.0,
account_manager.get_score(username='joe123'))对于严格恪守DRY 原则(“不要重复自己”)的人来说,上面的代码简直令人震惊。我明目张胆地重复自己,一字不差地从上一个测试里复制了六行。更糟的是,我还在主张这些违反 DRY 的测试比没有重复代码的测试更好。这怎么可能?
如果你能在不重复代码的前提下写出清晰的测试,那当然是最理想的,但要记住,消除冗余只是手段,不是目的。最终目标是清晰、简单的测试。
在盲目地把 DRY 套用到测试上之前,先想想当测试失败时,怎样才能让问题一目了然。重构或许能减少重复,但也会增加复杂性,并可能在出错时掩盖关键信息。
添加辅助方法前要三思
也许在每个测试里复制粘贴六行代码你还能忍受,但如果 AccountManager 需要更多的前置代码呢?
def test_increase_score(self):
# vvvvvvvvvvvvvvvvvvvvv Beginning of boilerplate code vvvvvvvvvvvvvvvvvvvvv
user_database = MockDatabase()
user_database.add_row({
'username': 'joe123',
'score': 150.0
})
privilege_database = MockDatabase()
privilege_database.add_row({
'privilege': 'upvote',
'minimum_score': 200.0
})
privilege_manager = PrivilegeManager(privilege_database)
url_downloader = UrlDownloader()
account_manager = AccountManager(user_database,
privilege_manager,
url_downloader)
# ^^^^^^^^^^^^^^^^^^^^^ End of boilerplate code ^^^^^^^^^^^^^^^^^^^^^^^^^^^
account_manager.adjust_score(username='joe123',
adjustment=25.0)
self.assertEqual(175.0,
account_manager.get_score(username='joe123'))光是为了拿到一个 AccountManager 实例并开始测试,就需要 15 行代码。到了这种程度,大量样板代码已经喧宾夺主,让人无法聚焦于你要测试的行为本身。
你的本能可能是把这些乏味的代码都丢给测试辅助方法,但你首先应该问一个更关键的问题:为什么这个系统会这么难测?
过多的样板代码往往是架构薄弱的征兆。例如,上面的测试就暴露了几处设计异味:
account_manager = AccountManager(user_database,
privilege_manager,
url_downloader)AccountManager 直接访问 user_database,但它的下一个参数却是 privilege_manager——一个对 privilege_database 的封装。为什么它要同时操作两个不同抽象层级的东西?而那个“URL 下载器”又是干什么的?它与其他两个参数在概念上显然相去甚远。
在这种情况下,重构 AccountManager 才能解决根本问题,而添加辅助方法只会掩盖表象。
如果非要写辅助方法,请负责任地写
你并不总是有自由为了可测试性而大刀阔斧地拆解生产类。有时,辅助方法是唯一的选择,所以当你确实需要它们时,就要把它们写好。
一个有效的辅助方法应该遵循“让读者停留在测试函数内”的原则。只要不损害读者对测试的理解,把样板代码抽到辅助函数里是可以的。
具体来说,辅助方法不应:
- 隐藏关键数值
- 与被测对象交互
下面是一个违反这些准则的辅助方法示例:
def add_dummy_account(self): # <- Helper method
dummy_account = Account(username='joe123',
name='Joe Bloggs',
email='[email protected]',
score=150.0)
# BAD: Helper method hides a call to the object under test
self.account_manager.add_account(dummy_account)
def test_increase_score(self):
self.account_manager = AccountManager()
self.add_dummy_account()
account_manager.adjust_score(username='joe123',
adjustment=25.0)
self.assertEqual(175.0, # BAD: Relies on value set in helper method
account_manager.get_score(username='joe123'))除非读者去辅助方法里找出那个被藏起来的 150,否则根本无法理解为什么最终分数应该是 175。这个辅助方法还把对 add_account 的调用藏了起来,没有让所有与 account_manager 的交互都保留在测试函数内,从而模糊了 account_manager 的行为。
下面是修正这些问题后的重写版本:
def make_dummy_account(self, username, score):
return Account(username=username,
name='Dummy User', # <- OK: Buries values but they're
email='[email protected]', # <- irrelevant to the test
score=score)
def test_increase_score(self):
account_manager = AccountManager()
account_manager.add_account(
make_dummy_account(
username='joe123', # <- GOOD: Relevant values stay
score=150.0)) # <- in the test
account_manager.adjust_score(username='joe123',
adjustment=25.0)
self.assertEqual(175.0,
account_manager.get_score(username='joe123'))它仍然在辅助方法里隐藏了一些值,但这些值与测试无关。它还把 add_account 调用拉回到测试中,让读者可以轻而易举地追踪发生在 account_manager 上的一切。
尽情使用长测试名
以下哪个函数名是你更愿意在生产代码中看到的?
userExistsAndTheirAccountIsInGoodStandingWithAllBillsPaidisAccountActive
第一个传达了更多信息,但代价是一个长达 57 个字符的名字。大多数开发者愿意牺牲一点精确性,来换取像 isAccountActive 这样简洁、也足够好的名字(Java 开发者除外,对他们来说这两个名字都短得冒犯)。
对于测试函数,有一个关键因素改变了权衡:你永远不会去调用测试函数。开发者只会在函数签名处敲一次测试名。鉴于此,简洁性依然重要,但在测试代码中远不如在生产代码中那么重要。
每当测试失败时,测试名是你首先看到的东西,所以它应该尽可能多地传达信息。例如,看看这个生产类:
class Tokenizer {
public:
Tokenizer(std::unique_ptr<TextStream> stream);
std::unique_ptr<Token> NextToken();
private:
std::unique_ptr<TextStream> stream_;
};假设你运行了测试套件,并在输出中看到了这样一行:
[ FAILED ] TokenizerTests.TestNextToken (6 ms)你能知道测试为什么失败吗?大概不能。
TestNextToken 的失败只告诉你把 NextToken() 方法搞砸了,但在只有一个公开方法的类里,这毫无意义。要诊断失败,你还得去读测试的实现。
那如果看到的是这样呢:
[ FAILED ] TokenizerTests.ReturnsNullptrWhenStreamIsEmpty (6 ms)在其他场合,叫 ReturnsNullptrWhenStreamIsEmpty 的函数会显得过于啰嗦,但作为测试名却很合适。如果看到它失败,你会立刻知道是这个类没有正确处理空数据流。你很可能无需阅读测试实现就能修复这个 bug。这就是好测试名的标志。
拥抱魔数
“不要使用魔数。”
这是编程世界里的“不要和陌生人说话”。许多优秀的开发者把这条教诲内化得如此深刻,以至于从未考虑过魔数何时反而能让代码更好。
复习一下,“魔数”是指在代码中出现、却没有说明其含义的数值或字符串。例如:
calculate_pay(80) # <-- Magic number程序员们一致认为生产代码中的魔数是非常糟糕的事,于是他们会用命名常量来替代,像这样:
HOURS_PER_WEEK = 40
WEEKS_PER_PAY_PERIOD = 2
calculate_pay(hours=HOURS_PER_WEEK * WEEKS_PER_PAY_PERIOD)遗憾的是,人们误以为魔数同样会削弱测试代码,但事实恰恰相反。
看看下面这个测试:
def test_add_hours(self):
TEST_STARTING_HOURS = 72.0
TEST_HOURS_INCREASE = 8.0
hours_tracker = BillableHoursTracker(initial_hours=TEST_STARTING_HOURS)
hours_tracker.add_hours(TEST_HOURS_INCREASE)
expected_billable_hours = TEST_STARTING_HOURS + TEST_HOURS_INCREASE
self.assertEqual(expected_billable_hours, hours_tracker.billable_hours())如果你认为魔数在任何情况下都是罪恶的,那上面的测试在你看来就是正确的。72.0 和 8.0 都有了命名常量,谁也无法指责这个测试使用了魔数。
但是,不妨暂时放下你的“信仰”,尝尝魔数这颗禁果:
def test_add_hours(self):
hours_tracker = BillableHoursTracker(initial_hours=72.0)
hours_tracker.add_hours(8.0)
self.assertEqual(80.0, hours_tracker.billable_hours())它更简单,代码行数只有原来的一半。而且更直观——读者不必在函数里来回跳跃去追踪常量的名字。
当我看到开发者在测试代码中定义常量时,通常是因为对 DRY 的误用,或是因为害怕使用魔数。然而,测试很少有必要声明常量,这样做反而会让测试更难理解。
结论
要写出优秀的测试,开发者必须让自己的工程决策与测试代码的目标保持一致。最重要的是,测试应该最大化简洁性,同时最小化抽象。一个好的测试能让读者无需离开测试函数,就能理解预期行为并诊断问题。
封面插画:Loraine Yow
随机一篇博客


评论
登录后参与讨论