第 7 章

红绿循环里的手气

在Clojure的JIRA工单系统里,有些工单的附件不是文档,也不是补丁,而是一小段测试用例。它们通常以一个失败的断言开始,断言的左边写着预期值,右边写着实际值。运行这些测试,终端里会显示一条红色。那条红色不是代码审查环节的产物,也不来自任何人的正式授权。

它只是某个开发者在探索语言行为时,把自己看到的那次失败固定了下来。把时间拨回2008年,这种固定失败的做法还没有名字。那时候Clojure的第一个邮件列表刚刚建立不久,语言本身还在剧烈变动。上一章所说的过滤共识——那套依赖核心团队精力和贡献者耐心的程序装置——仍在运转。

但回到这个起点,它运转起来所需要的精力似乎还相对充裕。因为社区足够小,一封邮件发出去,当天就可能收到三五个人的回复。一个人贴出自己写的函数,另一个人会复制到自己的REPL里试一遍,然后回信说,我试了你的代码,但得到了这个错误。接着贴出一段堆栈跟踪。那时候还没有人把这种行为叫作测试。

参与的人只是把它当成一种确认:你看到的东西,我也能看到;你看不到的地方,我也许能看见。REPL让人可以随手试验一个表达式,却无法保证这个试验明天还能被重复。

这是所有长期使用过解释器的人都会慢慢察觉到的经验。在REPL里,你键入一个表达式,按下回车,结果就出现在下一行。如果出错,会返回一串异常信息,颜色往往是红色。很多开发者刻意把错误输出配置成红色,不是为了好看,而是为了在快速刷屏时能一眼认出哪一行不值得继续读下去。

红色是一种信号,告诉你某个假设不成立。但它是瞬时的。今天下午三点你看到过一条红色,修复了代码,五点的时候它已经不在你的终端历史里了。明天打开电脑,你甚至想不起触发错误的具体条件是什么。如果没有记录,下周另一个人——或者就是你自己——可能会在同样的地方再次跌倒。

测试所做的,正是将那种瞬时信号变成可以反复读取的标记。一个用deftest写下的断言,就像把REPL里那条红色的堆栈跟踪钉在了代码仓库的墙上。

每次运行测试套件时,如果那条红色重新亮起,所有人都能看见。这条路径并不是从工程规范里推导出来的。

Clojure并没有一个专门的QA部门来推动这件事,也没有任何文件规定一个补丁必须附上测试。它是在日常使用中慢慢长出来的。开发者在REPL里试验一个函数,试验通过之后,把这次试验转录成几行断言。这个过程最初只是个人的备忘。后来有人把它发到邮件列表上,其他人看见了,觉得有用,也在自己的代码里这么做。

当足够多的人都在做这件事时,它才变成了一种公共的默契。这种默契的一个早期痕迹,出现在邮件列表上关于代码分享的方式里。

2008年前后,如果有人分享一段Clojure代码,常常会附带一句说明:这段代码在我的机器上可以运行。这句话既是一种谦辞,也是一种邀请。它承认眼前的结果无法被直接传递,只能靠另一个人在自己的环境里重新跑一遍。于是“我也试了”就成了常见的回复。

这不是自动化测试,也不是结构化测试,但它的确是一种重复验证:在另一个人、另一个时间点、另一台机器上,对同一个表达式进行确认。当有人回信说“我试了你的代码,但得到了这个错误”,那条错误信息本身就是最早的失败测试,只不过它没有被保存下来。

它只存在于邮件存档里,作为一次公开见证。后来有人开始把这些见证保存得更久一些。一个开发者在邮件列表上分享自己为标准库中几个函数写的系统性测试用例。他说,最近在给一些函数写测试,发现有几个边界情况文档里没有覆盖到。

这封邮件并没有引起剧烈的争论,但它引发了一个漫长的讨论。有人问他用了什么框架来写这些测试,他说用的是clojure.test。这是Clojure发布时就自带的一个轻量级测试库,但在那之前很少有人认真使用它。随后几个月里,邮件列表上开始零星出现类似的测试文件分享。人们不是在接受某个权威的指令,而是在模仿一种他们亲眼看见有用的做法。

一个人做了一件有用的事,另一个人看到了,觉得有用,于是也在自己的工作中重复这件事。当足够多的人都在重复这件事时,它就成了一种默认行为。不是“你应该写测试”,而是“如果你不写测试,别人怎么知道你的代码能用”。

这种默认行为的传播方式,正是手艺共识。它不是明文规范,不依赖流程图,也不依赖口头倡导。它依赖于示范、模仿和无声的忽略。一个人看见别人怎么做,觉得自己也能做,就去做了。做顺了,他就不会再回头想这件事是否需要正式批准。

Clojure社区早期关于测试的许多实践,就是这样在邮件列表和聚会中慢慢沉淀下来的。某个人写了一组测试,另一个人把它复制到自己的项目里,改了改函数名,发现也能跑。第三个人在下次提问时,会直接贴出一段最小化测试用例来说明自己遇到的问题。

到了后来,贴出一段会失败的测试,几乎成了提问的一种礼貌。它比长篇描述更清楚,因为它把问题缩小到了一个可以运行、可以观察、可以修改的边界里。

这种实践之所以能够在Clojure社区里存活,并不因为它被强制执行。核心团队从来没有发布过一份测试标准,邮件列表上也从来没有就“是否应该写测试”进行过正式投票。它存活下来,是因为它被转化成了一种半公开的身体记忆。开发者知道何时该让红灯亮起,也知道何时可以暂时关掉那盏灯。

红灯不是惩罚,而是练习失败的空间。一个人在新代码里先写下一个会失败的断言,然后看着它变绿,这个过程会在他手上留下一种节奏。那种节奏一旦形成,就很难再退回原来的工作方式里去。它比任何书面规范都更不容易遗忘。

这种节奏的形成,需要有一个可以反复练习的场所。Clojure的REPL恰好提供了这样的场所。它让试验和确认之间的间隔变得极短。开发者可以先在REPL里反复试几种写法,看哪一种能跑通,然后把跑通的过程转成测试。这里没有“先写测试还是先写代码”的教条。

早期的许多讨论都承认,REPL的存在让Clojure开发者可以比那些依赖编译-运行-检查循环的语言开发者更快地获得反馈,因此不必机械地遵循先写测试的流程。里奇·希基本人开发Clojure时,也经历过相对漫长的孤立尝试。他在没有外部资金的情况下,将其大部分时间都专门投入到了Clojure的工作上。

那段经历刻进语言里的,不是一套测试规范,而是一种对运行时反馈的信任。Clojure鼓励不可变值与持久数据结构,把并发看成状态到状态之间变化的管理。这种设计使得许多复杂问题可以在REPL里被拆成很小的表达式来观察。测试则是把这些观察固定下来。一个人可以先用REPL试出一个正确结果,再把这个结果变成断言。这个顺序没有人规定,却成了最常见的路径。

到了2010年前后,随着Clojars社区包仓库逐渐热闹起来,测试开始以另一种形式进入公共视野。库的作者在发布新库时,会自愿在说明文件里写上一句这个库的测试情况。

有的写“有测试”,有的写“部分函数有测试”,还有的写得更具体,说某些边界情况已经覆盖,某些图形界面相关部分不太好自动化。这些信息不是强制披露,也没有人要求统一格式。它们更像是一种额外的保证:你可以信任这个库,因为它的行为的一部分已经被反复验证过。

这种自愿信息披露很快被其他库作者模仿。没有人制定过规则,但到了后来,如果一个新的库里完全没有测试,邮件列表上就可能有人问:这个库有测试吗?这个问题本身没有惩罚性,但它构成了一种软性的压力。新入者想要让自己的库被认真对待,就会观察那些已经被认真对待的库是怎么做的,然后照着做。他也许并不完全理解为什么要写测试,也许只是不想被人追问。但当他第一次在REPL里试验自己的代码,再把这些试验转录成断言时,某种东西已经在他身上沉淀下来。

这种压力也会遇到反弹。因为Clojure社区关于测试的共识,从来就不是铁板一块。它始终在“先写测试”的倡导和“先玩起来”的宽容之间摆动。

一个还处在试探阶段的想法,如果被要求立刻配上完整测试,有时会压住创新的苗头。有些开发者会坚持说,在REPL里验证过就行,没有必要再写测试。另一些人则会反驳,REPL只能证明代码在你手上能用一次,测试才能证明它在别人手上也能用。这两种声音在邮件列表上交替出现,谁也没有彻底说服谁。

社区处理这种分歧的方式,并不是通过投票或裁决,而是通过一种基于声誉的软性区别。如果一个库的作者是已知的资深开发者,他的库即使测试覆盖不全,也会获得一定程度的信任。人们相信他有足够的判断力,知道哪些部分需要测试,哪些部分可以暂时放一放。而一个新人提交的库如果没有测试,就可能会收到更直接的询问:这个库有测试吗?

这种区别并不公平,它本质上让有声誉的人享有更大的自由空间。但它在实践层面维持了一种脆弱的平衡。这种平衡之所以能够维持,是因为早期的Clojure社区足够小,人和人之间多半相互认识,或者至少认识彼此的名字。

一个人贴出库时,读者知道他是谁,知道他曾经写过什么,知道他在邮件列表上的发言风格。这种个人信誉赋予了实践一种不言自明的权威性——不是因为他是老板,而是因为他被看见过怎么做。那个人在REPL里敲下deftest的动作,被另一些人在会议视频、邮件存档或别人的转述中看见。

后来者在自己的机器上重演这个动作,于是红绿循环变成了一种可以离开师傅身体继续传递的手艺。这种传递方式有一个隐含的前提:演示者和观看者之间必须存在某种直接或半直接的接触。当社区还足够小时,这种接触是自然发生的。

线下会议、配对编程、屏幕共享、邮件列表里的长期潜水,都可以构成接触的通道。新人可能没有见过某位资深开发者本人,但他在邮件列表里读过对方的帖子,在某个会议视频里看过对方演示,所以当他看到那段代码时,心里已经有了一个可以参考的动作序列。这种接触不同于阅读文档。文档给出的是规则,而接触给出的是手感。

一个人可以从文档里学会deftest的语法,却不一定能学会在什么时候写第一个失败测试、什么时候只做一次快速的REPL验证就够了。这些判断往往只能从别人做事的方式里揣摩出来。

2013年到2014年间,Clojure社区经历了一次显著的增长。语言本身获得了更多的关注,周边书籍出版,ClojureScript作为将Clojure编译到JavaScript的姊妹项目吸引了前端开发者,一些公司在生产环境中采用Clojure的案例被媒体报道。邮件列表的订阅人数增加,JIRA里的新工单数量翻倍,Clojars上的库数量突破四位数。

社区变大之后,那种依赖个人接触的传递方式开始承受压力。新入者不再是通过观看某个资深开发者的演示来学习测试的。他们可能来自其他编程语言社区,带着各自关于测试的既有习惯和期待。有人习惯用行为驱动开发框架写测试,有人习惯用注解标记测试类,有人习惯在文档字符串里嵌入可运行的示例,还有人只习惯在浏览器控制台里手动点击验证。

这些习惯在邮件列表上相遇时,产生了微妙的摩擦。一个典型的例子是,新人在邮件列表上提问,说现有的测试库语法不够有表达力,问有没有更像某种框架的替代品。这个问题本身是合理的。

因为clojure.test的设计确实非常朴素,只提供了最基本的deftest、is、are和testing这几个宏,没有嵌套上下文,没有复杂的辅助工具。但资深开发者的回应往往并不直接推荐第三方框架。他们更倾向于反问,你具体想表达什么,现有的断言已经足够清晰了。接着有人贴出一段示例,展示如何用testing宏来组织测试描述,如何用are宏来消除重复的断言模板。这些回复的技术内容是正确的,但它们的潜台词是:你不需要另一个框架,你需要的是学会用现有的工具。这不是傲慢,而是一种手艺人的本能。当一个学徒问有没有更好的锤子时,师傅的回答通常是,你先学会用这把锤子。

clojure.test之所以被接受为默认选择,不是因为它功能最强大,而是因为它足够简单、足够稳定,并且与REPL的交互足够自然。你可以直接从REPL里调用一个用deftest定义的测试,看到红绿输出,然后继续试验。这种无缝衔接在工作流中形成了一种节奏。

引入更复杂的测试框架,可能会打破这种节奏,因为它会让你在写测试之前先要做一系列关于组织结构和命名约定的决定。这些决定本身就会打断从REPL试验到测试转录的那条流畅路径。

但新入者的需求并非毫无道理。随着项目规模的增长,测试套件从几十个断言膨胀到几百个、几千个,clojure.test的朴素性开始显露出局限。测试之间的依赖关系变得难以管理,共享的初始化代码需要在每个测试文件中重复,测试失败时输出的信息有时不足以快速定位问题。社区对这个压力的回应方式,并不是由核心团队推出一个官方方案,而是由不同的贡献者各自试验不同的方向,然后让使用者在实践中做出选择。

到了2015年前后,Clojars上已经出现了多种不同的测试相关库。有的提供更简洁的断言语法,有的引入行为描述式风格,有的把基于属性的测试引入Clojure生态,有的则专注于优化测试运行器。这些库之间并不互相排斥。一个开发者可以在同一个项目中使用clojure.test作为基础,用某个库生成随机测试数据,再用另一个工具并行运行测试以缩短反馈时间。这种组合方式不是由某个架构师预先设计的,而是在日常实践中被逐渐摸索出来的。开发者们在邮件列表上分享自己的测试工具组合,就像厨师分享自己的刀具搭配。不是争论哪把刀最好,而是展示在什么情况下用哪把刀最顺手。

这种灵活组合的策略也有代价。它要求开发者对每个工具的设计哲学和适用边界有足够的理解,才能做出合理的组合决策。

对经验丰富的资深开发者来说,这是一种乐趣。但对刚入门的新人来说,这可能是一种负担。有人花了几个小时都搞不清应该选用哪个测试库,最后仍然不确定自己选得对不对。

这样的困惑在邮件列表上并不罕见,得到的回答也多半不是标准答案。因为在Clojure社区里,关于测试工具的标准答案从来就没有存在过。这正是手艺共识的特征:它提供的是实践示范而非选择指南。你可以看到别人是怎么做的,但你必须自己决定自己要怎么做。

这种自主选择的压力,在“何时写测试”的问题上表现得更为尖锐。Clojure社区早期有一种被广泛默许的工作方式:先在REPL里把功能探索出来,等代码形态稳定后再补写测试。这种方式尊重了REPL作为探索工具的优势。一个人可以在几分钟内试验十几种不同的实现思路,如果每次试验都要先写测试,探索的速度就会大幅降低。

但这种宽容也带来了问题。当“先探索后补测”变成普遍实践时,“后补测”中的“后”有时会被无限期地推迟。一个开发者在REPL里验证了自己的代码在自己的机器上能工作,然后直接把代码发布到Clojars上,没有测试,没有持续集成,没有在不同环境下的验证。

当另一个人下载了这个库并在不同的上下文里调用它时,它可能就会崩溃。邮件列表上曾经出现过一次关于这个问题的短期论战。起因是一位库的作者在回复一个bug报告时说,这个函数在我的REPL里是可以用的,我不确定为什么在你的环境里不行。另一位开发者回复说,这正是为什么我们需要测试。REPL只能证明代码在你手上能用一次,测试才能证明它在别人手上也能用。

这个回复获得了很多支持,但也引发了一些担忧。有人担心,如果社区过于强调每个库都必须有完整测试,会不会压制那些还处在早期试验阶段的创新项目。

论战没有得出明确结论。Clojure社区的邮件列表讨论很少以正式决议收场。但它在集体意识中留下了一道微妙的裂痕。

一边是稳定性的要求:一个被广泛依赖的库应该有充分的测试覆盖,这样依赖它的项目才不会因为一次升级而突然崩溃。另一边是创新的需要:一个还处在探索阶段的想法需要足够的自由空间来快速试错,过早被测试束缚可能会让它胎死腹中。这道裂痕从未被正式弥合。

社区以一种典型的非正式方式来处理它,那就是通过声誉信号来区分不同的期望。如果一个库的作者是已知的资深开发者,他的库即使测试覆盖不完整,也会获得一定程度的信任。人们相信他有足够的判断力来知道哪些部分需要测试,哪些部分可以暂时放一放。如果一个库的作者是新人,他的库如果没有测试,就可能会收到更直接的询问:这个库有测试吗?这种区分并不公平,但它在实践层面上维持了一种脆弱的平衡。

新人若没有得到这种私下接触,可能永远无法跨越从“写测试是负担”到“写测试是自然延伸”的那道门槛。

一个没有测试的日期处理工具包曾经在邮件列表上引发过正面碰撞。作者写道,我已经在REPL里验证了所有函数,它们都能正常工作。这个库解决了一个真实的需求,代码质量也不错。但因为它没有任何测试,回应是分裂的。一些人热情地欢迎它,表示这正是我需要的功能,并开始在自己的项目里使用。

另一些人则提出了关于测试的问题:你能否至少为最核心的几个函数添加测试,这样我们在升级时就能知道是否有破坏性变更。还有人说得更直接:没有测试的库就像没有刹车的车,它现在能跑,但你不知道什么时候会停不下来。作者对这些回应的反应是防御性的。他回复说,写测试需要时间,我现在更想把时间花在完善功能上。这个回复本身并没有错。对于一个由志愿者在业余时间维护的项目来说,时间确实是最稀缺的资源。

但这件事也暴露了手艺共识传递中的一个断层:这位新入者没有经历过那种在REPL里试验然后顺手转录为测试的日常训练。对他来说,写测试是一项额外的负担,而不是工作流中自然的一部分。

这个案例最终以一种温和的方式解决了。一位资深开发者私下联系了作者,不是去说服他写测试,而是和他一起做了一次配对编程。通过屏幕共享,资深开发者展示了如何在自己的REPL里加载这个库,如何设计几个简单的测试用例,如何将这些用例组织成deftest形式。整个过程花了大约四十分钟。

一周后,作者向邮件列表提交了更新版本的库,附带了二十几个测试用例。这个故事没有被广泛传播。它发生在私下的沟通中,只在后来的一次会议走廊谈话中被偶然提及。

但它精确地展示了手艺共识在规模扩大后所面临的困境:在早期小社区里,这种一对一的示范可以自然地发生。但当社区增长到一定规模后,资深开发者的时间和精力已经不足以覆盖每一个新入者。那些没有得到这种私下示范的人,可能永远无法跨越那道门槛。

这就回到了本章开篇提到的那种测试用例附件。一份附在工单里的失败断言,最终是如何被处理的?它没有被简单地关闭。有人贴出的测试用例被另一个人复制到自己的环境中运行,确认了红灯确实亮起。然后第三个人提交了补丁,修复了导致示例失败的行为不一致性。第四个人将这个补丁与测试用例一起合并到了主分支。第五个人更新了文档示例,使其与修复后的行为保持一致。五个人各自做了一小部分工作,没有一个人承担全部责任,也没有一个人拥有全部权威。这就是过滤共识在测试文化中的运作方式。

不是通过指令链来分配任务,而是通过公开空间中的示范和模仿来协调行动。每个人都在看别人做了什么,然后决定自己可以在哪个环节出力。但这种协调方式的有效性依赖于一个前提:有足够多的人在看着,并且有足够多的人愿意出力。

当社区规模扩大,工单数量增长,而愿意处理工单的人数并没有同比例增长时,这个前提就开始动摇。2014年到2015年间,Clojure的JIRA系统中开始出现一些长期未被处理的工单。它们不是被拒绝了,而是被忽略了。不是因为它们不重要,而是因为没有足够的人手去处理它们。这并非某一个人的责任。这只是规模带来的必然代价。

红绿循环里的手气,那种在REPL中随手试验然后顺手写成测试的流畅节奏,在小规模、高信任、面对面接触频繁的社区里可以自然维持。但当社区扩张到一定规模后,这种节奏开始出现断点。新入者不再能轻易接触到那些可以示范如何做的师傅。资深者的精力被越来越多的工单、邮件和评审请求所分散。

那些曾经通过私下配对编程来传递的隐性知识,开始在一些人身上失传。到了2015年,有人在邮件列表上说,自己最近开始给一些老库补写测试。有些库已经三年没有更新了,但仍然被广泛使用。它们的代码在REPL里还能跑,但没有人知道它们在什么边界条件下会崩溃。这条帖子获得了一些赞同,也获得了一些沉默。那些沉默的人可能在想,是的,这很重要,但我没有时间去做这件事。红灯还在亮着。只是看灯的人越来越少了。

这种看灯人的减少,并不是因为测试在Clojure社区中的地位下降了。相反,测试作为质量门槛的作用从未被否定过。问题在于,维持这道门槛的日常实践,需要一种只能通过接触来传递的手感。

当社区的规模超过了这种接触可以自然覆盖的范围,红绿循环就不再是每个人都能轻易进入的练习。它变成了一种需要额外寻找机会才能习得的技艺。有些人找到了师傅,有些人没有找到。找到的人沿着那条从个人手感到公共承诺的路径走了下去。没有找到的人,可能会留下一个没有测试的库,然后消失。

这正是Clojure社区核心矛盾在测试问题上的一个侧面。测试保护了稳定性,却也给快速试验增添了负担。当社区还小时,人们可以通过日常见面和邮件往来,在稳定与创新之间找到一种个人化的平衡。但当社区变大以后,这种平衡不再自动地传递给每一个新来者。它需要被反复地演示、反复地讲述,而演示和讲述的人,精力并不是无限的。

回到开头那一小段测试用例。它最初只是一个陌生人在终端里看到的一条红色。后来它被贴进工单,被复制,被修复,被合并,被写进示例。整个过程安静、分散、没有仪式。

但它恰好说明了测试在Clojure社区里的真实位置:它不是一套工程规范,而是一段从个人手感到公共承诺的缓慢转变。REPL让人可以随手试验一个表达式,却无法保证这个试验明天还能被重复。测试则把那种瞬间的确认固定下来,使“看起来能用”变成“失败时会出现一条明确的红”。这条红色的灯,至今没有熄灭。只是在这个越来越大的房间里,愿意抬头看一眼的人数,正在变得稀薄。