Archive for 2012年9月

关于单元测试(续)

2012/09/24

最近「知乎」上有几个关于王垠的问题,由此得知他写的《我和 Google 的故事》。最近得知他又放弃了博士学位。虽然对他的作为不是完全赞同,但是感慨他才华出众而且有勇气做想做的事情。一个匿名用户在一个问题下给出了这样的答案:

王垠是做学术的,而且是 PLT 程序编程语言领域。所以在这个领域他批评 Google 有足够多的理由。毕竟四大语言 C++, Java, Python, 和 JavaScript 在语言设计上可以吐嘈点在普通程序员里已经很多,更不用说一个此领域的博士生。而其中说到 Python,JavaScript 的语法语义分析,这本就是其研究领域的强项。

王垠为什么看不起 Test Driven ? 这个要从他的研究背景和方向就很容易理解。他在读书时候写作业,老板的要求就远超过 Test Driven。程序不但要求从输入到输出是正确,还要求程序能倒推运行,即给出输出结果,能产生所有可能的输入。王垠做的,是逻辑编程的方向,目的是通过逻辑推导演算去保证程序的正确性。为此不难理解,为什么他对测试是那么不以为然 —— 用大量测试就能保证程序逻辑正确么?

我相信王垠在形式化方法方面的成就绝非限于 toy problem。当初看到 L4 内核的正确性被形式化证明的消息也很感兴趣。但之后发现此类方法需将源代码经过某种建模转换才能带入形式化符号系统。对于实际的产品,这种转换可能过于简化。感觉在软件开发中的实际作用有限。

不过最近有关 TDD 的思考让我有了一些新想法,似乎形式化方法的实际意义并非那么「有限」。至少,相对于很多人鼓吹的 TDD 方法的根基 —— Unit Test 这种特定类型的测试,形式化方法的局限性还那么突出吗?

复杂的软件系统中一个 class 的实例化 (instantiation) 和执行通常需要调用所依赖的其它 dependent class 的代码。Class 之间的 dependency 是 Unit Test 的最大障碍之一,因为 Unit Test 需要尽量避免触及被测 class 以外的代码,特别是运行速度较慢或附带影响较复杂的代码,例如需要访问数据库或文件系统。所以 Unit Test 需要名目繁多的 dependency breaking 技术 —— 避免调用 dependent class 代码的方法。常用的 dependency breaking 技术包括 interface/implementation extraction、mock object、parameterise constructor、static setter 等等。Interface/implementation extraction 方法为 Unit Test 特地增加产品代码的继承层次。Mock object 在增加的继承层次下构建虚假的 dependent class,令其功能从测试角度模拟真实的 dependent class,同时避免访问数据库等不利于 Unit Test 的因素。

这些所谓的「技术」让我非常不舒服。因为它们触及到测试存在本质原因 —— 人们无法保证代码的正确性。在测试前根据某些假定对代码肆意地修改,等于是在宣布:我认为自己可以无需测试即猜测这段代码的这部分是正确的,可以替换为一个 mock object 而无损对其它部分的验证。实际上,软件开发者(包括测试者)根本无法通过测试以外的手段证明软件的任何一部分是正确的。Dependency breaking 本身违背测试的根本原则。

在 Robert L. Glass 的《Facts and Fallacies of Software Engineering》里,有这样一个 Fact:

Fact 33

Even if 100 percent test coverage were possible, that is not a sufficient criterion for testing. … 40 percent (of software defect emerge) from the execution of a unique combination of logic paths. They will not be caught by 100 coverage.

注意 Robert 这里指的是包括系统测试在内的各种测试。那么,可以想像在充斥 mock object 的情况下,分离测试单个函数的功能的 Unit Test 能在多大程度上保持代码的正确性。

我相信有些人已经开始准备反驳 —— 不能因为 Unit Test 的效果不够完美就否定其存在的意义。它还是能相对提高代码的质量。那么,我觉得这些评价无疑承认 Unit Test 只是一个「聊胜于无」的技术。但不要忘记这个「聊胜于无」的技术是有成本的,如果它的投入产出效率不及其它替代技术,那么连「聊胜于无」这个称号也无法胜任。上一篇《关于单元测试》提过了 SCM 和 TDD 的投入产出效率比较。这次回到王垠的研究领域。如果我们认为形式化证明存在模型过于简化这样的局限性,不妨审视一下 TDD 所鼓吹的 dependency breaking,其本质无非也是一种建模简化技术。TDD 在建模简化之后仍然需要搭建一个基本环境实际运行幸免于修改的代码来验证其正确性。而形式化方法全过程都无需搭建运行环境。似乎后者的成本更低。

以我有限的理论基础,不能像之前 SCM 和 TDD 的比较那样,对形式化方法的效率下一个比较确定的结论,也许形式化方法目前还不够用于实际的软件产品开发。即便如此,我相信形式化方法比 TDD 更有前景。因为形式化方法的一切缺陷,Unit Test 在本质上都无法避免;另一方面,鼓吹 TDD 的人丝毫不以为自己在进行着过度简化,也不认为自己鼓吹的技术有其它更有效率或者更有前景的替代物。

关于单元测试

2012/09/04

关于测试驱动开发 (Test Driven Development) 的争议之一是,耗费相当人力编写单元测试代码能否真的带来开发效率的净收益。

TDD 的拥趸认为答案是肯定的:完备的单元测试可以极大缩短从代码修改到发现错误的 turn-around 时间,在理想情况下,每次修改后仅需几分钟的单元测试可以立即发现并定位错误,修复错误并保证代码质量的开销得以降低。他们抱怨在缺乏单元测试的情况下,即使测试团队积极参与,从代码修改提交到发现问题通常也会经过几天时间。如果测试团队仅运行系统功能测试而缺乏模块级别测试,发现问题甚至可能推迟到几周之后。TDD 拥趸认为如此长的 turn-around 时间必然极大地增加定位错误的困难。当然,TDD 的拥趸也承认接近理想的单元测试并不轻松,除去编写和维护一套并行于产品代码并且需要和产品代码同时演进的测试代码的工作量,还必须用 extract interface/implementation 、parameterize constructor 、mock object 等对代码清晰度构成干扰的 dependency break 手段改造产品代码。

几乎所有我熟悉的同事,包括我自己都对单元测试有诸多怀疑。切身感受告诉我们,TDD 的鼓吹者似乎遗漏了某些环节。比如说有这么一项技术,它本身远非完美,需要程序员花费一定精力来掌握和操作,但是消耗的精力低于 TDD,对产品代码清晰度的干扰也低于 TDD,又能保证和 TDD 相当的代码质量。从推行 TDD 耗费的精力来看,存在一种投入产出更有效率的技术的可能性并不是悲观的。

最近我似乎找到了这项技术。TDD 的拥趸很少提及版本管理系统 (Source Control Management System, SCM 系统) 的作用。我不是说那些鼓吹 TDD 的人都不会用 SCM,但是他们似乎没有意识到 SCM 的关键作用。而像我们这样的人又往往认为 SCM 在软件开发中的核心地位透明得像空气一样,以至于在讨论中疏于提及 SCM,想当然地认为他人不至于忽视这个技术。但是 TDD 拥趸的各种论述似乎说明他们对 SCM 的认识远远低于我们的想象。

SCM 鼓励程序员采用一种大胆尝试的态度进行开发。现代的分布式 SCM 系统 (DSCM) 提供低开销的 branching 功能,鼓励为目的单一的短小修改创建 submission,而非为整个 feature 构建巨大的单个版本。Braching 和维护多个短小修改并非没有开销,但是相对来远低于 TDD。实践这些版本管理策略让 code base 成为一组可以单独 revoke 的 submission 序列 (或者说一组序列,因为 branching 还为 revokability 的粒度提供进一步的层次管理) 。Revoke 较早提交的 submission 通常并不会干扰较晚提交的 submission。这正是 TDD 的拥护者在鼓吹他们的技术时所忽视的另一种保证质量的方式,他们把 code base 看成是一个冰激凌蛋筒,先放进去的冰球被后放进去的死死压住。所以在放每一个冰球的时候都要异常小心地舔两口来保证质量。更糟糕的是,全面单元测试容易鼓励一种习惯:既然每次修改由单元测试保证正确,小的修改就不必进入 SCM 系统成为单独 submission,只有完整的 feature 才被提交到 SCM 系统。甚至可以怀疑 TDD 正是 Subversion 统治 SCM 的黑暗中世纪诞生的极端思想。

而我们这类人希望把 TDD 所消耗的精力节省下来用于让 SCM 发挥最大的作用。保证每一次 submission 尽量短小并互不干扰。保证每次 submission 之后的 code base 都处于一个基本 deliverable 的状态,让 experimental branch 和 main branch 的距离恰当的体现代码的成熟度。固然这也需要相当的技巧和努力,它对产品代码清晰度产生的是正面促进而非干扰,无需平行地维护一套产品之外的测试代码。SCM 令 design for testability 让位于 design for clarity。

在 SCM 应用良好的项目中,从代码修改到发现 bug 的 turn-around 时间可能会有所增加,但不会增加定位问题的难度。在中等团队中,turn-around 时间的增加只是在吞吐量 (through-put) 和反应度 (responsiveness) 之间的自然取舍,并无实质资源的浪费。基于 SCM 的产品更像操作系统的 page fault 机制,可以而且仅在真正出现问题的地方投入资源。退一步说,在真正出现问题的地方,SCM 可以赋予开发者在局部采用 TDD 模式的自由。如果在正常情况下一种模式比另一种模式开销低,而且前者可以在异常情况下无缝切换到后者,那么后者根本不值得作为常态的工作方式。

人类预测未来的能力非常糟糕,工具的发展趋势是帮助人们把分配资源的决定尽量推迟到必须做决定不可的时候。那种寄希望于预先精细计划并分配资源来保证正确性的 wishful thinking,就像「瀑布模型」和「premature optimization」,必须被更灵活的决策机制取代。