为什么好的程序员会写出糟糕的单元测试?

640?wx_fmt=gif

恭喜你!在写了无数行代码之后你终于可以买一套海景别墅了。你雇了世界著名的摩天大楼建筑师 Peter Keating,他向你保证他设计的海景别墅是最好的。

几个月后你终于迎来了剪彩的时刻。新房子是一栋人见人爱的钢筋混凝土结构的五层大楼,上面覆盖了闪闪发光的玻璃。你走过旋转门,沿着地上的沙子踏上了豪华大理石铺成的地板。在楼内,你看到了一个接待前台,后面还有电梯间,但楼上的主卧和三个次卧是只有办公室格子一般大小的四个互相挨着的房间。

640?wx_fmt=jpeg

而我们的建筑师 Peter Keating 不知道你为什么不高兴。“我遵循了所有最佳实践。”他信誓旦旦地说。墙壁是三尺厚的砖,因为结构坚固最重要。因此,你的房子要比周围的那些清爽宜人的小房子好得多。你也许没有面向大海的大落地窗,但他说,那种窗户不是最佳实践,它们只会无谓地浪费能源,而且还会分散办公室员工的注意力。

很多时候,软件开发者在构建单元测试时有着同样的想法。他们在产品代码里机械地使用各种“规则”,而根本不关心这些规则是否适合他们的测试。结果就像在沙滩上盖的这栋摩天大楼一样。


640?wx_fmt=png

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


产品代码的核心是抽象。好多产品代码会将复杂性隐藏在精巧地划分好的函数和类层次结构中。这样阅读者就可以很容易地浏览整个大型项目,而且还可以随心所欲地查看更多细节,或者查看高层次的抽象。

而测试代码完全不同。测试中的每一层抽象都会让加大阅读的难度。测试是诊断工具,所以很明显应该尽量简单明了。

好的产品代码有好的结构;好的测试代码非常简明。

比如一把尺子。几百年来尺子的形状没有任何变化,因为这种形状很简单,而且易于理解。假设我发明一种“抽象单位尺”,这种尺子需要另一张转换表才能把“尺子的单位”转换成英寸或厘米。

640?wx_fmt=jpeg

如果把这种尺子交给木匠,他们一定会把尺扔到我脸上。给一个简单明了的工具增加一层抽象是非常荒谬的行为。

好的测试代码也是一样。它应当提供清晰的结果,而读者不需要在多层之间跳来跳去。开发者通常对这一点有误解,因为这一点跟写产品代码是不一样的。


640?wx_fmt=png

好的开发者也会写出烂测试


我经常看到其他天才程序员写出下面的测试:

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

这段测试是干什么的?它从名为 joe123 的用户中取出“score”然后验证分数为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 方法创建了 joe123 用户,其分数为 150,这解释了为什么 test_initial_score 期待这些值。那么现在这个测试应该没问题了吧?

你错了,它依然是个烂测试。


640?wx_fmt=png

别让读者离开测试函数


在编写测试代码时,应当考虑到别人可能需要处理该测试失败的情况。他们绝不希望阅读整个测试套件,肯定也不想阅读一大堆测试工具的整个继承树。

如果测试失败,阅读者应当只需从头到尾阅读一边测试代码就能诊断问题。如果不得不去参考辅助的测试代码,这个测试用例就没写好。

考虑到这一点,上一节的测试用例应当写成这样:

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 方法中的代码内联到了测试函数中,但整个情况都不一样了。现在,任何阅读者都只需要阅读该测试本身就能理解。它也遵循了“计划-行动-断言”的结构,让测试的每个阶段都十分明显。

理想状态是,阅读者无需阅读测试函数之外的代码就能看懂。


640?wx_fmt=png

不要害怕违反 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 原则之前,仔细考虑下当测试失败时怎样才能更容易地找到问题所在。重构能减少重复,但也会增加复杂度,而且可能在测试失败时让信息更混乱。

如果一定的代码重复能让测试保持简单,那就接受它。


640?wx_fmt=png

添加辅助方法之前要三思


也许可以给每个测试都复制粘贴 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 才能解决根本问题,而添加辅助方法只是掩盖表象而已。

在尝试写辅助方法之前,先尝试重构产品代码。


640?wx_fmt=png

如果真需要辅助方法,就要负责地写好


然而,很多时候你并不能为了可测试性就随便修改产品代码。有时候,辅助方法是唯一的选择,所以在需要辅助方法时要认真负责地写好。

优秀的辅助方法需要秉承“把阅读者留在测试函数内”的理念。只要不给阅读者理解测试增加难度,那么将样板代码放到辅助函数里也是可取的。

具体来说,辅助函数不应该:

  • 埋藏关键值

  • 与被测试的对象交互

下面的辅助方法的例子违反了上述原则:

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 的情况。

必须保证辅助方法中不含任何阅读者必须理解的信息。


640?wx_fmt=png

不要惧怕使用长测试名


在产品代码中下列哪个函数名更好?

  • 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。因此这才是好的测试名。

好的测试名应当具有描述性,让开发者仅凭函数名就能诊断错误。


640?wx_fmt=png

拥抱魔法数


“不要使用魔法数。”

这句话相当于是编程界的“不要跟陌生人说话”。许多有经验的开发者都极力推崇这一点,他们绝不会认为魔法数会改善代码。

你还记得魔法数是什么吗?魔法数就是代码中出现的不含任何说明信息的数值或字符串。例如;

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,或者是他们惧怕使用魔法数。然而,测试很少有定义常量的需要,这样做只会让测试更难懂。

不要在测试代码中定义常量。直接使用魔法数就好。

注意:测试代码引用产品代码中导出的常量是没问题的。不要在测试代码中定义就行。


640?wx_fmt=png

结论


如果想写出优秀的测试代码,开发者必须根据测试代码的目的来作出工程上的决定。最重要的是,测试应当尽可能简化,使用尽可能少的抽象。好的测试应该让读者立即明白测试的行为,并且无需离开测试函数就能诊断问题。

原文:https://mtlynch.io/good-developers-bad-tests/

作者:Michael Lynch,软件工程师。

译者:弯月,责编:屠敏

推荐阅读:

640?wx_fmt=gif

640?wx_fmt=gif

展开阅读全文

程序员十个糟糕的行为

11-13

这里,我们主要讨论十个糟糕程序员的特征,主要是需要让我们去避免和小心的。rn 1) 情绪化的思维rn 如果你开始使用不同颜色的眼光来看待这个世界的话,那么你可能会成为一个很糟糕的程序员。情绪化的思维或态度很有可能会把自己变成一个怪物。相信你经常可以看到很多很糟糕的程序会使用下面的这些语句:rn 我的程序不可能有这种问题。rn Java就是shit。rn 我最恨的就是使用UML做设计。rn 需求怎么老在变,没办干了。rn 受不了这些人,他们到底懂不懂啊。rn …… ……rn 这些带着情绪化的思维和态度,不但可以让你成为一个很糟糕的程序员,甚至可以影响你的前途。因为,情绪化通常都是魔鬼,会让你做出错误的判断和决定,错误码率的判断和决定直接决定了你的人生。rn 2) 怀疑别人rn 糟糕的程序总是说:“我的代码一定是正确的,我怀疑编译器有问题”,“我这应该没有问题吧,STL库怎么这么难用啊”。我曾经见过有程序员这样使用STL类:map,当他发现这样放入字符串后却取不出来,觉得那是STL库的BUG,然后自己写了一个map!我的天啊!rn 某些时候,过早的下结论是一个很不好的习惯,任何事情都有其原因,只有知道了原因,你才能知道是谁的问题。一般来说,总是自己出的问题。rn 3) 过多关注实现,陷入问题细节rn 有些时候,当我们面对一个问题或是一个需求的时候,糟糕的程序员总是会马上去找一个解决方案或是实现,这是一个很不好的习惯。设计模式告诉我们,“喜欢接口,而不是实现”就是告诉我们,认清问题的本质和特性要比如何实现更重要。rn 对于一个客户的问题来说,首先应该想到的是如何先让用户正常工作,如果恢复正在“流血”的系统,而不是把用户放在一边而去分析问题的原因和解决方案。rn 对于解决一个bug来说,重现bug,了解原来程序的意图是首先重要的事,而不是马上去修改代码,否则必然会引入更多的BUG。rn 对于一个需求来说,我们需要了解的需求后面的商业背景,use case和真实意图,而不是去讨论如果实现。只有了解了用户的真实意图,实现使用,你才能真正如果去做设计。rn 糟糕的程序总是容易陷入细节,争论于如何实现,问题的根本原因,而忽略了比这些更重要的东西。只有看懂了整个地图,你才知道要怎么去走。rn 4) 使用并不熟悉的代码rn 糟糕的程序员最好的朋友是 Ctrl-C 和 Ctrl-V ,有些时候,他们并不知道代码的确切含义,就开始使用它,有证据表明,由拷贝粘贴引发的bug点了绝大多数。因为,代码总是只能在特定的环境下才能正常地工作,如果代码的上下文改变了,很有可能让使得代码产生很多你不知道的行为,当你连代码都控制不住了,你还能编出什么好的程序呢?rn 5) 拼命工作而不是聪明的工作rn 对于糟糕的程序员,我们总是能看到他们拼命地修正他们的bug,总是花非常多时间并重复地完成某一工作。而好的程序可能会花双倍的时间来准备一个有效的开发环境,工具,以及在开发的时候花双倍甚至10倍的时间来避免一些错误。好的程序员总是会利用一切工具或手段来让自己的工作变得更有效率,总是为在开发的时候尽可能得不出错。后期出错的成本将会是巨大的,而且那时改正错误的压力也是巨大的。所以,糟糕的程序通常会让自己进入一种恶性循环,他们看上去总是疲惫的,总是很辛苦的,所以更没有时间来改善,越没有时间来改善,就有越多的问题。所以,拼命工作有些时候可能表明你不是一个好的程序员。rn 6) 总是在等待、找借口以及抱怨rn 当需求不明确的时候,当环境不是很满意的时候,他们总是在等待别人的改善。出现问题的时候,总是在找借口,或是抱怨这也不好,那也不好,所以自己当然就没有做好。糟糕的程序员总是希望自己的所处的环境是最好的,有明确的需求,有非常不错的开发环境,有足够的时间,有不错的QA,还有很强的team leader,以及体贴自己的经理,有足够的培训,有良好的讨论,有别人强有力的支持……,这是一种“饭来张口,衣来伸手”的态度,这个世界本来就不完美,一个团队需要所有人去奋斗,况且,如果什么都变得完美了,那么,你的价值何在吗?driving instead of waiting, leading instead of following.rn 7) 滋生办公室政治rn 有句话叫“丑女多作怪”,意思是说如果一个自己没有真实的能力的话,那么他一定会在其它方面作文章。糟糕的程序员也是这样,如果他们程序编不好的话,比不过别人的话,他们通常会去靠指责别人,推脱责任,或是排挤有能力的人,等等不正常的手段来保全自己。所以,糟糕的程序通常伴随着办公室政治。rn 8 ) 说得多做得少rn 糟糕的程序员总是觉得自己什么都懂,他们并不会觉得自己的认识和知识都是有限的。这就是所谓的夸夸其谈,是的,什么都做不好的程序员能靠什么混日子呢?就是吹啊吹啊。rn 另一个表现方式是他们在评论起别人的程序或是设计,总是能挑出一堆毛病,但自己的程序写得也很烂。总是批评抱怨,而没有任何有建设性的意见,或是提出可行的解决方案。rn 这些糟糕的程序员,总是喜欢以批评别人的程序而达到显示自己的优秀。rn 9) 顽固rn 当你给出一打证据说明那里有一个更好的方案,那里有一个更好的方向的时候,他们总是会倔强的认为他们自己的做法才是最好的。一个我亲身经历的事例就是,当我看到一个新来的程序在解决一个问题的时候走到了错误的方向上时,我提醒他,你可能走错了,应该是另外那边,并且我证明了给他看还有一个更为简单的方法,有。然而,这位程序员却告诉我,“那是我的方法,我一定要把之走下去,不然我会非常难受”,于是,在三天后的代码评审中,在经过顽固地解释以及一片质疑声中,他不得不采用了我最先告诉他的那个方法。rn 这些程序员,从来不会去想,也不会去找人讨论还有没有更好的方法,而是坚持自己的想法,那怕是条死路都一往直前,不撞南墙永不回头。rn 10) 写“聪明”的代码他们写出来的代码需要别的同事查看程序语言参考手册,或是其程序的逻辑或是风格看上去相当时髦,但却非常难读。代码本应该简洁和易读,而他们喜欢在代码中表现自己,并尝试另类的东西,以显示自己的才气。是的,只有能力有问题的程序员才需要借助这样的显示。rn 记得以前的一个经历,一位英语很不错的程序员加入公司,本来对我们这些英语二把刀来说,我们喜欢看到的是简单和易读的英文文档,然后,那位老兄为了展示他的英语如何牛,使用了很多GRE中比较生僻的短语和词汇。让大家阅读得很艰苦。最有讽刺意味的是,有一位native的美国人后来在其邮件中询问他某个单词的意思。呵呵。rn 你是一个糟糕的程序员吗?欢迎你分享你的经历。 论坛

为什么难以招到好的程序员

09-02

我公司(在深圳)一直由于业务发展需要,急聘.NET程序员8-9名,在一些招聘网站也发布了企业招聘信息,但是奇怪的是,从年初到现在,面试了不下100人,只勉为其难的招到一名刚毕业的大学生,还是只是想培养一下才招进来的,因此很困惑,想向大家了解一下,rn我公司的招聘要求是:rn1、熟练掌握 C#,ASP.NET,WinForm 编程rn2、熟悉 SQL SERVER 数据库rn3、熟悉 面对对象等相关开发理论rn3、基本功扎实,有一定工作经验,能立即上手rnrn要求不高,但就现在招聘的情况来看,有如下表现,rn1、基本上来面试的都是刚毕业的大学生,经验不足,动手能力差,基础理论也差rn2、有一部分是VB,C++,JAVA等编程经验的,对.NET不熟悉rn3、很小部分的.NET编程经验的程序员,基本功也很不扎实,随便问一下多态,委托等一些很基本的问题也回答不出来或很模糊rnrn到现在为止,公司还是无法招到相关的人,公司也很焦急,问我什么原因,我也感到挺困惑的,在深圳,我觉得还是应该有挺多好的.NET程序员的,可是怎么会来面试的都没有呢?难道大家如果找工作,都不到网站上找,或者其他什么原因?呵呵,因为我刚毕业就在这家公司工作,7年没换过工作,所以也没什么找工作的经验或体会,所以想向大家了解一下,还请大家不吝赐教,谢谢rnrn我的Email: hhqwolf@gmail.com 论坛

没有更多推荐了,返回首页