前言
对之前的项目进行重构,由于之前的项目中的单元测试大部分都是走走形式,对单元测试疏于管理,运行之后大部分是不通过,这样的单元对项目而言毫无价值,更不要说有助于理解系统功能。这也使我有契机了解到BBD(测试驱动开发)的思想。为了在项目重构中编写有效的单元测试,我查找了有关BBD的一些书籍,《单元测试的艺术》(Roy Osherove著)和《有效的单元测试》(科斯凯拉著)都是有关测试驱动开发的不错的书籍,前者是使用.net语言,后者使用java语言,作为java程序员我自然选择了后者。但实际上作者在阐述一种思想,不论哪种语言都可以读懂,只是平时的习惯,对于熟悉的语言读起来更顺畅。这篇文章也是对书中的内容做一个总结。
一、单元测试代码的可读性
①使用更易懂的API,把你的代码读出来
示例:
//代码一 String msg = “hello,World”; assertTrue(msg.indexOf(“World”)!=-1);
//代码二 String msg = “hello,World”; assertThat(msg.contains(“World”),equals(true));
同样断言字符串中包含 World 这个单词,代码一中 使用indexOf 这个取得单词索引位置的API就显得间接许多,而且我们的大脑还需要对表达式进行判断,进一步增加了认知的负担,而contains 方法字面意思就是包含,更符合我们要表达的意思。所以一定要找到更适合易懂的API。同时用assertThat方法替代assertTrue方法,使的整个语句更具口语化,完全可以像读文章一样读出来
②避免使用较底层的方式,比如位运算符(这并不能表示你有多牛 =.=)
示例:
//代码一 public class PlatformTest { @Test public void platformBitLength(){ assertTrue(Platform.IS_32_BIT ^ Platform.IS_64_BIT); } }
//代码二 public class PlatformTest { @Test public void platformBitLength() { assertTrue("Not 32 or 64-bit platform?", Platform.IS_32_BIT || Platform.IS_32_BIT); assertFalse("can‘t be 32 and 64-bit at the same time.",Platform.IS_32_BIT && Platform.IS_32_BIT); } }
代码一 要检查的是什么?位运算符结果怎么算?恐怕大部分使用高级语言的程序员很少会用到,这会增加我们的认知负担。
位运算符可能会有效的执行一个程序,但单元测试的代码可读性优于性能,我们应该更好的表达我们的意图,使用布尔运算符来替换位运算符可以更好的表达意图,见示例二。
③不要在测试中对代码进行过度运用防御性策略
1 public void count(){ 2 Data data = project.getData(); 3 assertNotNull(data); 4 assertEquals(4,data.count()); 5 }
第三行代码有些画蛇添足,即使data为空,在没有第三行代码的情况下,测试案例依然会失败,在IDE中双击失败信息,可以快速跳转到失败行,并指出失败原因。所以第三行代码并没有意义,这种防御性策略的真正优势在于方法链中抛出空指针的时候。比如 assertEquals(4,data.getSummary().getTotal()),当此行代码抛出空指针异常时,你无法判断是data为空还是data.getSummary()为空,此时可以先进行assertNotNull(data)的断言。
二、单元测试代码的可维护性
①去除重复,包括结构性重复
1 //代码一 2 public class TemplateTest(){ 3 @Test 4 public void emptyTemplate() throws Exception{ 5 String template=“”; 6 assertEquals(template,new Template(template).getType()); 7 } 8 @Test 9 public void plainTemplate() throws Exception{ 10 String template=“plaintext”; 11 assertEquals(template,new Template(template).getType()); 12 } 13 }
两个测试方法,一个是测试建立一个空模板,另一个测试建立一个纯文本模板,明显可以发现存在结构性重复,对以上代码进行改进,如下:
1 //代码二 2 public class TemplateTest(){ 3 @Test 4 public void emptyTemplate() throws Exception{ 5 assertTemplateType(“”); 6 } 7 @Test 8 public void plainTemplate() throws Exception{ 9 assertTemplateType(“plaintext”); 10 } 11 private void assertTemplateType(String template){ 12 assertEquals(template,newTemplate(template).getType()) 13 } 14 }
虽然代码行数没有减少,甚至还多了一行,但是把相同的代码提炼到一处,当它发生变动时只需修改一处,可维护性增强了。
②避免由于条件逻辑而造成的测试遗漏,存在条件逻辑时要在最后加上 fail()方法,强制测试失败
考虑一下,当Iterator 为空的时候,下面的测试方法会失败吗?
1 //重构前 2 public class DictionaryTest{ 3 @Test 4 public void testDictionary() throws Exception{ 5 Dictionary dict = new Dictionary(); 6 dict.add(“A”,new Long(3)); 7 dict.add(“B”,”21”); 8 for(Iterator e = dict.iterator();e.hasNext()){ 9 Map.Entry entry = (Map.Entry) e.next(); 10 if(“A”.equals(entry.getKey())) 11 asserEquals(3L,entry.getValue()); 12 if(“B”.equals(entry.getKey())) 13 assertEquals(“21”),entry.getValue(); 14 } 15 } 16 }
显然当Iterator为空时,测试并不会失败,这并不符合我们单元测试的目的,进行重构后:
1 //重构后 2 public class DictionaryTest{ 3 @Test 4 public void testDictionary() throws Exception{ 5 Dictionary dict = new Dictionary(); 6 dict.add(“A”,new Long(3)); 7 dict.add(“B”,”21”); 8 assertContain(dict.iterator(),”A”,3L); 9 assertContain(dict.iterator(),”B”,21); 10 } 11 private void assertContain(Iterator i,Object key,Object value){ 12 while(i.hasNext()){ 13 Map.Entry entry = (Map.Entry)i.next(); 14 if(key.equals(entry.getKey())){ 15 assertEquals(value,entry.getValue()); 16 return; 17 } 18 } 19 fail("Iterator didn‘t contain "+ key); 20 } 21 }
当没有达到预期目的时使用 fail()方法,强制测试失败。
③避免使用sleep方法浪费大量的测试时间
counterAccessFromMultipleThreads 用来测试一个多线程计数器,开启10个线程,每个线程调用计数器1000次,sleep(500),是为了让主线程等待开启的10个线程执行完毕
那么问题来了,如果在10毫秒内所有线程都执行完毕,岂不白白浪费了490毫秒?又或者在等待500毫秒后仍有线程没有执行完毕,那该怎么办?
1 @Test 2 public class counterAccessFromMultipleThreads{ 3 final Counter counter = new Counter(); 4 final int callsPerThread = 1000;//每个线程调用计数器1000次 5 final Set<Long> values = new HashSet<Long>(); 6 Runnable runnable = new Runnable(){ 7 public void run(){ 8 for(int i=0;i<callsPerThread;i++){ 9 values.add(counter.getAndIncrement()); 10 } 11 } 12 }; 13 int threads = 10;//开启10个线程 14 for(int i=0;i<threads;i++){ 15 new Thread(runnable).start(); 16 } 17 Thread.sleep(500); 18 int exceptedNoOfValues = threads * callsPerThread; 19 assertEquals(exceptedNoOfValues ,values.size()); 20 }
改进后的测试方法:
1 public class counterAccessFromMultipleThreads{ 2 final Counter counter = new Counter(); 3 final int callsPerThread = 1000; 4 final int numberOfthreads = 10; 5 final CountDownLatch allThreadsComplete = new CountDownLatch(numberOfthreads); 6 final Set<Long> values = new HashSet<Long>(); 7 Runnable runnable = new Runnable(){ 8 public void run(){ 9 for(int i=0;i<callsPerThread;i++){ 10 values.add(counter.getAndIncrement()); 11 } 12 allThreadsComplete.countDown(); 13 } 14 }; 15 16 for(int i=0;i<numberOfthreads;i++){ 17 new Thread(runnable).start(); 18 } 19 allThreadsComplete.await(); 20 // allThreadsComplete.await(10,TimeUnit.SECONDS); 21 int exceptedNoOfValues = threads * callsPerThread; 22 assertEquals(exceptedNoOfValues ,values.size()); 23 }
等待所有线程结束后再继续执行,有更好的办法,java.util.concurrent 包中的CountDownLatch类完全可以胜任这项工作。
调用await方法开始阻塞,直到所有的线程都通知完成,然后继续执行主线程代码。也可以设置超时时间,allThreadsComplete.await(10,TimeUnit.SECONDS); 如果10秒钟内子线程仍未执行结束,也会继续执行主线程。
三、单元测试代码的可维护性
①避免歧义注释
1 /** 2 * 功能描述: 发送邮件<br> 3 * 〈功能详细描述〉 4 * @return 5 * @see [相关类/方法](可选) 6 * @since [产品/模块版本](可选) 7 */ 8 9 public void sendShortMessage() { 10 //todo11 }
有时候有注释,不如无注释。 可以看到以上代码的注释为发送邮件, 但方法名却为sendShortMessage ,明显为发送短信的意思。这时候我们可能就会想这段代码是要发送邮件还是要发送短信,为了弄清事实不得不去看方法体的内容。 造成这种歧义注释的原因很多,可能之一就是发送短信的方法大致流程可能跟发送邮件相近,所以直接拷贝了邮件的代码,改了方法的内容,却没有修改注释。如果方法名足够得当,可以不写注释。
②避免永不失败的测试
下面的测试代码检查是否抛出期望的异常,这段代码有什么问题?
@Test public void includeForMissingResourceFails() try{ new Environment().include("somethingthatdoesnotexist"); }catch(IOException e){ assertThat(e.getMesssage(),contians(“FileNotExist”)); }
上面的代码测试结果如下:
1.如果代码如期工作并抛出异常,异常会被catch代码块捕获,测试通过。
2.如果代码没有如期工作,也就是没有抛出异常,则方法返回,测试通过,我们并不会发现其中存在的问题。
改进测试方法:
1 try{ 2 new Environment().include(“FileNotEixst”); 3 fail(); 4 }catch(IOException e){ 5 assertThat(e.getMesssage(),contians(“FileNotExist”))}
添加fail()方法的调用,使测试起作用。除非抛出期望的异常,否则测试失败。
四、优秀的单元测试的原则
•少用继承多用组合,继承更大程度上是为了多态而非复用代码
•单元测试应该模块化,每个模块小而专注,减少反馈链
•如果一个单元测试方法失败了,那么导致它失败的原因只有一个
•加载外部文件时使用相对路径而不是绝对路径
•对于魔法数字除了提取局部变量或常量外,可以取一个恰当的方法名,见名知义
•好的注释应解释代码现状的缘由
可以看出优秀的单元测试的原则跟优秀的面向对象编程的原则一致,比如少用继承,多用组合,模块化且模块尽可能小,一个模块只完成一个功能等。
五、BBD测试驱动开发
测试驱动开发流程如上图:在开发前先写一个失败的测试案例,然后写出使测试代码通过的生产代码,重构优化生产代码和测试代码直至通过测试,然后再写一个新的测试,循环上述过程。
当你的生产代码写的一团糟的时候,你很难,甚至是不可能按照优秀单元测试的原则去编写测试代码。比如一个测试方法要求只测试一件事情,而当生产代码一个方法干了很多的事情,测试方法很难保证只测试一件事情,这时候只能重构生产代码才能写出优秀的测试
究其根本,测试驱动开发的本质是,当你的测试代码符合模块化、松耦合高内聚的特点时,生产代码会自然的“被逼迫”遵守同样的原则,从而产生良好的设计。