3.3 测试异常处理
到目前为止,测试都是按照主要路径执行。假如对象的行为以不期望的方式改变,这种类型的测试点是问题的根源。本质上,你将编写诊断测试监视应用程序的运行状态。
但是有时好的程序也会出现坏的情况,应用程序需要连接数据库。诊断测试是否遵从数据库的API,假如打开一个连接但是没有关闭它,诊断能够通过合适的异常提醒测试失败,所有连接使用后必须关闭。
但是假如一个连接不可用,可能这个连接池是空的,或者可能数据库服务器已经死机了,假如数据库服务器配置正确和你拥有需要的所有资源,那么可能永远不会发生。但是所有的资源是有限的,而不仅仅是一个连接。你可以提交一个异常:“任何可能出错的事情都将出错”。
假如使用手工测试应用程序,对这类事情的测试方法是当应用正在运行的时候关闭数据库。强制错误条件是测试灾难恢复能力的一个优秀方式。创建错误条件总是浪费时间的,我们大多数人不能做这个一天几次或者整天。另外,其它许多错误条件使用手工不容易创建。
测试主要执行路径是件好事和要求。测试异常处理是非常重要的,还应包括要求。假如主要路径不工作,你的应用程序将也不会工作(你可能会注意到一个条件)。
JUnit最佳实践:测试可能失败的任何事情
单元测试帮助确认你的方法同其它方法都保持它们的API协议。假如这个协议完全基于其它组件保持它们的协议。那么你的测试有可能不是有用的行为,但是假如方法以任何方式改变参数或者值,然后你应当提供唯一的测试行为。这个方法不在是一个简单媒介,未来的变化可能会打破它自己的行为方法,如果一个方法改变那么它就不再简单,然后你应该在发生变化之后添加一个测试。
由JUnit FAQ提出,一般的哲学是:如果不能破坏它自己,就是它太简单不会出错。这也和极限编程的原则一致:不要过早加入任何功能。
就像JavaBean的getters和setters一样,嗯,这取决于。假如手工使用文本编辑器编写代码。你肯能想测试它们。它是非常容易的错误编码setter方法,在某种程度上,编译器不会捕捉它。
但是如果你使用一个IDE观察这些事,然后你的团队可能决定不测试简单的JavaBean属性。我们是都是人,当异常事件发生时我们常常是草率的。过度减少教材上的错误处理及简化例子。结果,否则许多伟大的程序投入生产而没有错误验证,如果正确的测试,应用程序不应该暴露出屏幕死亡而是应当记录日志,并优雅解释所有的错误。
3.3.1模拟异常条件
在单元测试中使用异常测试用例是明智的,单元测试能够模拟异常条件,同正常情况下一样容易。其它类型的测试,功能测试和接受性测试,是工作在产品的级别。这些测试是否遇到系统错误往往是一件偶然的事,单元测试能够按需生产异常条件。
我们最初的编码灵感,我们在编写基类的时候会加入代码错误处理程序,回顾Listing3.2,
processRequest方法捕捉所有的异常和特别的错误响应:
try{
response = getHandler(request).process(request);
}
catch(Exception exception){
response = new ErrorResponse(request,exception);
}
如何模拟异常测试错误处理程序是否工作?测试处理一个正常的请求,创建一个
SampleRequestHandler然后返回一个SampleRequest(Listing3.5)。测试处理错误条件,可
以创建一个SampleExceptionHandler抛出一个异常代替。如Listing3.11所示。
Listing3.11 Request handler for exception cases
public class TestDefaultController{
private class SampleExceptionHandler implements RequestHandler{
public Response process(Request request) throws Exception{
throw new Exception("error processing request");
}
}
}
上面创建一个测试方法注册处理程序和试图处理一个请求-例如Listing3.12所示。
Listing3.12 TestProcessRequetsAnswersErrorResponse,first iteration
public class TestDefaultController{
@Test
public void TestProcessRequetsAnswersErrorResponse(){
SampleRequest request = new SampleRequest();
SampleExceptionHandler handler = new SampleExceptionHandler();
controller.addHandler(request, handler);
Response response = controller.processRequest(request);
assertNotNull("Must not return a null response", response);
assertEquals(ErrorResponse.class, response.getClass());
}
}
如果通过Junit运行这个测试,它将失败,快速查看消息将知道两件事,第一,需要使用一个不同的测试请求名字,因为这里已经有一个请求名字Test在夹具中。第二,需要给类添加更多的异常处理,因此在产品中RuntimeException不会抛出。
第一个条目,你能试图使用请求对象在夹具中代替自己的。但是将以同时的错误失败(一旦你有一个测试,用它来探索替代编码策略)。需要考虑改变夹具,假如从夹具移除代码,注册一个默认SampleRequest和SampleHandler,你将复制到其他试验方法--不明智,最好修复SampleRequest因此它能被实例化在不同的名字下。Listing3.13是一个重构的结果,改变的地方将以粗体表示。
Listing3.13 testtestProcessRequestExceptionHandler, fixed and refactored
public class TestDefaultController{
[...]
private class SampleRequest implements Request{
private static final String DEFAULT_NAME = "Test";
private String name;
public SampleRequest(String name){
this.name = name;
}
public SampleRequest(){
this(DEFAULT_NAME);
}
public String getName(){
return this.name;
}
}
[...]
@Test
public void testProcessRequestAnswersErrorResponse(){
SampleRequest request = new SampleRequest(" testError");
SampleExceptionHandler handler = new SampleExceptionHandler();
controller.addHandler(request, handler);
Response response = controller.processRequest(request);
assertNotNull("Must not return a null response", response);
assert(ErrorResponse. class, response.getClass());
}
}
1.介绍一个成员字段保存请求的名称和默认设置它以前的版本
2.下一步,介绍一个新的构造方法使你可以通过名称来请求,重写默认方法。
3.介绍一个空的构造器,使存在的调用持续工作,最后调用新的构造器代替。
4.因此异常请求对象不会与夹具相冲突
假如你添加另一个测试方法也使用异常处理器,你可以将它的实例化移动到@Before的夹具中,可以消除重复。
JUnit最佳实践:让测试改进代码
编写单元测试常常帮助你编写更好的代码,原因是简单的:一个测试用例是代码的使用者。只有当使用代码时才可以发现它的缺点。不要犹豫去监听你的测试和重构你的代码,它很容易去使用。测试驱动开发实践依赖这些原则。首先编写测试,然后从用户的角度开发代码。在第五章纤细讲解TDD。
因为重复尚未发生,让我们反对预见变化和让代码停止(极限编程:不要过早地添加功能)。
3.3.2测试异常
在测试中,假如试图使用一个重复名字注册一个请求,发现addHandler抛出一个没有记录(undocumented)的运行时异常,(undocumented:它在签名中没有出现)。看代码,假如请求没有被注册,getHandler抛出一个运行时异常(RuntimeException)。
你是否应该抛出没有记录的运行时异常是一个很大的设计问题(在后面的学习中你将那样做)。现在,编写一些测试证明该方法将表现为设计。
Listing3.14展示两个测试方法证明addHandler和gethandler将抛出运行时异常。
Listing3.14 Testing methods that throw an exception
public class TestDefaultController{
[...]
@Test(expected=RuntimeException.class)
public void testGetHandlerNotDefined(){
SampleRequest request = new SampleRequest("testNotDefined");
//The following line is supposed to throw a RuntimeException
controller.getHandler(request);
}
@Test(expected=RuntimeException.class)
public void testAddRequestDuplicateName(){
SampleRequest request = new SampleRequest();
SampleHandler handler = new SampleHandler();
// The following line is supposed to throw a RuntimeException
controller.addHandler(request, handler);
}
}
1.使用@Test注释方法使它表示成一个测试方法。
2.因为我们将要测试异常条件,我们希望测试方法将生产某种类型的异常,我们需要指定想要产生的异常类型,在@Test注释中使用expected参数指定异常类型。因为这个测试表示异常用例,添加NotDefined到testGetHandler作为标准前缀,这样做使所有getHandler测试同每个文档推导的目的一致。
3.为测试创建请求对象,给她一个明显的命名,通过请求默认的getHandler方法,
4.因为这个请求没有附加处理器,一个RuntimeException异常将被抛出。
JUnit最佳实践:使异常测试易于阅读
在@Test注释中正常的expected参数清晰地告诉开发者什么类型的异常将产生,但是可以想的更深入一些,使用一个明显的命名风格重新命名测试方法,使这个方法表示测试一个异常条件,你也能放置一些评论到去高亮代码行,产生这个expected的异常。
这个控制器类并没有完成,但是你有一个可观的第一次迭代和测试套件证明它工作。现在你能提交这个控制器包同它的测试一起。对项目的代码库和移动到下一个任务清单。
JUnit最佳实践:让测试改进代码
在测试代码中一个容易的方式去鉴定异常路径是检查不同的分支,通过分支,我们指的是IF从句,switch语句,try-catch模块。当你开始如下分支时,有时你可以发现测试每一个分支时痛苦的。假如代码难于测试,它通常很难使用,当测试表明是一个低劣的设计,你应当停止并重构这些代码。万一有太多的分支,通常的解决方案是将一个大的方法划分成几个小的方法,相对低,你可能需要修改类的层次以便更好地表示问题域。其它的解决方法将需要不同的重构。测试时代码的第一个客户,正如谚语所说,客户就是上帝。
下一个任务是时间超时测试
3.4超时测试
到目前为止,我们已测试我们应用的功能性,当提交合适的数据时,它不仅表现在预期的方式,而且也产生期望的结果。现在我们想看看应用测试的另一方面:扩展性.DefaultController类如何扩展?
编写一些测试并期望他们运行在一个给定的时间范围内,这样做,Junit提供另一个参数给@Test注释,叫做timeout,使用这个参数,你能指定时间范围精确到毫秒,假如测试花费很多时间去执行,JUnit将标记测试失败,例如,我们看代码Listing3.15.
Listing 3.15 Timeout tests
[...]
public class TestDefaultController{
[...]
@Test(timeout=130)
public void testProcessMultipleRequestsTimeout(){
Request request;
Response response = new SampleResponse();
RequestHandler handler = new SampleHandler();
for( int i=0; i< 99999; i++){
request = new SampleRequest(String.valueOf(i));
controller.addHandler(request, handler);
response = controller.processRequest();
assertNotNull(response);
assertNotSame(ErrorResponse.class, response.getClass());
}
}
}
1.我们使用毫秒指定timeout参数,我们期望运行的时间范。。
2.声明测试中使用的Request,Response,RequestHandler对象。
3.使用For循环创建99999个Request样本对象并同处理器一起添加到控制器。最后,控制器涉及到的processRequest()方法,并断言我们得到一个非空Response对象和不是一个错误的响应Response。
你可能认为130毫秒需要优化,你是对的。这个时间范围有点小。但是执行时间依赖于硬件资源(cpu速度,内存大小)。也依赖于软件资源(操作系统,java版本等等)。对于不同的开发者而言,这个测试可能失败或者通过。而且,当添加更过的功能到processRequest()方法中,我们选择的时间范围将可能变得不足。
言归正传,其中一些测试超时可能失败在整个开发的版本中,有时最好跳过这些测试。在
JUnit3.x中,我们不能不改变测试方法的名字,但是在JUNit4.x中,有一个很好的方式去跳过测试,唯一需要做的事是在@Test注释后面使用@Ignore注释。看Listing3.16代码。
Listing3.16 Ignoring a test method in JUnit 4.x
[...]
@Test(timeout=130)
@Ignore(value="Ignore for now until we decide a decent time-limit")
public void testProcessMultipleRequestTimeout(){
[...]
}
就像你看到的,我们仅仅添加一个@Ignore注释到方法上,注释接收value参数,插入一段信息表明为什么跳过测试。
JUnit最佳实践:总是指定跳过测试的原因
如前面列表所示,我们指定信息为什么需要跳过执行测试,这样做是一个好的实践,首先,通知后面的开发者你为什么想要跳过执行这个测试,第二,证明你自己知道测试做了什么,而不是因为它失败而忽略它。
正如我们所提到的,在JUnit3.x中只有一个方式去跳过执行测试方法,就是重命名测试方法或者将测试注释掉。没有指明有多少测试时跳过的。在JUNit4.x中,当时是用@Ignore注释方法,你将得到有多少测试跳过,有多少测试执行通过和失败。
3.5介绍Hamcrest匹配器
统计表明,人们很同意被单元测试的原理所感染。一旦习惯编写测试,看到有人保护你免受可能的错误,这样的感觉很好。你会很奇怪在单元测试之前它是如何工作的。
随着你编写越来越多的测试和断言,你会不可避免地遇到一些断言很难阅读的问题。例如,思考如下代码。
Listing 3.17 Cumbersome JUnit assert method
[...]
public class HamcrestTest{
private List
@Before
public void setUpList(){
values = new ArrayList
values.add("x");
values.add("y");
values.add("z");
}
@Test
public void testWithoutHamcrest(){
assertTrue(values.contains("one")
|| values.contains("two")
|| values.contains("three"));
}
}
1.上面的例子构造了一个简单的JUnit测试,到目前就像我们构建的那样,我们拥有一个
@Before夹具,它将为测试初始化数据
2.我们拥有一个单独的测试方法,在这个方法中你可以看到当我们插入了一个难以阅读的断言(可能它不那么难以阅读,但是咋一看定义不是很明显)。我们的目的在测试方法中是使断言简单化。
为了解决这个问题,我们要为构建测试表达式提出一个匹配库。Hamcrest是一个包含许多有用的匹配对象(也被称为约束和谓词),移植几种语言(Java, C++, Objective-C, Python, and PHP),注意Hamcrest自己并不是测试框架,但是它协助你定义简单的匹配规则。这些匹配规则能在不同的条件下使用,但是它们对单元测试特别有用。
Listing3.18是同一个测试方法,这次使用Hamcrest库编写。
[...]
import static org.junit.Assert.assertThat;
import static org.hamcrest.CoreMatchers.anyOf;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.junit.JUnitMatchers.hasItem;
[...]
@Test
public void testWithHamcrest(){
assertThat(values, hasItem(anyOf(equalTo("one"), equalTo("two"),
equalTo("three"))));
}
[...]
1.这里重用Listing3.17的代码和添加另一个测试方法,这次我们导入需要的匹配器和
assertThat方法
2.然后我们构造一个测试方法,在这个测试方法中我们使用匹配器中最强大特性中的一个:他们可以彼此嵌套。
你是否更喜欢断言代码而不是Hamcrest匹配器是个人喜好。Hamcrest提供标准的断言模式,但是不提供断言失败时提供可读的描述信息。
图表3.1,左图显示没有使用Hamcrest执行测试的栈跟踪。右图显示使用Hamcrest后的情况。
假如你跟随前面的两个例子,你可能注意到这两个用例中,我们使用“x”,“y”,“z”元素构造一个List。之后我们断言"one","two","three",意味着测试将失败。执行测试执行的结果在Figure3.1中展示。你能看到两个截图,右边的提供更多的细节。
Table3.2列出大多常用的Hamcrest匹配器。
table3.2 Some of the most commonly used Hamcrest matchers
核心 |
逻辑 |
anything
|
在断言可读性的地方匹配任何 |
is |
仅用于改善你的可读性报表。
|
allOf |
检查是否全部包括,有点像&& |
anyOf |
检查是否包含其中一个,有点像|| |
not |
不是,有点像! |
instanceOf,isCompatilbeType |
测试对象类型是否兼容 |
sameInstance |
测试对象身份 |
notNullValue,nullValue |
测试空值或非空值 |
hasProperty |
测试JavaBean是否包含属性 |
hanEntry,hasKey,hasValue |
测试Map是否给定Entry,Key,Value |
hasItem,hasItems |
测试collection表现Item,Items |
closeTo,greaterThan,greatThanOrEqual,lessThan,lessThanOrEqual |
测试数字接近,大于,大于或等于,小于,小于或等于 |
equalToIgnoringCase |
测试字符串是否与另一相等,忽略用例 |
equalToIgnorngWhiteSpace |
测试字符串是否与另一相等,忽略空格 |
containsString,endsWith,startWith |
测试字符串是否包含,开始,结局某个字符 |
所有这些看起来很方便去阅读和使用,记住能将它们结合起来使用。
最后,Hamcrest是可扩展的,很容易去编写自己的匹配器检查一个确定条件。唯一需要做的事是实现Matcher接口和一个合适的工厂方法。你能在附录D中找到如何编写自定义的匹配器,我们提供如何写自定义匹配器的完整概述。
3.6为测试设置项目
由于本章涵盖了一个测试的真实组件,让我们完成如何设置控制器包作为一个大型项目的一部分,在第一章,所有的Java代码和测试代码都放在同一个文件中。
使用一个样例类来介绍测试,因此这种方法看起来对每个人都简单。在本章中,你开始构建真实的类使用真实的测试,作为自己的项目,因此,需要设置源代码库作为真实的项目。
目前,你仅有一个测试用例,混合着域类将不是一个好注意,但是经验告诉我们必须至少为你的域类创建测试类。把同一目录中的文件全部替换将产生创建文件管理问题。
Figure 3.2
你想要测试能够进行单元测试保护方法,因此你想要保持每件事在同一个java包中,解决方法是在一个包中有连个文件夹,图3.2指出在IDE中如何组织目录结构。
除了消除复杂,一个分离但是相同的目录结构也将产生其他的好处,现在,唯一的测试类有Test前缀,之后你可能需要其它的帮助类去创建更多复杂的测试。他们可能包括桩模块(stubs)和驱动模块(mock)对象,及其他的协助者。可能在所有的类前面加上前缀Test会很不方便,而且很难告诉域类的测试类。使用一个单独的测试文件夹也使它容易只有域类提供一个运行时JAR包,它能使测试自动化的运行简单化。
JUnit最佳实践:同样的包,单独的目录
放置测试类在同一个包中作为类的测试,但是是一个并行的目录结构,你需要在同一个包中测试允许访问受保护的方法,你希望测试在单独的文件夹中很简单的文件管理和清楚地界定测试类和域类。
3.7总结
现在看第一章,是不是很难转换地编写JUnit测试,在这一章中,我们为一个简单但是完全的控制器应用创建了一个测试案例,而不是测试一个单独的组件,测试用例检查几个组件之间如何一起工作。我们以一个引导测试案列开始,能够使用任何类,然后我们添加新的测试到测试用例中,直到所有的原始组件都在测试之下。因为断言越来越多和越来越复杂,我们发现一个简单的方式是使用Hamcrest匹配器。我们期望这个包增长,因此我们创建第二个源代码目录,因为测试类和域类都是包的一部分,我们依旧测试保护和包中的默认成员。
在下一章中,我们把单元测试与其他类的测试对比,你需要在你的应用程序中执行。我们也将谈论单元测试开发的生命周期。