为什么好的程序员会写出糟糕的单元测试?
恭喜你!在写了无数行代码之后你终于可以买一套海景别墅了。你雇了世界著名的摩天大楼建筑师 Peter Keating,他向你保证他设计的海景别墅是最好的。
几个月后你终于迎来了剪彩的时刻。新房子是一栋人见人爱的钢筋混凝土结构的五层大楼,上面覆盖了闪闪发光的玻璃。你走过旋转门,沿着地上的沙子踏上了豪华大理石铺成的地板。在楼内,你看到了一个接待前台,后面还有电梯间,但楼上的主卧和三个次卧是只有办公室格子一般大小的四个互相挨着的房间。
而我们的建筑师 Peter Keating 不知道你为什么不高兴。“我遵循了所有最佳实践。”他信誓旦旦地说。墙壁是三尺厚的砖,因为结构坚固最重要。因此,你的房子要比周围的那些清爽宜人的小房子好得多。你也许没有面向大海的大落地窗,但他说,那种窗户不是最佳实践,它们只会无谓地浪费能源,而且还会分散办公室员工的注意力。
很多时候,软件开发者在构建单元测试时有着同样的想法。他们在产品代码里机械地使用各种“规则”,而根本不关心这些规则是否适合他们的测试。结果就像在沙滩上盖的这栋摩天大楼一样。
测试代码跟其他代码不一样
产品代码的核心是抽象。好多产品代码会将复杂性隐藏在精巧地划分好的函数和类层次结构中。这样阅读者就可以很容易地浏览整个大型项目,而且还可以随心所欲地查看更多细节,或者查看高层次的抽象。
而测试代码完全不同。测试中的每一层抽象都会让加大阅读的难度。测试是诊断工具,所以很明显应该尽量简单明了。
好的产品代码有好的结构;好的测试代码非常简明。
比如一把尺子。几百年来尺子的形状没有任何变化,因为这种形状很简单,而且易于理解。假设我发明一种“抽象单位尺”,这种尺子需要另一张转换表才能把“尺子的单位”转换成英寸或厘米。
如果把这种尺子交给木匠,他们一定会把尺扔到我脸上。给一个简单明了的工具增加一层抽象是非常荒谬的行为。
好的测试代码也是一样。它应当提供清晰的结果,而读者不需要在多层之间跳来跳去。开发者通常对这一点有误解,因为这一点跟写产品代码是不一样的。
好的开发者也会写出烂测试
我经常看到其他天才程序员写出下面的测试:
def test_initial_score(self):
initial_score = self.account_manager.get_score(username='joe123')
self.assertEqual(150.0, initial_score)
这段测试是干什么的?它从名为 joe123 的用户中取出“score”然后验证分数为150。看到这里你一定会有以下问题:
joe123 账号从哪儿来的?
为什么 joe123 的分数应该是 150?
很可能答案在 setUp 方法中,这个方法会在每个测试函数执行之前被调用:
def setUp(self):
database = MockDatabase()
database.add_row({
'username': 'joe123',
'score': 150.0
})
self.account_manager = AccountManager(database)
好吧,setUp 方法创建了 joe123 用户,其分数为 150,这解释了为什么 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 原则(Don't Repeat Yourself - 不要重复)的角度来看,上面这段代码非常糟糕。显然里面有重复代码,我从前一个测试中直接复制了 6 行代码过来。更不可思议的是,我认为上面这段违反 DRY 原则的测试要比前面没有重复代码的测试更好。这怎么可能?
最理想的情况当然是不重复任何代码实现清晰的测试,但别忘了不重复是手段,不是目的。目的是清晰简单的测试。
在盲目应用 DRY 原则之前,仔细考虑下当测试失败时怎样才能更容易地找到问题所在。重构能减少重复,但也会增加复杂度,而且可能在测试失败时让信息更混乱。
如果一定的代码重复能让测试保持简单,那就接受它。
添加辅助方法之前要三思
也许可以给每个测试都复制粘贴 6 行代码,但是如果 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'))
上面整整 15 行代码的目的只是获得 AccountManager 的实例然后测试它。在这个层次上,样板代码过多,分散了测试行为的注意力。
一个很自然的想法是把这一段代码移到辅助方法内,但首先要问一个极其重要的问题:这样会让系统更难测试吗?
过多的样板代码通常是弱结构的象征。例如,上面的测试代码表现出了多个设计异味(https://en.wikipedia.org/wiki/Design_smell):
account_manager = AccountManager(user_database,
privilege_manager,
url_downloader)
AccountManager 直接访问了 user_database 数据库,但它的下一个参数是 privilege_manager,是对 privilege_database 的一个封装。为什么它要同时操作两个不同层次的抽象?而且这跟 URL downloader 有什么关系?后者显然跟前两个参数完全无关。
在这种情况下,重构 AccountManager 才能解决根本问题,而添加辅助方法只是掩盖表象而已。
在尝试写辅助方法之前,先尝试重构产品代码。
如果真需要辅助方法,就要负责地写好
然而,很多时候你并不能为了可测试性就随便修改产品代码。有时候,辅助方法是唯一的选择,所以在需要辅助方法时要认真负责地写好。
优秀的辅助方法需要秉承“把阅读者留在测试函数内”的理念。只要不给阅读者理解测试增加难度,那么将样板代码放到辅助函数里也是可取的。
具体来说,辅助函数不应该:
埋藏关键值
与被测试的对象交互
下面的辅助方法的例子违反了上述原则:
def add_dummy_account(self): # <- Helper method
dummy_account = Account(username='joe123',
name='Joe Bloggs',
email='joe123@example.com',
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'))
阅读者无法理解最终分数为什么是175,除非他去阅读辅助方法中隐藏的150。辅助方法还隐藏了 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='dummy@example.com', # <- 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.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,或者是他们惧怕使用魔法数。然而,测试很少有定义常量的需要,这样做只会让测试更难懂。
不要在测试代码中定义常量。直接使用魔法数就好。
注意:测试代码引用产品代码中导出的常量是没问题的。不要在测试代码中定义就行。
结论
如果想写出优秀的测试代码,开发者必须根据测试代码的目的来作出工程上的决定。最重要的是,测试应当尽可能简化,使用尽可能少的抽象。好的测试应该让读者立即明白测试的行为,并且无需离开测试函数就能诊断问题。
原文:https://mtlynch.io/good-developers-bad-tests/
作者:Michael Lynch,软件工程师。
译者:弯月,责编:屠敏
推荐阅读: