为什么优秀的开发者会写出糟糕的单元测试
恭喜!你终于写了足够多的代码,赚到了买海滨别墅的钱。你聘请了以摩天大楼闻名世界的建筑师 Peter Keating(彼得·基廷),他向你保证,他已为你的海滨地产准备了出色的设计方案。
几个月后,你来到新居的盛大揭幕现场。你的新家是一座气势逼人的五层庞然大物,由钢铁、混凝土和反光玻璃建成。当你穿过旋转门时,沙子被带到了华丽的大理石地板上。走进室内,你看到一个接待台,后面是一排电梯。楼上,你的主卧和三间客房不过是四间相连的办公隔间。
身为专家建筑师的彼得·基廷无法理解你为何会失望。“我可是遵循了所有最佳实践,”他辩解道。墙壁有三英尺厚,因为结构完整性至关重要。因此,你的房子比旁边那些通风明亮、采光充足的房子更好。你可能没有巨大的临海窗户,但基廷告诉你,那种窗户不符合最佳实践——它们会降低能效,还会让办公室里的人分心。
很多时候,软件开发者对待单元测试也有着同样错误的思维。他们机械地套用在生产代码中学到的所有“规则”,却不去思考这些规则是否适用于测试。结果,他们就在海滩上建起了摩天大楼。
测试代码不同于其他代码
生产应用通常包含数千到数百万行代码。其规模之大,人类无法一次性在脑海中完全把握。为了管理这种复杂性,语言设计者提供了函数和类层次结构等机制,让开发者能够以抽象的方式思考。
优秀的生产代码实现了封装。它让读者能够轻松地在庞大的系统中穿梭,需要时深入细节,或上升到更高的抽象层面。
测试代码则是另一回事。好的单元测试往往足够小,开发者可以一次性在脑海中理清所有逻辑。给测试代码增加抽象层次反而会增加其复杂性。测试是一种诊断工具,因此应该尽可能简单明了。
想想一把尺子。几百年来它一直保持着同样的形态,因为它简单易懂、易于解读。假设我发明了一把新的尺子,用“抽象尺子单位”来度量。要把“尺子单位”换算成英寸或厘米,你得查一张单独的换算表。
如果我把这样的尺子递给一位木匠,他会直接用它抽我的脸。给一个本就能提供清晰、明确信息的工具再加一层抽象,是荒谬的。
好的测试代码也是如此。它应该直接给出清晰的结果,而不迫使读者在多层间接跳转中费力查找。开发者常常忽略这一点,因为这与他们学习编写生产代码的方式不同。
优秀开发者写出的糟糕测试
我经常看到一些原本很有才华的开发者写出如下的测试:
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 的行为,而不是将所有交互都保留在测试函数本身中。
下面是解决了这些问题的重写版本:
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 这样的函数在其他上下文中会显得过于冗长,但它是一个很好的测试名称。如果看到它失败,你会立刻知道是这个类没有正确处理空数据流。你很可能甚至无需阅读测试的实现就能修复这个错误。这就是一个好测试名称的标志。
欣然接受魔数
“不要使用魔数。”
这是编程世界里的“不要和陌生人说话”。许多优秀的开发者如此深刻地内化了这一教训,以至于他们从未考虑过魔数何时反而能改进代码。
回顾一下,“魔数”是指在代码中出现却没有说明其代表含义的数值或字符串。下面是一个例子:
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(洛兰·尤)创作
随机一篇博客

