有效的单元测试

前言

对之前的项目进行重构,由于之前的项目中的单元测试大部分都是走走形式,对单元测试疏于管理,运行之后大部分是不通过,这样的单元对项目而言毫无价值,更不要说有助于理解系统功能。这也使我有契机了解到TDD(测试驱动开发)的思想。为了在项目重构中编写有效的单元测试,我查找了有关TDD的一些书籍,《单元测试的艺术》(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    //todo
11 }

  有时候有注释,不如无注释。 可以看到以上代码的注释为发送邮件, 但方法名却为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测试驱动开发
 
 

 

  测试驱动开发流程如上图:在开发前先写一个失败的测试案例,然后写出使测试代码通过的生产代码,重构优化生产代码和测试代码直至通过测试,然后再写一个新的测试,循环上述过程。

  当你的生产代码写的一团糟的时候,你很难,甚至是不可能按照优秀单元测试的原则去编写测试代码。比如一个测试方法要求只测试一件事情,而当生产代码一个方法干了很多的事情,测试方法很难保证只测试一件事情,这时候只能重构生产代码才能写出优秀的测试

  究其根本,测试驱动开发的本质是,当你的测试代码符合模块化、松耦合高内聚的特点时,生产代码会自然的“被逼迫”遵守同样的原则,从而产生良好的设计。

posted @ 2016-04-21 02:33  爬山虎的脚  阅读(2430)  评论(3编辑  收藏  举报