Infoq: 持续集成:从六个层次加速测试执行

 

在持续集成领域,一个产品的发布往往都有自己的过程周期(lifecycle),大体都会划分为:构建->部署->测试->发布等几个重要阶段,其中测试是发布产品前不可或缺的重要阶段,是产品质量的保证。而能让持续集成奏效,除了要求测试脚本更充分健壮,还要求测试脚本运行得更快更好。这点对于小型项目而言可能显得无关紧要,毕竟大多小项目的测试脚本不过百条,验证点不过千“点”;但对于一个大型项目而言,测试代码源文件可能成百上千,执行完所有的测试可能要等很久,而苦等之后的结果却可能是满眼的failure, 于是如果提高测试执行速度成为迫切需要解决的问题,试想把测试阶段从2小时压缩到1小时,再从1小时压缩到30分钟,每次时间压缩带来的不仅是技术人员本身的成就感,更是对整个产品发布过程体验的改善。

那么如何加速测试的执行呢?提起速度,我们立马可能联想到“性能”调优的步骤:先进行tuning,然后找到问题的瓶颈所在,最后逐个击破。本文暂不讨论如何进行这些步骤, 而是基于C++和Java为语言案例,TestNG和Google test为测试框架,Jenkins为持续平台做分析,从以下六个层次提出提高测试执行的一般方法:

硬件资源层次

工欲善其事必先利其器,提高硬件(CPU、内存、磁盘等)配置是改善执行速度的“硬”方法,硬件资源的优化不应仅仅局限在单机自身的各项指标提升,在需求不断提高的情况下,可以考虑实施虚拟机、分布式集群等方式来进一步获取更优的硬件资源,当然,涉及分布式执行时,可以借助以下持续集成平台层次的“软”实施来共同作用。

另外,在硬件资源紧张的情况下,不同项目或者不同团队可能不得不复用一套测试环境,造成可利用资源更为紧张,此时可以错开时间测试(例如A项目组测试定时在凌晨0点启动,B项目组定时在凌晨2点)以提高速度。

语言编码实现层次

测试代码本身也是代码,显而易见,如果代码编写时注重效率,速度上肯定有所收益。这点可能需要“纠结”于一些日常的编码细节:例如Java中Stringbuffer和Stringbuilder的比较;C++中是i++和++i的比较。这种语言层次提高效率的文章书籍很多,这里不做过多描述。在语言编码层次上最重要的不是这些语言细节,而是避免一些消费时间的测试代码设计,减少不必要的耗时操作,例如以下几点:

(1)  冗余的日志信息,不合理的日志级别设置等

输出日志带来的磁盘频繁访问必然让速度下降,所以在保证日志信息充足的前提下,尽量减少日志,或者只记录失败测试的日志(毕竟对于测试者而言很少去关注成功日志),可以让测试加快。

(2)  不合理的等待

用户执行完某个操作,必须等待某条件的发生(例如DB里面插入一条新数据)进而执行后续动作是测试中经常面对的场景,那么等待多久成为需要考虑的问题,假设用TimeUnit.MINUTES.sleep(1)等待一分钟,在10秒即可满足条件的场景下浪费的就是50秒,所以这里必须去考虑合理sleep的时间来兼顾对资源的消耗和运行速度的影响,同时在等待方式上也可以考虑是采用循环短时间条件等待或异步通知的方式去进行。

(3)  用例的过程

先执行完所有测试步骤,然后做对所有步骤做一次性校验,还是做完一步校验一步,这两种方式的速度在不同场景下有所不同,所以需要权衡;相类似的,对于需要获取DB连接的用例,是每条都执行获取DB连接然后释放连接,还是所有用例执行之前获取连接,所有case执行完之后释放连接也会对执行速度有所影响。

构建测试脚本层次

对于一个大型项目,源文件的数目庞大或依赖的dependency过多导致代码编译占用大量时间,如何提高编译代码的速度?除了使用更好的磁盘,注重代码编写时对编译速度的影响,还可以针对不同的语言采取不同的有效策略,例如针对C++, 使用make命令编译项目时,可以加上参数-j来并行编译项目。-j参数的含义可以参考下文:

-j [jobs], –jobs[=jobs]

    指定同步运行的作业(命令)的数量。如果有一个以上-j选项,那么只有最后一个有效。如果-j选项没有参数,那么编译过程就不会限制能够同步运行的作业的数量。

需要说明的是,编译过程可能要求特定的顺序而导致并行编译失败,如果遇到这种问题,可以先并行、后串行(去掉-j)重复执行一次以解决。

而对于java,Maven 3 开始支持并发build,提供了以下几种常见方式:

  1. mvn -T 4 clean install # Builds with 4 threads
  2. mvn -T 1C clean install # 1 thread per cpu core
  3. mvn -T 1.5C clean install # 1.5 thread per cpu core

同时使用maven管理java项目常出现时间消耗在依赖jar的下载上,此时可以检查是否有冗余失效的repository配置、较长的下载timeout时间设置、所选择repository的连接速度等,甚至在不同测试环境下可以使用 profile来管理repository来加速测试脚本构建。

测试框架支持层次

在测试框架支持层次上,应该充分运用框架本身提高的丰富功能来提高测试执行速度,以Java测试框架TestNG为例:

(1)  利用timeout控制失效等待

如果某个测试用例等待某条件的触发而陷入长时间等待,等待的时间过长往往对于用例本身而言已失效,特别是当条件永远无法满足时。因此需要控制用例执行允许的最大timeout时间。TestNG可以给test或者test suite设置 timeout时间,分别控制具体某个或一组(testing.xml配置)自动化测试用例执行的最大允许时间:

  1. @Test(timeout = 1000) 
  2.  testng.xml : <suite name=”Module Test” parallel=”none” time-out=”200000″>

 (2)  利用@BeforeTest、@BeforeClass等条件注解,减少无意义测试

测试的顺利完成都需要满足很多基础条件,例如需要测试环境就绪, 如果不使用@before类标签,则当条件不具备时,仍然会执行完所有的用例,必然带来巨大的时间浪费,因此使用@before类标签可以避免无意义的测试,@before标记的方法一旦失败,后续的相应的测试不会继续进行。

(3)  使用框架自带的多线程支持

例如对于TestNG自身,可以在testng.xml中设置parallel参数来指定是否并发以及并发的级别:methods|tests|classes,除了测试框架自身外,软件项目管理工具也可以提供多线程支持,例如maven的测试组件maven-surefire-plugin,提供了并发参数的设置:

<plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>2.16</version>
        <configuration>
          <parallel>methods</parallel>
          <threadCount>10</threadCount>
        </configuration>
 </plugin>

持续集成平台层次

现在市场上存在不少持续集成平台,大多持续集成平台支持并发执行用例然后汇总、发布测试结果,从而最大化提高测试执行速度。而并发执行的前提是测试代码本身及测试代码的组织支持并发,如果测试本来就含有多个模块,那么直接并发运行多个模块,最后汇总结果即可。如java以testng.xml为模块,gtest以makefile为模块,所以相比较顺序执行4个模块,并发使用4个Job并发执行,那么时间压缩可以达到4倍。在实际应用中,即使在同一个模块,我们仍然面对自动化测试用例数目过多运行速度过慢的问题,此时,可以考虑将单一模块拆分成子模块,对于Java而言较简单,配置下测试套件的xml即可;而对C++而言,如果不允许直接复制粘贴原有的makefile,就需要重新设计makefile以复用, 例如将makefile中编译的test cases定义分拆到多个makefile(如下图测试模块1的makefile引用了共用的makefile并添加了自己的测试用例)中,然后并发执行多个makefile。

0502020

为并发执行多个job, 持续集成平台必须提供必备的支持,以Jenkins为例,可以使用multijob插件来实施,配置多个测试模块同时进行.

0502021

并发完测试后,讲所有测试结果汇总到一个地方,然后使用xunit plugin来汇总结果(如下图),它可以汇总多个文件,且支持cpptest、gtest等输出结果格式。

0502022

过程改进层次

在产品的持续集成生命周期中,可以将测试拆分成两部分放在两个阶段:基本功能快速校验阶段(fast fail)和基本功能之外的全面测试阶段。如果产品在第一阶段最基本的功能都无法通过,那么部署之后进行全面测试纯属浪费时间,这个阶段的引入可以快速的校验产品是否有必要开展全面测试。这点类似与测试用例中添加了@before类标签所带来的收效,不过更宏观且阶段划分的更清晰。

原有过程:构建阶段->部署阶段->测试阶段->发布阶段

细化后过程:构建阶段->部署阶段->基本功能快速校验阶段->全面测试阶段—>发布阶段

这种过程优化可以利用持续集成平台来支持,例如对于Jenkins系统,可以使用multijob插件,将基本功能快速校验和全面测试阶段分列在不同的phase即可,执行效果如下:

0502023

结论

通过上面由微观到宏观六个不同层次的分析可知,要加速测试用例的执行是一个系统的过程,单靠某一方面分析可能有所偏失,并不能将测试用例的执行速度发挥极致。同时,本文针对不同层次的分析也没有提供step by step的方式描述每一个细节,只是点到为止,所以读者可以根据自己采用的语言、测试框架、持续集成平台做类似更有针对性的分析,相信在综合不同层次的综合调优后,可以让持续集成实施的更快、更好。

Infoq: 代码覆盖的16种典型情景

代码覆盖(Code Coverage)为何物?相信程序员特别是测试人员不陌生,很多人都喜欢用代码覆盖来驱动测试的开展和完善。确实代码覆盖可以找出测试疏漏和代码问题,但是单纯的代码覆盖率高低并不能直接反映代码质量的好坏。大多我们的努力方向都是找出那些没有覆盖到的代码,然后补充用例,完善测试。而摆在我们面前的问题是:是否我们已经充分认识到哪些不需要、不能、必须被覆盖?只有对代码覆盖的各种情景了然于胸,才能不盲目乐观于代码覆盖率之高,悲观于代码覆盖率之低。在实践中(本文面向主要Java语言,基于emma工具),梳理可知,对于代码覆盖我们可能都会遇到以下16种典型情景:

1. 代码覆盖

即代码所有路径被经过,这种需要注意的是:不应该覆盖而被覆盖的情况。例如某种特殊异常就是不期望遇到的,但是遇到了,异常处理的代码也覆盖了,这时,我们应该追溯异常产生的根本原因,而不因覆盖了就直接忽略。

提示:不仅要关注未覆盖的代码,也要关注覆盖的,特别是偶然覆盖的代码。

2. 废弃的功能

一些功能点随着产品版本不断更新,可能会被取消,这部分功能可以直接移除,保留只会让代码看起来越冗余。如果某天需要参考或找回删除的那些代码,CVS/SVN工具就搞定了。

提示:不用的功能不需要覆盖,要及时删除,不通过保留或者注释的方式残留在代码中。

3. 工具类(助手类)、常量类等的私有构造器

工具类和常量类共有的特征是对外开放的都是静态方法,调用方法的时候,无需创建实例,所以推荐实践是创建一个private的构造器方法。这导致类的构造器代码无法覆盖(不考虑反射等方式)。

相反,如果某天发现对于这样的类覆盖率为100%,那检查下是否代码写的不规范: 用默认构造器,然后通过实例来调用静态方法。

例1:工具类

public final class StringUtil {

    public static String concatWithSpace(String... strings) {
        return concat(MarkConstants.SPACE, strings);
    }


    public static String concatWithSemicolon(String... strings) {
        return concat(MarkConstants.SEMICOLON, strings);
    }

    private StringUtil() {
    }

}

例2:常量类

public final class MarkConstants {
    /**
     * {@value}
     */
    public static final String SEMICOLON = ";";

    private MarkConstants() {
    }

}

提示:工具类(助手类)、常量类等的私有构造器不能被覆盖

4. 日志级别配置

日志级别不同,覆盖率高低也不同。在产品部署中,很少将日志的级别设成debug,因为日志占用磁盘空间会增长很快。只在做一些问题跟踪、调试时才会调高日志级别。

所以环境使用不同的日志级别,也会导致一些日志代码没有覆盖。如以下示例程序,不打开debug级别无法覆盖部分代码:

public static String formatPath(String path) {
     ValidationUtil.checkString(path);
     String returnPath = path.trim();
     if (!returnPath.startsWith(SPLIT))
         returnPath = SPLIT + returnPath;
     if (returnPath.endsWith(SPLIT))
         returnPath = returnPath.substring(0, returnPath.length() - 1);

     if (LOGGER.isDebugEnabled())
         LOGGER.debug(String
                 .format("[util]convert [%s] to [%s]", path, returnPath));
     return returnPath;
}

那么这部分代码需要覆盖嘛?需要。 假设代码误写成:

    LOGGER.debug(String.format("[util]convert [%] to [%s]", path, returnPath));

某天日志级别设为debug,就会发现报错。类似的还有日志中经常输出某个对象信息,但是该对象可能是null,从而抛出空指针异常。

提示:日志也是代码的一部分,需要通过调整日志级别来覆盖。

5. JVM等参数

程序的配置参数会直接影响代码路径覆盖,不仅包括业务上的一些配置,也包括依赖平台的参数,例如JVM参数除了会影响性能,也会影响代码的覆盖情况,例如断言相关参数:

    -ea[:...|:] 和-da[:...|:]

分别是启用和关闭用户断言(-esa/-eda,针对系统断言),在JAVA中断言是默认关闭的,所以涉及断言的代码默认无法覆盖。

提示:一些代码路径能否覆盖与JVM等参数有关,需要通过调整参数来覆盖

6. main()方法

一些程序员喜欢临时写一个main()方法方便于测试,完成测试后寻思以后还能方便测试就留了下来。导致产品代码中的这些代码无法被覆盖。在产品代码中,应该删除这些,部署的毕竟是产品代码,不是测试代码。

提示:main()方不需要被覆盖,产品代码不保留测试代码

7. 编码习惯写法

在编码过程中,常常有一些习惯写法,最常见的比如:(1) 覆盖toString()方法; (2) 以意义配对形式写一些方法:比如数据连接中Connect()搭配 DisConnect(), 枚举中常用的 toString()搭配fromString(),这些惯用的写法告诉读者一些涵义,但是不见得所有的方法都必须被调用,例如在产品应用中,我们可能启动起一个周期性的job,但是本身尚未添加“取消“功能(或本来就不需要停止)。自然也就无法调用: 但是它应该不应该存在? 笔者认为作为完整的功能应该存在。 类似的还有异常定义的时候,会定义很多重载的方法,虽然不见得每个都调用,但是不定某天就会被调用。

提示:编码习惯写法造成的未覆盖代码需要被覆盖,是代码的一部分。

8. 项目的使用方式

下面两种使用方式会造成代码不能全部被覆盖:

  1. 客户端Jar方式:部分代码作为客户端Jar包形式提供给他人使用;
  2. 分布式系统交互:分布式系统之间存在交互时,例如从系统1复制文件到系统2,如果始终按照从1到2的顺序,又仅仅统计系统1的代码覆盖肯定不能覆盖全部。 需要覆盖的代码虽出于一处,但是使用方式不同也会导致在不合并覆盖数据情况下代码未覆盖。

提示:项目使用方式造成的代码覆盖统计数据分散需要通过合并数据来覆盖。

9. 常用最佳实践

一些很难覆盖的最佳实践:例如对于一些资源(IO,lock)的释放,可能直接 try…catch然后记录异常,这些异常一般很难发生。

    public static void close(InputStream inputStream)
    {
         try {
             inputStream.close();
         } catch (Exception e) {
             LOGGER.warn("fail to close inputstream");
         }
    }

提示:常用最佳实践可以不覆盖。

10. 被拒绝的馈赠

在接口/抽象类定义的时候,有时候定义的一些方法子类并没有都实现,即常说的被拒绝的馈赠(Refused Bequest),这种问题如果是为了短期扩展需求多加了一些方法也可接受,否则还是需要重新继承体系设计。

提示:子类未使用“馈赠”,无需覆盖,需重新审视继承体系结构。

11. 代码覆盖工具未做合并

做代码覆盖时,往往工具本身不支持“合并”的功能,这导致以下问题存在:

时间上:

  1. 例如对于拥有cache的系统: 系统经过一段时间运行后,重新测试得到的代码覆盖往往不包括cache miss的情况。
  2. 手工测试问题:每次统计都需要重新完成全部手工测试,否则将丢失数据。

空间上:

  1. 负载均衡:现在大多系统应用都采用负载均衡技术,如果测试时间不够长且只统计一台系统的代码覆盖情况,往往不全面。

提示:代码覆盖本身要支持“合并”功能,对多个系统、不同时间的数据进行合并,才能覆盖的完整全面。

12. 系统逻辑重复

这里可分为两种情况:

  1. 组件之间重复:上层系统可能会对数据合法性做检验,但是下层系统出于系统的独立性目标,也可能对数据做二次校验,但是作为一个完整系统进行end-to-end测试时,就无法覆盖二次校验的代码;针对这种情况,需要拆开成独立组件进行测试。
  2. 组件内部重复:同一组件内多层重复逻辑确实可以纠正代码,例如在对于某个数据做多次同一类型校验,这种问题常出现于多人协同编码又缺乏沟通的情况中。

提示:逻辑重复导致的部分未覆盖要分辨是组件之间还是组件内部冗余,组件之间则需要覆盖,组件内部则要修改代码。

13.代码写法

有时候某些代码的写法,也会导致无法覆盖,例如对于代码调用顺序:多个类调用读取配置文件,而稍晚些调用的再次判断配置文件是否初始化,自然为已初始化。再如对于单例,某些代码写成这样:

    private static SingleInstance INSTANCE = new SingleInstance();

    public static SingleInstance getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new SingleInstance(); 
        }
        return INSTANCE;
    }   

提示:代码写法造成的未覆盖,需要审查下是代码问题。

14. 隐式的分支

当代码中含有隐式的分支时,往往很难100%覆盖,例如上文提到的断言assert,貌似只有一句,但是即使启用断言仍然无法100%覆盖,例如下例还是显示黄色:分支未覆盖。

    public class AssertCodeCoverage {

        public void verify(Boolean b) {
         assert b;
        }

    }

究其原因,查看编译后的class可知,存在第三条指令:判断是否启用断言。在实际应用中,要么启用要么关闭,所以不可能覆盖所有分支。只能说启用断言,或许能提高指令覆盖率,下图为启用及关闭断言的覆盖率对比:

    public void verify(java.lang.Boolean b);
     0  getstatic com.test.coverage.AssertCodeCoverage.$assertionsDisabled : boolean [16]
     3  ifne 21
     6  aload_1 [b]
     7  invokevirtual java.lang.Boolean.booleanValue() : boolean [28]
    10  ifne 21
    13  new java.lang.AssertionError [33]
    16  dup
    17  invokespecial java.lang.AssertionError() [35]
    20  athrow
    21  return
    }
0x9a ifne 当栈顶int型数值不等于0时跳转。

因此,从这个角度来说,想覆盖断言,不仅要关闭断言完成测试用例,还要在开启断言情况下完成测试。

提示:隐式的分支(黄色)需要分析未覆盖分支。

15. 不在覆盖范围内

下面两种类型的代码不在代码覆盖统计范围内:

  1. Java接口,接口里面都是抽象方法的结合,不含有任何代码细节;
  2. 不含有可执行java字节码的方法:抽象方法和本地native方法。
    abstract class AbstractService {
        abstract String getString();  //不做统计
        String getName() {  return getString().trim();    }
    }

    public class BaseService extends AbstractService {
                  @Override
                  String getString() {      return "zookeeper";    }
    }

提示:不在统计范围内的直接忽视。

16. 应用程序在线实时收集的局限

对于一个在线服务的应用程序的代码覆盖统计,需要实时,但是如果关闭服务,显然收集不到。所以关闭服务的代码无法被统计,因为关闭服务的时间很短暂,很难介入。

protected void stopService() throw Exception()

提示:对于这种需求可以采用单元测试的方式去覆盖,否则对于应用程序的在线实时收集不加任何特殊代码处理的话是收集不了的。

小结:

通过对上面15种典型情况的概括,相信大家对代码覆盖的常见情景已有大概印像,在实际分析中,可以按照以下规则进行:

  1. 内容:包->类->方法->代码;
  2. 优先级: 核心业务类->普通业务类->工具助手类->常量类;

经过不断的分析和推敲,相信大家得到的不仅是一个较高的代码覆盖率,更是对代码质量的一份信心。