Why Good Developers Write Bad Unit Tests

Michael Lynch

为什么优秀的开发者会写出糟糕的单元测试

原文由 Michael Lynch 发布,订阅该博客

恭喜!你写的代码已经多到足以买下一栋海景别墅。你请来了彼得·基廷——一位以摩天大楼闻名于世的建筑师,他向你保证,已经为你的海滨地块准备了绝妙的设计方案。

几个月后,你来到新居的落成揭幕仪式。眼前的房子是一座由钢铁、混凝土和反光玻璃构成的五层庞然大物,气势逼人。你穿过旋转门走进去,脚下的沙子落在了华丽的大理石地面上。屋内是一张接待台,后面是一排电梯。上了楼,你发现所谓的主卧和三间客房,不过是四个相连的办公格子间。

建筑师在海滩上展示摩天大楼

专家建筑师彼得·基廷完全不理解你为何失望。“我可是遵循了所有最佳实践,”他辩解道。墙体足有三英尺厚,因为结构完整性至关重要。因此,你的房子比旁边那些通透、明亮的房子更好。你也许没有面向大海的大窗户,但基廷告诉你,那种窗户并不符合最佳实践——会降低能效,还会让办公人员分心。

很多时候,软件开发者对待单元测试时也抱着同样错误的想法。他们机械地套用在生产代码中学到的所有“规则”,却不去思考这些规则是否真的适用于测试。结果,他们就在海边盖起了摩天大楼。

测试代码和其他代码不一样

生产环境的应用程序通常包含成千上万甚至数百万行代码,规模大到人类无法一次性在脑海中完整把握。为了应对这种复杂性,语言设计者提供了函数、类层级等机制,让开发者能够以抽象的方式思考。

优秀的生产代码实现了封装。它让读者能够轻松地在庞大系统中穿梭,需要时深入细节,需要时上升到更高的抽象层面。

测试代码则完全是另一回事。一个好的单元测试往往小到开发者可以一次性理解全部逻辑。给测试代码增加抽象层只会让它变得更复杂。测试是一种诊断工具,因此应该尽可能简单、直观。

好的生产代码结构良好;好的测试代码一目了然。

一把尺子的特写照片

想想一把尺子。几百年来它都保持着同样的形态,正因为它简单、易读。假设我发明了一把用“抽象尺子单位”来度量的新尺子,要把“尺子单位”换算成英寸或厘米,你还得去查一张单独的换算表。

如果我把这样的尺子递给一位木匠,他会直接拿它抽我脸。给一个本就提供清晰、明确信息的工具再加一层抽象,实在是荒谬。

好的测试代码也是如此。它应该直接给出清晰的结果,而不是迫使读者在多层间接跳转中兜圈子。开发者常常忽略这一点,因为这与他们学习编写生产代码的方式截然不同。

优秀开发者写出的糟糕测试

我经常看到一些原本很有才华的开发者写出这样的测试:

def test_initial_score(self):
  initial_score = self.account_manager.get_score(username='joe123')
  self.assertEqual(150.0, initial_score)

这个测试做了什么?它获取了用户名为 joe123 的用户的“分数”,并验证分数是 150。到这里,你应该会产生以下疑问:

  1. joe123 这个账号是从哪来的?
  2. 为什么我会预期 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 上的一切。

辅助方法中不要包含读者理解测试所需的任何信息。

尽情使用长测试名

以下哪个函数名是你更愿意在生产代码中看到的?

  • userExistsAndTheirAccountIsInGoodStandingWithAllBillsPaid
  • isAccountActive

第一个传达了更多信息,但代价是一个长达 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.08.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

本文章由 muse-spark-1.2-contributor 进行翻译

评论