致讨厌写测试代码的人

协作翻译

原文:Testing, for people who hate testing

链接:https://eev.ee/blog/2016/08/22/testing-for-people-who-hate-testing/

译者:Tocy, polly, xiaoaiwhc1, stevobm, foolishFox, -_-struggle, 翻译狂, leoxu

我喜欢测试,但我讨厌编写测试.......

编写测试既乏味又无聊。编写测试代码很辛苦,有时甚至比写业务代码更辛苦。更糟糕的是,写完了测试也根本感觉不到达成了什么目标。

所以我通常不去编写测试。好吧,我知道我应该去做这件事。我也应该多锻炼、多吃蔬菜。

很可笑,我看到只有在一种情况下有人称赞测试的优点,那就是从事测试工作的人鼓吹TDD的好处。于我而言,就好比一个素食主义者为了让我吃素食而喋喋不休的告诉我吃素食是多么棒的一件事! 如果我一点都不想去做它,只能让自己接受没有工作可做的生活。我需要一点更实用的东西,就像“做冰沙”,或者“专业上来讲薯条也是一种蔬菜”。

现在我找到了一种做测试“冰沙”的最佳方法。我会尽量避免使用任何测试专业术语,即便根本没有人同意我讲的任意一个观点。

降低对测试的反感

喜欢你的测试工具

你的测试工具是一个可以找到你的测试用例,可以运行他们,并且(理论上)可以帮助你编写测试用例的框架。

如果你讨厌你的测试工具,你绝不会喜欢写编写测试。这将是一个艰难的过程,只要你能,无论何时你都要避免这个问题。逛逛街,看你能否找到更有意思的事情。

例如,Python 的标准解决方案是 stdlib unittest 模块。它是一个灵感来自 java 的变异体,你要像下面这样写一些看似无意义的代码:

import unittest

from mymodule import whatever

class TestWhatever(unittest.TestCase):

    def test_whatever(self):

        self.assertIn(whatever(), {1, 2, 3})

这让我很恼火,一个无关紧要的测试写成这个怪样子。这个类本身是没有意义的,我真正想测试的东西是隐藏在几十个 assert* 方法中的一个。

有一些小抱怨。但是,在应用每一个独立的测试方法,和不得不强迫自己首先编写这些测试用例时,是完全不同的。(或许它没有困扰到你,在每一个测试用例中坚持使用该 unittest 组件!)

基于此,我选择使用 py.test 来代替 unittest:

from mymodule import whatever

def test_whatever():

    assert whatever() in {1, 2, 3}

如果测试失败,始终会输出一些有用的信息,包括字符串形式或 sequences 形式的 diff。

    def test_whatever():

>       assert whatever() in {1, 2, 3}

E       assert 4 in set([1, 2, 3])

E        +  where 4 = whatever()

你真的真的不想知道它是怎么做到的。但它做到了,这才是我关心的问题。

py.test 也可以在测试失败时声音提醒并可以显式局部信息,提供了让你可以开发自定义断言的钩子,以及大量的其他钩子和插件。但是对我来说最重要的是它尽可能地降低了编写测试代码的要求。我可以在 REPL  代码中和 assert 代码段周围肆意地大量复制/粘贴。

重构你的 API

如果你感觉很难写出测试用例,那可能你需要重构你的 API 了。

我看到过很多测试代码仅仅是构造核心对象就需要各种奇技淫巧才能完成,同样地,也听到很多人抱怨他们不想写测试用例因为仅仅创建核心对象就很难了。

其实测试就是代码。如果你花很长时间才能构建某个特殊状态的待测对象,那么这可能是一个信号:你的 API 太难用了!

“好吧,我不想增加一个方法去测试这个功能因为没有人会需要它。”但是你现在就需要它,就是现在!你正在使用你自己写的 API , 却抱怨它不能做这不能做那,然而却不为它增加这些缺失的功能,只是因为你认为没有人会需要这些功能。

写测试最不受重视的一个部分是:强制你用自己的 API 写实际的代码。如果用你自己的 API 写基本的配置都很难,赶紧重构这些 API 吧。

保障你的测试套件快速稳健

我的测试套件已经运行了几个小时了,是否他们根本没有运行?

很显然需要权衡:这些测试套件是很公平的,速度是彻底性的成本。对于关键的 app ,这可能是很值得去做的。对于大型的 app ,也是无法避免的。

对于从始至终都没有进行测试过的基本代码,这是一个巨大的测试之痛的来源。你的测试套件应该尽可能的快速执行,或者你就不执行,然后找到一个正当的理由说服自己:编写更多的根本不会运行的测试代码毫无意义。

如果你的代码恰恰很慢,认真思考一下原因,让它执行更快。如果你有大量的测试需要做,审视一下是否可以合并一些。

或者,你有一些特别慢的测试用例,我有一个激进的建议:或许可以考虑删除它们。否则,加入它们不是绝对关键的,而且它们不断的在测试套件中被运行,这个时间成本可能是不值得的。删除一个测试会降低测试覆盖率百分比,绝对不要让覆盖率降低为0。

标记为 @Flaky 的测试是很糟糕的。你的测试应该一直完全的测试通过。如果你有一个测试用例有 10% 的失败率,而你又说不清楚为什么,禁用或者删掉它吧。这是告诉你不是任何事情都是有用的,同时也是训练你当测试失败的时候,忽略它。如果一个失败的测试并没有立即出现紧急状况,那么这个测试就根本不重要。

让它自动运行

你见过那些GitHub项目么?它们的拉取请求会自动告诉你测试集是通过了还是失败了。很不错,对吧?这个是通过 Travis 来实现的,让人吃惊的是这一切居然可以“无痛”地搭建起来。

一旦搭建好了,别人的电脑就会自动地通宵达旦地运行你所有的测试用例并且在运行失败的时候骚扰你。这确实很烦人,但是用起来真心不错。

 (还有一个Coveralls,可以用来评估你的测试覆盖率。这个挺好,不过如果你测试写得很艰难,火急火燎地提醒你的短板可能对你没什么帮助。)

我最近遇到了一个Pelican里面的有趣问题,Pelican是一个用来生成这篇博客的Python库。它里面有fr_FR本地环境的测试用例,而且它的测试集会在你没有安装那个本地环境的时候跳过那些测试……

但是README文件里面说,在提交拉取请求之前,你应该生成那个本地环境,这样你才能够运行那些测试。我当然就忘掉了这个,没有去生成fr_FR,心想我都通过了所有的测试,然后就提交了拉取请求——很快就在Travis上跑挂了。

由于可选依赖丢失而跳过测试是一件很让人棘手的事情。 当你写测试的时候,你觉得“没有任何地方显示测试失败意味着实际代码库没有问题” —— 当我运行的时候,我又认为 “哦,这些测试用例被跳过去了,所以它们并不是重要”。

测试什么

测试你手工测试的那些东西

当你正在做一个庞大的特性开发或者故障修复的时候,你会进行一个“仪式”来检查它是否完成了。

你“啪”地一下打开 REPL,重复那寥寥几行代码,然后运行一个凑起来的脚本,或者是打开你的应用然后重复同样的操作。这事情真是难以置信的乏味。

你在发布一个大版本之前可能也有着类似的“仪式”:运行、随便点点、尝试些常见的操作,还很自信——至少基础功能是正常的。

这些就是测试能够做的最好的事情了,因为你已经测试过它们了!如果你把这些“仪式”转换成代码的话,你可以省却很多的痛苦。作为额外的好处 ,其他人也可以重复你的“仪式”而不需要读懂你的或者是发明他们自己的代码,而且你的测试套件会被作为你发现的最重要东西的粗略描述。

有时,这很难。无论怎样还是试试吧,即便(特别是)你还没有测试套件。

有时,这真的很难。至少把你能写的那部分测试用例写了吧。 你总有机会坐下来把剩余的问题在稍后解决的。

测试可能出问题的地方

有些功能是容易测试的。比如你有个判断一个数是否为偶数的功能!然而,你可能要为这个功能写50个测试用例。然后你就多了50个测试用例。没毛病。

你会很轻松地写完,自我感觉良好,然而,这个功能被别人修改的可能性有多大呢?简直易如反掌,显然一眼就能看出这是完全正确的,并不会受到其他功能的影响,并且几乎不能再改善了。

测试主要的好处是适应变化。当代码改变时,测试可以帮你确认功能是否正常和正确运行。并不需要修改的测试代码不会给你的测试套件增添什么麻烦。

并不是说你不用测试一些无足轻重的功能——尤其是我们并不知道这个功能以后是不是要修改的时候——然而当你精力有限时,这些功能并不值得你花费大量的精力。

了解到测试也是一种需要解说的艺术形式,我认为测试和艺术有异曲同工之妙。测试会隐藏一些细节,放大一些问题。测试会让一些难以理解的代码的细节变得清晰。测试是难以通过简单的阅读源码从而检验代码的各项性能。当你需要自己理解代码的含义时,你需要测试

尽可能测试你能够测试的最小集合

很高兴看到一些测试显示你的代码正常工作很久了。不幸的是,这些也是最慢的(因为它们要处理的东西很多),最脆弱的(因为任何小的改动都可能会立即破坏许多这样的测试),最没有用的(因为同一个问题可能来自任何地方),而且时效率最低的(因为两个这样的测试执行的代码大部分是相同的)。

相比于功能性测试,执行测试的人更喜欢做单元测试,或者也可能是集成测试,验收测试,或端到端测试等。

忽略类别,你已经知道自己的代码库的状况:它是一个层次结构,每个人都觉得它们与特定的概念相关,即使代码组织并没有反映出来。如果你正在编写一个反汇编程序,并且在各种地方都有一些代码可以处理跳转和标签,这就是一个块,即使其代码在磁盘上并不连续。

所以针对这些块编写测试代码,要使它们尽可能的小。如果你仍在运行整个反汇编程序来实际执行某些测试,那你需要考虑减少额外的工作:禁用可选功能,并使测试尽可能简单。如果你对跳转或标签进行了更改,你将准确地知道要查找哪些测试用例;如果这些测试用例失败,你会对个中原因有一个很好的了解。

我知道,从开始到完成,像每个用户都会经历的那样,需要一堆测试来贯穿整个应用程序开发过程。但是根据我的经验,这些测试经常会失败,你还不知道失败的原因是什么,而且还有那么几个测试足以让整个测试套件陷入混乱。让你在编写下一个测试时,心有余悸。

如何测试?

测试输出,避免副作用

测试代码应该简单。选择输入;传递给函数;检查输出是否正确。“输入”和“输出”的相关预测很容易猛然失控,但是至少这个过程是简单的。

有副作用的测试代码就像是“眼中钉,肉中刺”。(因为测试只是代码,所以使用带有副作用的代码也好像“眼中钉,肉中刺”。)

“副作用”在这说起来就像是:你向函数传递参数,得到输出,在这个过程中一些地方发生了改变(不可预期的改变,因为正常情况下,每次相同的输入都应该获得相同的输出,译者注)。或者类似地,函数的行为不仅仅取决于传递给函数的参数。最常见的例子就是全局变量的使用,比如那些app级别却写在模块下的配置。

差劲,混乱,避免!避免!避免!重要的事说三遍!

所以......,不要使用全局变量?

我不止一次听到那些程序员抱怨没有比全局变量更难理解的了,我在这里重申一下:或许正是这样!使用和测试那些有单一全局变量代码的负担或许还不是很高。

但是,一旦全局变量一个套一个。或者可能你的全局变量和错综复杂的大规模对象纠缠在一起。很快,你就会意识到你已经写了一堆乱糟糟并藏有大量bug的代码,以至于你很难在短时间内追踪到到底会发生什么。

与饱受非议的V语言中的“goto”相类似,全局变量并非像是易传染和无法治愈的病毒那样会迅速而无法挽回地污染你的代码库。你会在一段时间后为之付出一些代价,但是这并不值得你花费五分钟时间来挽救。如果你必须要引入一个全局变量,那你最好花一点时间来考虑一下你接下来要做的事情将有些糟糕。

测试负面和边界情况

我曾今供职于一家公司。作为其招聘流程个一部分,这家公司的会要求潜在雇员们实现一个棋盘游戏,并且要附上测试。他们的解决方案最后被扔给了我来进行评估, 由于某些原因,对于这些方案我会以苛刻的眼光来做出评价。

我有意对测试的标准含糊其辞,那是因为不想帮助任何人蒙混过关, 充其量只会告诉你们如何编写测试。现在,让我们假设要写的这个游戏是棋盘游戏界最简单的那个: Tic Tac Toe。

我想要在这些方案中抓住的重点是它们是否提供了像下面这样的测试套件:

board = """

    X--

    -X-

    --X

"""

assert check_winner(board) == "X"

board = """

    OOO

    ---

    ---

"""

assert check_winner(board) == "O"

就是像这样的,针对特定获胜状态的两到三个测试。(通常都没有对放置一块以后是否可以工作进行的测试,不过我们先不管这个。)

我总是会表达这样的看法。上面的测试可以检测出你的代码是正确的,不过它们并不会检查出你的代码并非错误的。如果一局游戏没有获胜者会如何呢,你的代码会如何考虑这件事情?

这一个要难理解得多的问题,我留给了你们来解决! Tic Tac Toe 棋盘只有少数几个能获得胜利的状态,用脚趾头就能数的清, 但是非获胜状态的数量就要大得多了。而我所想的是我们真正要面对的坑是获胜是正面定义的 — “三个必须在一行” — 而非获胜则只是被定义为没有获胜。通常我们不会倾向于认为这样的定义缺少了某些具体的东西。

当我在进行测试时,我会注意到在编写代码的时候碰到的那些 BUG,会思考自己的算法哪儿出问题了,然后根据经验做出猜测,进行一些测试。所以也许我得检查一下是否这些棋盘会没有获胜者 :

OO-     -O-     -X-

O-X     -O-     --X

-XX     -X-     X--

左边的棋盘每一种符号都有三个,但是都不在一行。中间的棋盘三种都各自在一行。右边的棋盘有三个在一行的情况,但是除非棋盘允许这样子玩。

上述这些情况是否是假的正面情况呢? 我并不清楚。我所要做的就是考虑有没有那么一个发生错误的一刻, 之后就会让某些棋盘发生那种类型的错误。(就是我抓住的那么一两类会让事情变得异常的情况需要来编写测试!)

同样也要有这样的想法 — 我是不是漏掉了什么? — 这样的想法会引导我迅速地找出这个测试套件中是否有另外一个明显的遗漏之处: 如果有一个Tie会如何呢? 

而我确实在提交的几个解决方案中发现了没有处理一个Tie的。(Tie 不太可能向在 Tic Tac Toe 游戏程序中那样在实际游戏中出现。) 游戏可能会要你挪一挪,而你也许不能这样做,而游戏也许就永远僵在那儿了。

不要因为为自己编写了测试用例就沾沾自喜,认为自己做了一件正确事情。 编写测试只是为了检测你之前的操作是否有误。将正在测试的代码作为对手,你会如何发现其中的错误?

光说不练假把式, 我想给这个假设的测试套件进行素性测试(注:素性测试是指测试给定的数是否为素数)。

def test_is_prime():    assert is_prime(2)    assert is_prime(3)    assert is_prime(5)    assert is_prime(11)    assert is_prime(17)    assert is_prime(97)

重点:写一些通过这些测试的代码。 称之为测试驱动开发实践。

这是我想出来的代码:

def is_prime(n):    return True

word 天!

从反测试代码中的的确确看出了一个好处:它确实能够运行。 我看到一两个测试,无法合理地验证一些程序的输出是否真正正确,所以他们运行程序检查出来的程序并没有错误。

后来,测试套件出了问题,程序默默地停止了运行 - 这自然也没有产生异常。 一个单独的测试应当输入错误的数据进行测试,这样我们就能正确地找到错误的原因。

重构

测试即编码。如果你多次重复已经做过的事情,或者对于一些常见的任务有很多冲突,重构之。写一些helper工具。看看你的测试工具是否可以帮助你。

测试即代码。不要写一些不可思议的、复杂的、脆弱的垃圾来推动你的测试。如果你无法证明你的测试是正常工作的,那么你的测试如何说服你来正面其他代码是正常工作的呢? 你应该对你的测试代码比其他代码更有信心,但你可能花费更少的时间来维护它。 即使你必须坚持重复自己,也应对明显的和无聊的错误同样处理。

比较麻烦的一些情况

外部状态

如果测试一些你的程序之外的东西就比较麻烦了。对于文件系统,你能建立临时目录,对于时间,你也可以设置假的时间。通常,如果你将所有的外部状态整合在一个尽可能小的地方,你以后的工作就容易多了:容易明白,容易测试,也容易替换为其它方案。

对于数据库就比较麻烦了。数据库存取遍及大多数需要访问数据库的代码。

在 Python 网络开发社区,比较通用的方法是在小型的 SQLite 数据库上运行测试集。这是一个好主意,除非你突然被限制到一个 SQL 子集,不然它能在 SQLite 和你的目标数据库上同样的工作。次好的方法是运行一个真实的实例然后就此打住。

然后你可能停在这儿,没有什么更好的办法了。即使对于带有非常复杂数据库的大型 App,似乎这也是你能做的最好的选择。你可能每个测试需要花掉 20 分钟去执行一个完全重复的 setup 或缓存或者其它什么东西,但是我也没有什么更好的主意了。

问题是数据库存取仍然通过 SQL,并且 SQL 完全是你通过网络传输的另一种编程语言。你要替换为一个进程内的 SQL 实现并不方便——这被叫做 SQLite。你能把所有的数据库存取隐藏在一个带有非常长的名字的函数中,并且复杂难懂的返回值只能在一个地方被调用,然后替换为一个测试桩,但是这一点也不好玩儿。特别地,它没有检查你的 SQL 是否正确。

如果你正在用 ORM,你稍微有一些机会,但是我从来没有看见一个 ORM 能在内存数据结构中原生执行查询。(我希望如此,好像这也在可能的范围内,但是它可能需要大量的工作并且仍旧没有覆盖所有角落的针对于你的数据库的函数和语法。)

我不知道,我一无所获。

程序生成和其他随机性

假设你编写过NetHack,它生成了一些2D塔式迷宫地图。当这些地图结构是完全随机时,你怎么可以测试生成的迷宫是否正确呢?

我并没有对此太过深入,但我认为这里有一些很多可以探讨的地方。你并不知道输出应该是什么样的,但你肯定在头脑中有一些限定。例如,迷宫地图中应至少有10%的迷宫壁和至少30%的开放空间,对吧?否则它就不是迷宫了。你可以写一个验证这种情况的测试,然后再运行多次。

你不能百分百地确定没有边界情况(除非你当初非常聪明地编写地图生成器的逻辑),但每次运行并通过测试都会让你更有信心。每次测试运行都有推倒重来的风险,所以你必须对诊断和解决任何可能出现问题保持更高的警惕。

如果你在物品生成器中尽可能多地提供了确切参数,而不是依靠内部算法生成的话,你还可以编写一些更具体的测试。也许你的迷宫生成算法需要一个参数来指定有多少开放空间,从0.3到0.9。如果你把它设置到最低限度,是否还有一个从入口到出口的开放路径呢?你也可以测试下。

网络输出

这是一个有趣的问题。 HTML 比图像更容易校验;你可以解析它,使用XPath、CSS selector或其他你手头的工具深挖数据,并检验正确的文本是否在正确的位置。

但!你可能更想知道它看起来像什么,这可困难多了。显然,自动化测试浏览器,截取屏幕截图,并将其与好的渲染进行对比 —— 所有这些都会在某人将边框像素设置为 1 时无效。我不知道我们我们还能怎样把它做得更好,除非我们可以以某种方式向电脑解释什么是“看起来像”的意思。

我想看到的是针对HTML + CSS的自动化检查。布置页面而无需呈现它,并检查是否有任何明显的错误,诸如重叠文本或不必要的溢出。

我不知道它的实际作用有多大(或者它是否已经存在),但这倒是个简单的检查自己是否犯了严重错误的好方法。你甚至不必在测试套件中使用它 —— 只需将其插入到爬虫里面并将其放在你的站点上即可。

GUI 和游戏

噢,我不太懂这个。抱最好的希望,然后保持你的 UI 与内部组件分离,测试内部结构吧。

最重要的一点

尝试去测试一下。 从零开始测试到一次测试完成是无限的改进过程。

一旦你拥有一个测试套件的桩,你就有一些东西可供参考,下一个测试将会更容易一些。 你甚至可能发现自己在添加功能时,突然想起来,嘿! 这是一个很好的机会,可以写一两个快速测试。

推荐阅读

关于 Java 你不知道的 10 件事

构建 React.js 应用的十佳 UI 框架,都在这了!

为何 Node.js 成为了 Web 应用开发的最佳选择?

惊呆了,Servlet 3.0 的这个特性竟然99%的人都还不知道!

电子凭证 —— Java 生成 Pdf

点击“阅读原文”查看更多精彩内容

相关文章推荐