作者武可投稿,在简书上写了 6.2 万字,获得 103 个喜欢。常和人谈论 TDD。
在向开发人员介绍单元测试或 TDD 等工程实践时,往往可以听到这样的疑问。比如:
自己写的程序,自己无法从另一个角度测出问题。
写 bug 的时间都不够了,哪有时间来写测试?
开发来写测试了,测试干什么?
除了核心代码,没有什么值得测试的。
……
本篇想要通过探讨这些问题背后的困难,来说明程序员怎样通过编写自测代码更有效率的进行开发。
一个例子
首先我们看一个例子。
全项目唯一的测试
不止一次,我在各种项目中看到这样的测试,往往这也是整个工程中唯一一个测试。
可以看出,开发者认为编写是有必要的。所以按照“标准”的做法建立了测试目录,引入 JUnit 依赖。并且利用它在开发的初期来验证某些技术疑问,一般是某些当时还不熟悉的第三方库,或者数据库、中间件等外部依赖。
项目初期技术调研阶段很快过去后,似乎没有更多需要验证的问题。因而也就再没有需要编写测试的地方。
简单而言:“写测试是应该,但我们的代码没什么好测的”
测试,不仅仅关于未知
说起测试,往往与未知相关联。我们通过测试、调试、检测来获取反馈,不断调整。
以上图为例,一般想到的测试,都集中在“已知的未知”这个象限。正如前面的示例代码,使用不熟悉的库带来未知。程序员通过在测试中调用和观察结果来消除未知。
然而,对于自动化测试来说,其实关注点在于已知。
“都已知了,还测试什么呀?”,也许你会有这样的疑问。
火柴问题
火柴,这种行将消失的物品。也许现在的小朋友只是在《卖火柴的小女孩》中才得知它的存在。在我小时候,还是时常用到的。那时,也许是工艺问题,或者存储条件有限,往往一盒火柴好多根都不能点着。记的那时听到的笑话:
小明的妈妈让他去买盒火柴,不一会功夫买回来了。妈妈问:“你试过没有,能点着吗?”
“试过啦”,小明很骄傲的说,“每一根我都试了一遍。”
我把这种问题称为“火柴问题”,往往传统的质量控制面临的都是这类问题,有如下限制:
成本,显然现实中不会有人把所有的火柴拿来测试。不过问题的本质并没有变,在花费的成本和获得安全性之间取一个平衡。
事后,造出火柴后才有能否点着的问题。
一次性,成本换取的安全是一次性的,每当一个批次到来时,以前的测试的付出都成为了沉没成本。
另一种测试
让我们来看另一种关于已知的测试。
Checklist
检查清单。
比如每天出门的时候,我都会自然而然的检查一遍,手机、钥匙、钱包。就是个简单的清单。
清单是关于已知的,只有十分确定的事项才会列入在清单里。
清单本身很简单,并不能回答火柴问题这样的难题。但是不代表它没有作用。以出门为例子,有时出门是每天都在做的上班通勤,有时是去面临某个很大的未知,比如去见一个陌生的客户,进行重要谈判。
这时如果有个水晶球,告诉你会成功失败,甚至告诉你怎样做才能成功,那就太好了。
然而没有水晶球。
一个简单的清单至少保证你不会走在路上才发现忘带手机。无论未知的挑战是什么,忘带手机基本上不会产生任何帮助。
切换回软件开发的场景,程序员梦想中的完美测试也许能告诉我们未知,甚至未知的未知结果。这在目前还不现实。那么写一个测试确保你在不断调整中不破坏正确的事情,仍是值得的。
可以看到,这种视角下的验证,与检查火柴有所不同:
预防,这种校验着眼于未来,是为了避免更大的损失的投入。
过程中,检查是做事情步骤中的一个环节。
反复,越频繁的行为越有必要进行校验,校验的越频繁潜在收益越大。
假定你是独自居住,出门前还是锁门后发现没带钥匙的成本,会有一个巨大的飙升。往往检查列表都是在这种成本拐点前进行的。
checklist 和成本
应对这种猛增的成本曲线有三种方式:
拉平曲线,通过技术改进使原本难以挽回的决定变得不那么昂贵。
优化待检查项目,比如现在出门带钱包已经不那么重要了,有手机即可。如果把门换成扫码开锁,那么钥匙也免了。这样需要检查的项目越少,越不容易遗漏。
自动化,比如遗漏了东西就有提醒报警,自然大大降低了犯错的可能。
自测给程序员带来什么
敏捷方法论的一个基础,就是现代软件开发方式已经使软件变更的成本曲线大大平缓了。我们可以看看开发者的自测在其中起到的作用。
错误反馈等级
错误定位等级
对照上面两个列表,可以回想一下
在最近的开发活动中碰到各类错误的比例是多少?
由于反馈时间和定位手段不同,解决错误话费的时间有何不同?
有多少最初百思不得其解的错误,长时间摸排后定位为一行修改即可改正的弱智错误?
如果这些错都在第一时间发现,以明显的方式报错会怎么样?……
自动化
自动化投入时间对照表
这张表是值得花多少时间把某项工作自动化,比如左上角第一表格表示,一个需要一秒的操作,如果在未来 5 年每天执行 50 次,那么花 1 天时间自动化它是值得的。
事实上这张表仅仅是花费时间的简单数学计算。考虑到注意力节省的话,其实可以花费更多的时间。
大家都可能都有过手工部署环境的经历,假定有 10 个步骤,操作只要 1 秒,然后等待 30 分钟进行下一步。理论上来说这一天只需要花费 10 秒在这个任务上,不过试过的人都知道,这天能有平时一半的产出就很不容易了。
注意力是很贵的。自动化节省的不止是时间。
记录
常玩游戏的同学都熟悉要时常存盘,可以让我们安心挑战 boss,大不了失败时返回安全点。
那么代码呢?Git,SVN 等代码管理工具使可靠的保留代码历史成为可能。然而,如何在历史中找到安全点呢?(题外话,你有尝试过 Git bisect 命令么)
记录还带来了另一件事,复盘。
没有记录也就无法从系统的进行回顾和改进。对于编码,我们往往只能看到最终的结果。这大概也就是编码活动在软件开发“工程—艺术”图谱中最偏向与艺术这一极的原因吧。
频繁提交的代码历史,加上表达行为变化的测试,会使原本大家熟视无睹的进程如实呈现出来。有兴趣的话可以看看 这篇 cyber-dojo 设计者的讲演,我们甚至仅仅观察测试变化的情况就可以对一段程序编写的过程有个大致的了解。
可以通过测试改进的点
把 main 函数改为测试
有经验的开发者大多都知道写出的代码都至少要运行验证一遍。然而运行代码有时并不那么简单,有的要以待定的方式部署,有的需要复杂的前置流程才能触及。为了高效的运行代码,我们会采用一些手段,比如为目标代码增加一个 main 函数,这样就可以直接以希望的输入执行想要的操作,并观察结果。
这种调试技巧可以很容易的用测试来改写,如下图所示。
main vs test
在基本不增加工作量的前提下,带来如下收益:
明确的分离了调试代码和生产逻辑。避免误导后来维护代码的人,也防止把测试代码发布到生产环境产生隐患。
抹平了“调试期—维护期”的成本差异。main 方法的往往是在调试阶段使用。开发人员反复调整输入、观察输出、修正代码,直到开发完成。之后这段调试程序就成为了过去时。后来者无法判断这段脚手架代码是否还符合最新的逻辑,是否可以运行。而测试代码在每次构建时都会自动检查,保证代码保持上次变更后预期的逻辑。为开发者保留了一个调试现场,是否“开发完了”并无显著差异。
测试可以记录多种用例。使用调试方法,我们往往在确认完一个行为后修改输入,观察其它行为。因为预期这是一次性的工作。用测试可以在不同的用例中描述行为的不同侧面。方便维护者理解代码,也避免了,“咦,这个 bug 我明明测试过呀”的回归错误。
测试明确写出了期望的行为。通过 assert,测试明确的写出可以自动判别的行为。而不是 main 方法中通过肉眼来阅读理解程序行为。写出预期会带来如下该变:
□帮助阅读者理解什么是代码“应该的”行为。
□促使开发者思索代码的目的是什么,会怎样被使用。
□自动判断节省了开发者的注意力,更有效的反馈错误,定位错误。
用隔离依赖代替调试“高仿”代码
所谓高仿代码,是指与现实代码非常接近,但是稍有不同的代码。往往在调试时,目标代码并不是纯粹的逻辑处理,还会涉及到其他的外部依赖。这些依赖可能要单独部署配置,甚至根本无法在开发环境获得。
为了对付这种情况,一个显而易见的方法是把目标代码 copy 一份到调试代码处,修改依赖相关的部分。比如下图就演示了一段代码,需要根据外部依赖判断执行某操作,并更新数据库。为了测试执行操作的逻辑,开发者 copy 了代码,注释掉与环境相关的代码。
copy code vs test
另一种类似的处理方法,在每次调试时临时修改目标代码,调试结束后再恢复。
这种情况,只需要结合 mock 框架对外部依赖进行模拟,就可以在不改变目标代码的情况在测试中改变代码行为。如上图所示。
这种做法有避免了显而易见的问题:
copy 代码方式在经历修改后,不能保证于实际生产代码一致。
临时修改代码后事后忘记恢复的风险。
除此之外,还有些潜移默化的收益:
使隐含的输入输出更加明显了。比如例子中的代码,从外部看起来只有一个字符串输入一个字符串输出。通过测试可以明确的看到,事实上输入还有从外部依赖获取的布尔值,输出还有对数据库的操作。
促使代码向松耦合、单一职责演化。有时候为了在测试中 mock 隔离依赖,会需要对现实代码稍作重构。短期看来似乎写测试引发了更多的工作量和变更,但这种变更一般会使代码向职责更明确,模块间更松耦合的方向改变。促使开发者在设计层面更多的思考。
用测试来增强注释
适当的注释能极大的增强代码的可维护性。好的注释描述代码在 做什么 ,而非 怎么做 的。
对于复杂结构的处理,往往看代码千头万绪,摸不着头脑。注释里附上示例数据,马上让人对代码的大致行为有所掌握。
comments vs test
将这种注释中的样例放入测试中,可以:
避免代码修改注释无人维护的问题。
把不同的输入和对应输出一一对应起来。
利用自测促进开发
前面说了一些通过自测手段对已有工作方式的改进。事实上在熟悉掌握这些手段后,可以更近一步,主动利用测试来完成原来不能高效率做到的事情。
分解“已知的未知”
对于未知的解决方案,有时是由于我们对于相关技术了解有限。也有一种情况,技术方面已经确定,但是由于问题较为复杂,一时看不到解决方法。
面对这种问题,一般的做法是 构造式 的。也就是说从自己知道的方案出发,看看需要增加什么来接近目标,增加后调整整体一致,再次看需要增加什么……
还有一种 分解式 的方式。假定已经有了一个解决方案,从中选取一个子集,解决这个子集,然后选取下一个,直到完全解决。测试就很适合在这种方法中对问题进行分解和检验。
在最近的一次练习中,我就体会到即使没有开始编码,测试也能对解决问题起到帮助。
练习:写一个函数,判断两个字符串是否同构。
所谓同构,是指字符串 A 可以通过字符串换变为字符串 B。
比如:
Hello 与 Apple,不同构
Hello 与 Speed,同构
有兴趣的同学可以自己尝试尝试,能否通过测试逐步分解问题找到解决方案。提示:从最简单确定的问题开始,比如一个字母的字符串如何判断。
显现“未知的已知”
有多少次,当你正在开发调试的过程中,发现了某种更好的做法。然而思索后你对自己说:“已经差不多写好了,算了,还是以后再改吧”。即使这个改动只是给函数起个更贴切的名字而已。
而我们都知道,以后往往等于永远也不会。
造成这种状况的,除了我们固有的弱点,比如拖延、图省事外,有个很重要的原因是难以评估改变的影响。还记得前面错误反馈列表么?如果几个月后才会知道有没有问题的改动,就算再简单我们也会避免。这就是遗留代码的处境。
众所周知,不产生 bug 的最佳方法就是不写、不修改代码。当然这是不现实的。所以会有两种局部化变更影响的方式。
原木式
码出的结构
不同的用例逻辑好像木材一样码在一起,彼此类似又稍有不同。好处显而易见,新增一条木头并不会影响另一条木头。
缺陷是出现切片式的变更时会发生散弹式修改。随着代码历史变长每条木头间的微妙差异会越来越难以分辨是无意的不同步,还是有业务含义的特性。
沉淀式
沉淀出的结构
在有控制的摇动/静置中,不同关注点的逻辑逐步分层,基础的逻辑越来越沉淀到下方,越来越稳定。易变的逻辑浮在顶层,但是影响的范围越来越少。
缺少控制的情况下,这种组织方式是不可行的。足够的测试正是用来显现和保持这种沉淀的必要条件。
说走就走的旅行
回到标题的问题,程序员为什么要自测,与测试人员所做测试的区别。测试人员更多着眼于火柴问题式的未知,关于软件在不确定的使用中是否达到预期的效用。
开发人员的自测更多着眼于检查清单式的已知,关于软件在不确定的修改中是否保持已知的行为。
尽管并不直接回答未知的问题,掌握已知,是我们应对未知的保证。就像背包探险的旅行家。组织有序的行囊,是说走就走,走向未知风景的保证。
验证已知,让机器帮助检验,为了更好的探索未知。
测试是为了更好的改变,而不是防止改变。
多个简单、具体的特例,可以描述复杂、一般化的逻辑。
评论