zoukankan      html  css  js  c++  java
  • 有效的单元测试

    前言

    对之前的项目进行重构,由于之前的项目中的单元测试大部分都是走走形式,对单元测试疏于管理,运行之后大部分是不通过,这样的单元对项目而言毫无价值,更不要说有助于理解系统功能。这也使我有契机了解到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测试驱动开发
     
     

     

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

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

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

  • 相关阅读:
    类函数指针
    resource for machine learning
    蒲丰投针与蒙特卡洛模拟
    IIS5、IIS6、IIS7的ASP.net 请求处理过程比较
    CMD 命令速查手册
    Process, Thread, STA, MTA, COM object
    在托管代码中设置断点(Windbg)
    SidebySide Execution
    .NET Framework 3.5 Architecture
    Overview of the .NET Framework
  • 原文地址:https://www.cnblogs.com/jarman/p/5272761.html
Copyright © 2011-2022 走看看