测试遗留代码
/* I have no idea how this works but it seems to. Whatever you do, don't touch this function, and don't break this code! (虽然我不知道这段代码起什么作用,但是看上去它似乎是有用的。无论您做什么,一定不要碰这个函数, 不要破坏这段代码!) */
如果您曾经遇到过带有此类注释的代码,这种情况并不少见。因为没有人了解这些系统, 所以有时候就使用规则约束禁止进入整个系统;但是仍然需要对这些系统进行维护。即使是 一个已经完全没有 bug 的系统(又有哪个系统能够完全没有 bug?), 外部环境的改变也会使代码的改变成为必要。Y2K 营业额是一个最大且最明显的例子。欧元的引入对于某些金融系统来说相当于造成外伤。Sarbanes-Oxley 引入了新的以前不存在的报告要求,而且为了支持这些新规则,必须对遗留软件进行翻新。这个世界不是静态的,所以软件也不能是静态的,它必须向前发展否则就 会被代替。
好消息是,测试驱动开发不仅仅适合于新代码。即使是程序员维护老代码时也可以 利用它编写、运行以及通过测试。对于已经在生产中的遗留系统,测试确实更加 重要。只有通过测试,您才能确信您对于系统中的某一部分所做的改变不会中断其他地方的另外一部分。当然,您可能没有时间或者经费为一个规模庞大的代码基础达到 100% 的测试覆盖率,但是即使是并不完美的覆盖率也能减少失败的风险,加速开发并且产生更加健壮的代码。
本文使用 jEdit 做为例子,向您展示如何为从未测试过的遗留代码开发一个单元测试套件。jEdit 是一个流行的开放源码的文本编辑器,它完全没有任何测试套件!但是我将马上开始对其进行修改。在本文中,我着手开发一个测试套件,其目的是为了使将来 jEdit 的开发更加多产、高效并且有趣。
中国有句老话,千里之行始于足下。遗留代码的测试套件首先开始于一个单独的测试。重点是做什么和从何做起。不要掉入相信因为不能够测试每行代 码所以就不能够测试任何东西的陷阱。只管打开您的 IDE 并且开始编写测试。使用 JUnit(或者 NUnit,或者 CppUnit,或者任何您喜欢的框架)和一个一般的 IDE,您通常就能够在 20 分钟以内编写出第一个测试。编写测试要比编写模型代码简单得多。测试很小并且具有独立的代码块。它们不需要很多配置、思考和理解。您不需要 “专业知识” 来艰难地编写出高质量的测试。
测试套件需要做的第一件事情是直接到达方法的中心。寻找您能够做的最大范围、最全面的测试。对于一个独立的应用程序,可能是 main()
方法。例如,这是我的第一个 jEdit 测试用例。它所做的就是运行应用程序的 main()
方法并且检验它是否在屏幕上输出了正确的窗口:
import java.awt.Frame; import junit.framework.TestCase; public class MainTest extends TestCase { public void testMain() { org.gjt.sp.jedit.jEdit.main(new String[0]); // make sure there's a window on the screen Frame[] allFrames = Frame.getFrames(); for (int i = 0; i < allFrames.length; i++) { Frame f = allFrames[i]; if (f.isFocused()) { assertTrue(f instanceof org.gjt.sp.jedit.View); } } } } |
第一个测试的目的不是在边界条件上费力,也不是为了查看解决了什么问题。 第一个测试是一个发烟试验,目的是为了对于什么可能是错误的给您一个清晰的概念。 即使最基本的测试也不能揭示出构造系统、运行时环境、已安装的软件以及对每件事情进行本质上的破坏的其他主要问题中存在的问题。我的第一个测试用例确实能够准确地发现 jEdit 代码 基础的这样一个问题:在我的类路径中没有包含所有可能的目录。
我并没有开始测试类路径配置,但是我寻找到的问题也是重要的,因为它可能导致代码基础很难调试。类似这种的全面测试涉及到应用程序的很多方 面。很多不同的东西能够中断并且导致测试失败。就这种意义上说,并不是非常统一。在 test-first 编程中,这不是一个问题;但是当测试遗留代码时,您没有时间或者预算为每个单独的方法或者分支编写独立的测试。您必须在编写每个测试时尽量地覆盖尽可能多 的方法和分支。使用一些测试来测试大部分代码比根本不进行测试要好。
测试 main()
方法并不作用于所有的应用程序。例如, 库中不包含 main()
方法。一些确实具有 main()
方法的应用程序可能也不希望这个方法被不止一次的调用。 如果您这样做的话,静态初始化软件会非常混乱。它们可能不去清除一些对象和类,因为它们假定当程序存在时,虚拟机也存在,所有对象和类都将被自动清除。如果事实如此,您可能需要更深层地观察您的应用程序,寻找第一个测试点。
但是,不要走的过深。对于相对于 test-last 开发来说的 test-first 开发,尤其是对于遗留代码来说,您可能会遇到一个问题是,经常存在未公开的依赖关系和先决条件。一些方法假定其他对象存在并且在它们运行前已经创建了。例如,大多数菜单栏不会脱离它们的父窗体单独起作用。
实际上,如果您试着不止一次地调用 main()
方法,jEdit 就会变得非常混乱。我希望能一次也不调用它。但是,很多其他代码依赖于 jEdit.initSystemProperties()
方法已经被调用,并且这个方法是私有的。执行它的惟一方法就是调用 main()
。我采用的解决方法是,只有当 main()
方法一次也没被调用过的时候才调用它,如下所示:
private static boolean hasMain = false; protected void setUp() { if (!hasMain) { jEdit.main(new String[0]); hasMain = true; } View view = jEdit.getFirstView(); while (view == null) { // First window may take a little while to appear view = jEdit.getFirstView(); } menubar = view.getJMenuBar(); } |
如果能够自由地重构正在测试的代码,您的工作会简单些。特别是将一些私有方法变成公有方法能够使这个代码编写起来更容易。在 test-first 开发中,这些都不成问题,因为您倾向于将代码编写得易于测试。然而,遗留代码几乎不考虑可测试性,因此,您必须消除这样的阻碍。
一旦编写了第一个测试,您就能够经常快速地从同一个框架下开发更多的测试。 将初始化和清理代码放入 setUp()
和 tearDown()
方法中,注意从那里您可以真正快速地编写多少测试。例如,我编写过一些基础测试来保证 jEdit 菜单栏出现并且在正确的地方显示正确的菜单,如清单 2 所示:
package org.jedit.test; import javax.swing.*; import org.gjt.sp.jedit.*; import junit.framework.TestCase; public class MenuTest extends TestCase { private JMenuBar menubar; private static boolean hasMain = false; protected void setUp() { if (!hasMain) { jEdit.main(new String[0]); hasMain = true; } View view = jEdit.getFirstView(); while (view == null) { // First window may take a little while to appear view = jEdit.getFirstView(); } menubar = view.getJMenuBar(); } public void testFileIsFirstMenu() { JMenu file = menubar.getMenu(0); assertEquals("File", file.getText()); } public void testEditIsSecondMenu() { JMenu edit = menubar.getMenu(1); assertEquals("Edit", edit.getText()); } public void testHelpIsLastMenu() { JMenu help = menubar.getMenu(menubar.getMenuCount()-1); assertEquals("Help", help.getText()); } // Tests for other menus... } |
与典型的 test-first 编程不同,我在此处编写了很多测试代码却没有必要编写任何模型代码。标准的 TDD 只编写能够使一个测试失败的足够多的代码。然后,它就切换到模型代码中直到测试通过为止。 我并不是说 TDD 原则是错误的,但当两百万行的遗留模型代码已经存在时,它的确不能成为一个有用的选择。 那时的目的是为了尽快地获得尽可能大的覆盖率。
如果遗留系统是一个好的系统,您的大多数测试还是很有希望通过的。尽管如此,您也会找到 bug。当测试一个以前从未经过测试的代码基础时,这种情况很可能很快就出现而不是以后才出现。这时,标准的 TDD 方法是停止测试并且开始进行修改直到测试通过。然而,这是假设您已经测试了模型中的其他所有内容并且相当自信如果 您的修改中断了系统中的其他部分,您可以立即 发现。在遗留测试中,这不是一个安全的方法。在修改一个以前的 bug 时,您很有可能 将一个新的 bug 引入未经测试的代码中;而且假如这样的话,您可能不能立即注意到这个新 bug。因此,强烈建议首先编写更多的测试,稍后再对 bug 进行修复。 归根结底,这是一种基于如下一些因素的判断调用:
- bug 是不是看起来简单、明显并且是局部的?
- 您是否理 bug 出现的那段代码?
- 您是否理解所作的修复?
如果上面问题的回答都是 “是”,那么就去修复这个 bug。如果回答是 “否”,则应该在修复改代码之前首先尽力扩充测试套件。
在最高层次上开始测试能够以最快速度获得代码覆盖率。对于遗留代码,应该考虑应用程序 在做什么而不是考虑单个方法。尽量为它所做的每件事编写测试。对于一个 GUI 应用程序如 jEdit,菜单项提供应用程序功能的一个好的典型。激活每个菜单并且证实它做了应该做的。例如,清单 3 展示了这样的测试,向一个窗口输入一些文本,激活 "select all" 菜单项,剪切所选中的文本,然后验证文本在剪贴板中,而不在窗口中:
public void testCut() { JEditTextArea ta = view.getTextArea(); ta.setText("Testing 1 2 3"); JMenu edit = menubar.getMenu(1); JMenuItem selectAll = edit.getItem(8); selectAll.doClick(); JMenuItem cut = edit.getItem(3); cut.doClick(); assertEquals("", ta.getText()); assertEquals("Testing 1 2 3", getClipboardText()); } private static String getClipboardText() { Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard(); Transferable contents = clipboard.getContents(null); if (contents != null && contents.isDataFlavorSupported(DataFlavor.stringFlavor) ) { try { return (String) contents.getTransferData(DataFlavor.stringFlavor); } catch (Exception ex){ return null; } } return null; } |
很可能我在这个方法中测试的有点太多。最好将一些测试转移到装备中。 无论如何,尽一切办法进行测试。
当您已经测试了应用程序的基本功能,考虑代码中的替代路径就变得非常重要了。 在大多数语言中,您可以按照下面的步骤分解您的测试:
- 为每个程序包或者模板编写一个测试。
- 当每个程序包都有了至少一个测试,再为每一个类编写一个测试。
- 当每个类都有了至少一个测试,再为每一个方法编写一个测试。
- 当每个方法都有了至少一个测试,使用一个代码覆盖率工具如 Cobertura 为 每个分支编写一个测试,直到每一行代码都能够被测试。
您也可以在第 4 步之前使用一个代码覆盖率工具,但是我宁愿您手动完成前面的步骤。 虽然很多类、程序包和方法都可以通过功能测试进行测试,但是当您从一个程序员的 角度而不是从一个用户的角度查看程序时,经常会发现不同的问题。
实际上,在很多情况下,您从来都不会接触到第 4 步。您完全没有时间或者预算来编写 每个可能的测试。这样也可以,因为做一些测试和不作测试的区别比做所有的测试和做一些 测试的区别更大更重要。
可以使用反射生成一个测试骨架。这样能够更容易找到您需要测试的所有公有方法。每个测试都像这样开始:
public void testMethodName() { fail("Test Code Not Written Yet"); } |
这种方法不好的一面是会立刻得到成百上千个失败的测试。一个可供选择的方法是在每个测试中添加一个 TODO 注释而不是完全失败。然后当时间允许时,您再检查并补充这些测试。
public void testMethodName() { // TODO fill in test code } |
如果您使用的是 JUnit 4,您能够简单地将测试注释为 @Ignore
,直到您将它们填写完,例如:
@Ignore public void testMethodName() { // TODO fill in test code } |
在这种情况下,运行测试提醒您它虽然跳过了测试,但是它不会使您失望。 实际上,它给您评为 Pass-Fail-Incomplete。
您能够找到很多可以为您自动编写测试的工具。然而,这些工具生成的测试往往 相当琐碎和基本,例如方法是否能够处理传递来的 null 参数。这种工具不能真正了解每个类 和方法应有的功能。所以,您需要人类的智能。
在这一阶段很可能会令您吃惊的是,您的代码中有多少是您实际上并不需要的。遗留代码基础往往具有很多残留代码,这些代码现在已经不需要了,尽管在当时是必需的。越老的遗留代码,您会找到越多的残留代码。有时候,这些代码是明显不可达的(未调用的私有方法以及 未读入的本地变量,等等)。这种类型的残留代码也可以通过静态代码分析工具如 PMD 和 FindBugs 找到。有时失效代码看起来并不是那么明显,只有为了测试试图到达它时才能发现实际上的残留程度。
不管您用何种办法找到这种残留代码或者无论任何理由 它原来被放在这里, 都将它去掉。您需要维护的代码越少越好。
进入一个遗留系统,您常常对哪里进行检查有好的想法:您对于某个特定的模块、程序包或者是 环境设置有问题,这些问题驱使您进行测试。在这种情况下,尽一些办法使您的测试集中在那个区域。
有时会发现非常清楚和明显的 bug。修复它之前,首先编写一个测试。然后运行这个测试证实测试失败。然而,经常令人吃惊的是,关于这个 bug 的第一直觉并不正确,测试通过了。测试失败与否,都不要把它丢掉。它对于将来的开发仍然是有价值的。把它放在您的测试套件中,继续编写其他的测试。重复进 行,直到找到一个真正失败的测试,从而找到造成 bug 的真正原因。
不要过分追求完美。即使您有一个大规模的未经测试 的遗留代码基础,现在就开始为它编写测试吧。不要为达到获得百分之百的覆盖率过多担心。您所编写的 每个测试都会增加您对代码的信心、排除 bug 并且为将来的开发提供更多的灵活性。 需要增加一个特性?编写一个测试。找到一个 bug?编写另外一个测试。遗留程序员也可以很灵活。
学习
- 您可以参阅本文在 developerWorks 全球站点上的 英文原文。
- “利用 Ant 和 JUnit 进行增量开发”(Malcolm Davis,developerWorks,2000 年 11 月) 介绍 Java 平台上的单元测试。
- “揭开极端编程的神秘面纱: 测试驱动的编程”(Roy Miller,developerWorks,2003 年 4 月):解释关于测试驱动编程的一切,更重要的是解释它与什么无关。
- “TestNG 使 Java 单元测试轻而易举”(Filippo Diotalevi,developerWorks,2005 年 1 月):TestNG 不如 JUnit 那么集中于单元测试和 test-first 编程,这使它在某种程度上成为比较好的测试遗留代码的产品。
- “Keeping critters out of your code”(David Carew、Sandeep Desai、Anthony Young-Garner;developerWorks,2003 年 6 月):对一个服务器端的应用服务器环境进行单元测试的简介。
- “用 Cobertura 测量测试覆盖率”(Elliotte Rusty Harold,developerWorks,2005 年 5 月):展示如何使用一个开放源码的工具识别从未测试过的代码。
- “JUnit 4 抢先看”(Elliotte Rusty Harold,developerWorks,2005 年 9 月):介绍 JUnit 4 的新的基于注释的体系结构。这需要 Java 5 或者更高版本。
- Pragmatic Unit Testing in Java (Dave Thomas 和 Andy Hunt,Pragmatic Programmers,2003 年 9 月):阅读 Dave Thomas 和 Andy Hunt 的书。
- Java 技术专区:数百篇关于 Java 编程各个方面的文章。