### 添加更多测试
现在,我们应该继续添加更多测试。我遵循的风格是:观察class该做的所有事情,然后针对任何一项功能的任何一种可能失败情况,进行测试。这不同于某些程序 员提倡的「测试所有public函数」。记住,测试应该是一种风险驱动(risk driven)行为,测试的目的是希望找出现在或未来可能出现的错误。所以我不会去测试那些仅仅读或写一个值域的访问函数(accessors),因为它们太简单了,不大可能出错。
这一点很重要,因为如果你撰写过多测试,结果往往测试量反而不够。我常常阅读许多测试相关书籍,我的反应是:测试需要做那么多工作,令我退避三舍。这种书起不了预期效果,因为它让你觉得测试有大量工作要做。事实上,哪怕只做一点点测试,你也能从中受益。测试的要诀是:测试你最担心出错的部分。这样你就能从测试工作中得到最大利益。
TIP:编写未臻完善的测试并实际运行,好过对完美测试的无尽等待。
现在,我的目光落到了read()。它还应该做些什么?文档上说,当input stream到达文件尾端,应该返回-1 (在我看来这并不是个很好的协议,不过我猜这会让C程序员倍感亲切)。让我们来测试一下。我的文本编辑器告诉我,我的测试文件共有141个字符,于是我撰写测试代码如下:
~~~
public void testReadAtEnd() throws IOException {
int ch = -1234;
for (int i = 0; i < 141; i++)
ch = _input.read();
assertEquals(-1, ch);
}
~~~
为了让这个测试运行起来,我必须把它添加到test suit(测试套件)中:
~~~
public static Test suite() {
TestSuite suite= new TestSuite();
suite.addTest(new FileReaderTester("testRead"));
suite.addTest(new FileReaderTester("testReadAtEnd"));
return suite;
}
~~~
当test suit (测试套件)运行起来,它会告诉我它的每个成分——也就是这两个test cases (测试用例)——的运行情况。每个用例都会调用tearDown(),然后执行测试代码,最终调用tearDown()。每次测试都调用setUp()和tearDown()是很重要的,因为这样才能保证测试之间彼此隔离。也就是说我们可以按任意顺序运行它们,不会对它们的结果造成任何影响。
老要记住将test cases添加到suite(),实在是件痛苦的事。幸运的是Erich Gamma和Kent Beck和我一样懒,所以他们提供了一条途径来避免这种痛苦。TestSuite class有个特殊构造函数,接受一个class为参数,创建出来的test suite会将该class内所有以"test"起头的函数都当作test cases包含进来。如果遵循这一命名习惯, 就可以把我的main()改为这样:
~~~
public static void main (String[] args) {
junit.textui.TestRunner.run (new TestSuite(FileReaderTester.class));
}
~~~
这样,我写的每一个测试函数便都被自动添加到test suit 中。
测试的一项重要技巧就是「寻找边界条件」。对read()而言,边界条件应该是第一个字符、最后一个字符、倒数第二个字符:
~~~
public void testReadBoundaries()throwsIOException {
assertEquals("read first char",'B', _input.read());
int ch;
for (int i = 1;i <140; i++)
ch = _input.read();
assertEquals("read last char",'6',_input.read());
assertEquals("read at end",-1,_input.read());
}
~~~
你可以在assertions中加入一条消息。如果测试失败,这条消息就会被显示出来。
TIP:考虑可能出错的边界条件,把测试火力集中在那儿。
「寻找边界条件」也包括寻找特殊的、可能导致测试失败的情况。对于文件相关测 试,空文件是个不错的边界条件:
~~~
public void testEmptyRead()throws IOException {
File empty = new File ("empty.txt");
FileOutputStream out = new FileOutputStream (empty);
out.close();
FileReader in = new FileReader (empty);
assertEquals (-1, in.read());
}
~~~
现在我为这个测试产生一些额外的访对test fixture (测试装备)。如果以后还需要空文件,我可以把这些代码移至setUp(),从而将「空文件」加入常规test fixture。
~~~
protected void setUp(){
try {
_input = new FileReader("data.txt");
_empty = newEmptyFile();
} catch(IOException e){
throw new RuntimeException(e.toString());
}
}
private FileReader newEmptyFile() throws IOException {
File empty = new File ("empty.txt");
FileOutputStream out = new FileOutputStream(empty);
out.close();
return newFileReader(empty);
}
public void testEmptyRead() throws IOException {
assertEquals (-1, _empty.read());
}
~~~
如果读取文件末尾之后的位置,会发生什么事?同样应该返回-1。现在我再加一个测试来探测这一点:
~~~
public void testReadBoundaries()throwsIOException {
assertEquals("read first char",'B', _input.read());
int ch;
for (int i = 1;i <140; i++)
ch = _input.read();
assertEquals("read last char", '6', _input.read());
assertEquals("read at end",-1,_input.read());
assertEquals ("readpast end", -1, _input.read());
}
~~~
注意,我在这里扮演「程序公敌」的角色。我积极思考如何破坏代码。我发现这种思维能够提高生产力,并且很有趣。它纵容了我心智中比较促狭的那一部分。
测试时,别忘了检查预期的错误是否如期出现。如果你尝试在stream被关闭后再读 取它,就应该得到一个IOException异常,这也应该被测试出来:
~~~
public void testReadAfterClose() throwsIOException{
_input.close();
try {
_input.read();
fail ("no exception for read past end");
} catch (IOException io) {}
}
~~~
IOException之外的任何异常都将以一般方式形成一个错误。
TIP: 当事情被大家认为应该会出错时,别忘了检查彼时是否有异常如预期般地被拋出。
请遵循这些规则,不断丰富你的测试。对于某些比较复杂的,可能你得花费 一些时间来浏览其接口,但是在此过程中你可以真正理解这个接口。而且这对于考虑错误情况和边界情况特别有帮助。这是在编写代码的同时(甚至之前)编写测试代码的另一个好处。
随着tester classes愈来愈多,你可以产生另一个class,专门用来包含「由其他tester classes所形成」的测试套件(test suite)。这很容易做到,因为一个测试套件本来就可以包含其他测试套件。这样,你就可以拥有一个「主控的」(master)test class:
~~~
class MasterTester extends TestCase {
public static void main (String[] args) {
junit.textui.TestRunner.run (suite());
}
public static Test suite() {
TestSuite result = new TestSuite();
result.addTest(new TestSuite(FileReaderTester.class));
result.addTest(new TestSuite(FileWriterTester.class));
// and so on...
return result;
}
}
~~~
什么时候应该停下来?我相信这样的话你听过很多次:「任何测试都不能证明一个程序没有臭虫」。这是真的,但这不会影响「测试可以提高编程速度」。我曾经见过数种测试规则建议,其目的都是保证你能够测试所有情况的一切组合。这些东西值得一看,但是别让它们影响你。当测试数量达到一定程度之后,测试效益就会呈现递减态势,而非持续递增;如果试图编写太多测试,你也可能因为工作量太大而气馁,最后什么都写不成。你应该把测试集中在可能出错的地方。观 察代码,看哪儿变得复杂;观察函数,思考哪些地方可能出错。是的,你的测试不可能找出所有臭虫,但一旦进行重构,你可以更好地理解整个程序,从而找到更多臭虫。虽然我总是以单独一个测试套件开始重构,但前进途中我总会加入更多测试。
TIP:不要因为「测试无法捕捉所有臭虫」,就不撰写测试代码,因为测试的确可以描捉到大多数臭虫。
对象技术有个微妙处:继承(inheritance)和多态(polymorphism )会让测试变得比较困难,因为将有许多种组合需要测试。如果你有3个彼此合作的abstract classes ,每个abstract classes 有三个subclasses,那么你总共拥有九个可供选择的classes,和27种组合。我并不总是试着测试所有可能组合,但我会尽量测试每一个classes,这可以大大减少各种组合所造成的风险。如果这些classes之间彼此有合理的独立性,我很可能不会尝试所有组合。是的,我总有可能遗漏些什么,但我觉得「花合理时间抓出大多数臭虫」要好过「穷尽一生抓出所有臭虫」。
测试代码和产品代码(待测代码)之间有个区别:你可以放心地拷贝、编辑测试代 码。处理多种组合情况以及面对多个可供选择的classes时,我经常这么做。首先测试「标准发薪过程」,然后加上「资历」和「年底前停薪」条件,然后又去掉这两个条件……。只要在合理的测试装备(test fixture)上准备好一些简单的替换样本,我就能够很快生成不同的test case (测试用例),然后就可以利用重构手法分解出真正常用的各种东西。
我希望这一章能够让你对于「撰写测试代码」有一些感觉。关于这个主题,我可以说上很多,但如果那么做,就有点喧宾夺主了。总而言之,请构筑一个良好的臭虫检测器(bug detector)并经常运行它;这对任何开发工作都是一个美好的工具,并且是重构的前提。
- 译序 by 侯捷
- 译序 by 熊节
- 序言
- 前言
- 章节一 重构,第一个案例
- 起点
- 重构的第一步
- 分解并重组statement()
- 运用多态(Polymorphism)取代与价格相关的条件逻辑
- 结语
- 章节二 重构原则
- 何谓重构
- 为何重构
- 「重构」助你找到臭虫(bugs)
- 何时重构
- 怎么对经理说?
- 重构的难题
- 重构与设计
- 重构与性能(Performance)
- 重构起源何处?
- 章节三 代码的坏味道
- Duplicated Code(重复的代码)
- Long Method(过长函数)
- Large Class(过大类)
- Long Parameter List(过长参数列)
- Divergent Change(发散式变化)
- Shotgun Surgery(散弹式修改)
- Feature Envy(依恋情结)
- Data Clumps(数据泥团)
- Primitive Obsession(基本型别偏执)
- Switch Statements(switch惊悚现身)
- Parallel Inheritance Hierarchies(平行继承体系)
- Lazy Class(冗赘类)
- Speculative Generality(夸夸其谈未来性)
- Temporary Field(令人迷惑的暂时值域)
- Message Chains(过度耦合的消息链)
- Middle Man(中间转手人)
- Inappropriate Intimacy(狎昵关系)
- Alternative Classes with Different Interfaces(异曲同工的类)
- Incomplete Library Class(不完美的程序库类)
- Data Class(纯稚的数据类)
- Refused Bequest(被拒绝的遗贈)
- Comments(过多的注释)
- 章节四 构筑测试体系
- 自我测试代码的价值
- JUnit测试框架
- 添加更多测试
- 章节五 重构名录
- 重构的记录格式
- 寻找引用点
- 这些重构准则有多成熟
- 章节六 重新组织你的函数
- Extract Method(提炼函数)
- Inline Method(将函数内联化)
- Inline Temp(将临时变量内联化)
- Replace Temp with Query(以查询取代临时变量)
- Introduce Explaining Variable(引入解释性变量)
- Split Temporary Variable(剖解临时变量)
- Remove Assignments to Parameters(移除对参数的赋值动作)
- Replace Method with Method Object(以函数对象取代函数)
- Substitute Algorithm(替换你的算法)
- 章节七 在对象之间搬移特性
- Move Method(搬移函数)
- Move Field(搬移值域)
- Extract Class(提炼类)
- Inline Class(将类内联化)
- Hide Delegate(隐藏「委托关系」)
- Remove Middle Man(移除中间人)
- Introduce Foreign Method(引入外加函数)
- Introduce Local Extension(引入本地扩展)
- 章节八 重新组织数据
- Self Encapsulate Field(自封装值域)
- Replace Data Value with Object(以对象取代数据值)
- Change Value to Reference(将实值对象改为引用对象)
- Replace Array with Object(以对象取代数组)
- Replace Array with Object(以对象取代数组)
- Duplicate Observed Data(复制「被监视数据」)
- Change Unidirectional Association to Bidirectional(将单向关联改为双向)
- Change Bidirectional Association to Unidirectional(将双向关联改为单向)
- Replace Magic Number with Symbolic Constant(以符号常量/字面常量取代魔法数)
- Encapsulate Field(封装值域)
- Encapsulate Collection(封装群集)
- Replace Record with Data Class(以数据类取代记录)
- Replace Type Code with Class(以类取代型别码)
- Replace Type Code with Subclasses(以子类取代型别码)
- Replace Type Code with State/Strategy(以State/strategy 取代型别码)
- Replace Subclass with Fields(以值域取代子类)
- 章节九 简化条件表达式
- Decompose Conditional(分解条件式)
- Consolidate Conditional Expression(合并条件式)
- Consolidate Duplicate Conditional Fragments(合并重复的条件片段)
- Remove Control Flag(移除控制标记)
- Replace Nested Conditional with Guard Clauses(以卫语句取代嵌套条件式)
- Replace Conditional with Polymorphism(以多态取代条件式)
- Introduce Null Object(引入Null 对象)
- Introduce Assertion(引入断言)
- 章节十一 处理概括关系
- Pull Up Field(值域上移)
- Pull Up Method(函数上移)
- Pull Up Constructor Body(构造函数本体上移)
- Push Down Method(函数下移)
- Push Down Field(值域下移)
- Extract Subclass(提炼子类)
- Extract Superclass(提炼超类)
- Extract Interface(提炼接口)
- Collapse Hierarchy(折叠继承关系)
- Form Template Method(塑造模板函数)
- Replace Inheritance with Delegation(以委托取代继承)
- Replace Delegation with Inheritance(以继承取代委托)
- 章节十二 大型重构
- 这场游戏的本质
- Tease Apart Inheritance(梳理并分解继承体系)
- Convert Procedural Design to Objects(将过程化设计转化为对象设计)
- Separate Domain from Presentation(将领域和表述/显示分离)
- Extract Hierarchy(提炼继承体系)
- 章节十三 重构,复用与现实
- 现实的检验
- 为什么开发者不愿意重构他们的程序?
- 现实的检验(再论)
- 重构的资源和参考资料
- 从重构联想到软件复用和技术传播
- 结语
- 参考文献
- 章节十四 重构工具
- 使用工具进行重构
- 重构工具的技术标准(Technical Criteria )
- 重构工具的实用标准(Practical Criteria )
- 小结
- 章节十五 集成
- 参考书目