最近正在搞gitlab+jenkins+nexus的工具链, 其实考虑持续集成的过程, 涉及到的方面很多:

  • 配置管理
  • 包管理/编译打包
  • 单元测试/集成测试/测试覆盖率/静态代码质量分析

单元测试是其中比较简单且实用的功能, 但是其实一直没有写过, 这次做了调研推广.

示例代码见gitee


Intro

整个测试从TDD角度讲是个很大的过程, 用户需求/场景故事/测试验收等等.

从测试方法角度有很多名字黑箱/白箱/冒烟/金丝雀/系统测试/功能性测试.

但从程序员角度讲, 最需要关注的还是单元测试和集成测试.

我理解程序员编写的测试程序正确性的程序. 所谓单元是指要测试尽量小的程序单元.

更简单的理解就是, 把之前总用psvm(main方法)跑一下的习惯, 变成写一个测试用例.

单元集成


这里废话一下单元测试的好处:

  • 反向验证代码是否优雅: 好的代码一定是方便单元测试的.
  • 重构友善: 如果有单元测试, 重构一个方法后, 之后测试通过即可保证重构的正确性.
  • 尽早’跑’程序, 尽早发现bug. 尤其是类似MapReduce的程序, 上集群运行调试太慢.

如果你的代码是这样的, 也没必要写单元测试:

  • 一次性的代码, 只会短暂运行的代码
  • 不会被别人拿去用的代码
  • 不会涉及修改和重构的代码
  • 逻辑简单一目了然的

如果是这样还是尽量写单元测试:

  • long term工作的代码, 会迭代更新和维护的代码
  • 开源的或者被很多人引用的代码
  • 逻辑复杂, 分支较多

FIRST原则

Fast: 测试的尽量是最小单位, 一个类一个方法的测. 一个案例只测一个方法.

Independent:

  • 可以独立运行
  • 不同测试案例之间不要有依赖.
  • 不要对外部系统有依赖

Repeatable:

  • 反复执行无副作用
  • 不要包含逻辑. 否则方法重构后测试用例可能失效

Self-Validating: 直接可以验证对错的, 不需要外部来判断

Timely: 及时的


怎么写

3A写法:

  • arrange: 就是做一些setup的工作,把要测试的东西构造出来, 测试数据准备一下
  • act: 执行被测方法
  • assert; 断言/验证是否和预想的一样

这里的验证, 简单可以有三种方式:

  • 验证返回值
  • 验证状态
  • 验证对象交互

3中assert


JUnit

虚的概念扯完了, 具体到Java开发中, 这里选用Junit单元测试框架. Junit使用起来很简单:

  • 为每个被测类写一个测试类.
  • 每个@Test作为一个测试用例.
  • Runner启动和运行测试类.

接下来从注解/断言/runner三方面了解JUnit.

提供的一些注解:

junit注解

提供的一些静态断言方法:

//默认提供一些assertTrue, assertEquals等方法
assertFalse("failure - should be false", false);
assertEquals("failure - strings are not equal", "text", "text");
assertNotNull("should not be null", new Object());
assertArrayEquals("failure - byte arrays not same", expected, actual);
//集成Hamcrest包提供了更丰富的断言方法, 高级高级
assertThat("albumen", both(containsString("a")).and(containsString("b")));
assertThat(Arrays.asList("one", "two", "three"), hasItems("one", "three"));
assertThat(Arrays.asList(new String[] { "fun", "ban", "net" }), everyItem(containsString("n")));

Runner接口:

Junit测试时, 由Runner类做准备和初始化等工作, 然后反射构造用户写的测试类再运行. 想要扩展测试框架的开发者可以自定义实现, 普通用户不太需要关注. 简单看了些源码.

ParentRunner 常用重要的抽象实现. 该类实现的大多数功能:

  • 提供对被测对象的filter和sort的功能
  • 处理@BeforeClass @AfterClass @ClassRule 的功能
  • 调用children的Run方法

一般情况下, ParentRunner的所谓Parent就是被测试的类, 其Children就是类下面被测试的方法. 该类的泛型T即为其Children类. 其核心方法见classBlock()

BlockJUnit4ClassRunner, 是Junit默认的Runner. 名叫ClassRunner, 其children是FrameworkMethod. run的时候就是method invoker. 核心方法见methodBlock()

  • 处理@Test注解下的timeout和exception推断的功能
  • 处理@Before @After @Rule 的功能

像Spring和Mockito等框架, 都是基于这个Runner实现了自己的Runner. 还有一些其他的功能性Runner如: Suite, Parameterized等.

Parameterized Runner, 参数化Runner, 即为测试类提供一组参数(test case), 根据每种参数运行一次.

其实到这里, JUnit单元测试的基本内容就这样了, 但是其实还是很难下手写单元测试. 其中一个重要原因就是, 如果做到隔离依赖可独立运行的单元测试. 比如想测试MVC项目的controller层代码, 就必须有一个HttpServletRequest, 难道测试的时候真的发起一个http请求么?


Test Double测试替身

为了解决依赖隔离的问题, 就要引入测试替身的概念, 就是把所需依赖伪造出来. 查这个概念会被如下五个名词迷惑好久.

几种令人迷惑的叫法:

  1. Dummy 仿制/傀儡: 啥也没有, 只是为了不报错. call return null;
  2. Stub 桩: 提供一些不方便搞的输入信息. call方法直接返回固定值
  3. Fake 伪造/骗子: 真的去实现了. Eg: 一个操作数据库的Repository类, 你用List装着元素, 这种实现就是Fake
  4. Mock 嘲弄: 主要是为了验证交互用的, 可以预设并断言行为, 其他都是断言状态的Eg: logger
  5. Spy 间谍: 安插间谍了, 对spy对象做的事都记住了

其实这五个东西, 就是实现测试替身, 伪造依赖的五种思路, 不用扣太细, 这篇博客中讲的比较清楚并附有示例代码.

那具体如何写测试替身呢? 简单的可以用现有类构造, 或者专门写一些Fake类来做, 不过好消息是有mock框架专门用于自动生成mock类.


Mockito

Mockito就是这样一个mock框架, 官网示例代码:

// mock creation
List mockedList = mock(List.class);

// some stub
when(mockedList.get(0)).thenReturn("first");

// using mock object - it does not throw any "unexpected interaction" exception
System.out.println(mockedList.get(0));
mockedList.add("one");
mockedList.clear();

// selective, explicit, highly readable verification
verify(mockedList).add("one");
verify(mockedList).clear();

可以看到, 使用起来非常简单, 如下基础功能:

  • @mock注解和mock方法
  • when then等方法
  • verify等方法

一些高级用法:

  • 使用类似Spring的@Autowired注解强行注入, 既没有构造器也没有setter的属性, 可通过@InjectMocks注解帮助注入进去.
  • 静态方法, 写死的new对象, mockito无法帮忙伪造, 可以使用powermock库增强
  • @Spy功能: 普通的mock会遇到一些问题. 比如如果mock一个request的话, request.getParameterMap().get(key)request.getParameterValues(key)都要考虑. 比如测MapReduce的时候mock一个context, 对context写一个重用的text对象导致验证失效.

    这些情况可能就需要我们自己来实现一个Fake的类用于测试. 而mockito为这种需求提供了@Spy, 其内部和@Mock类似, 提供了如下好处:

    • 写的fake类不用实现的每一个接口方法, 抽象类即可, 不想管的方法不写. @Spy就能生成对象.
    • 这个对象真正执行的时候, 会真的调用我们写的方法, 但是加入了spy的功能.

    举例来说, 为了测试MapReduce的WordCount程序的Mapper, 我写了如下FakeContext:

      @Spy
      private FakeContext context;
        
      static abstract class FakeContext extends Mapper.Context {
          Map<String, Integer> cache = new HashMap<>();
    
          public FakeContext() {
              new Mapper().super();
          }
    
          @Override
          public void write(Object o, Object o2) throws IOException, InterruptedException {
              String key = ((Text) o).toString();
              int value = ((IntWritable) o2).get();
              cache.compute(key, (k, v) -> v == null ? 1 : v + value);
          }
      }
    

Mockito的实现原理 简单说就是 反射

  • bytebuddy库动态生成要mock类的子类, 对这生成类的方法调用拦截到mockito自己的handler处理.
  • objenesis库绕过构造器生成对象

好用是好用, 但是more mock, more fragile:

  • 不知道的类mock容易出错
  • 容易违反OO原则, 如: 测试代码中包含逻辑; 面向类实现进行测试;

测试DAO层

一个很棘手且未解决的问题: 如何测试DAO/ORM层代码

依赖数据库预定义的schema和数据, 难以独立无依赖的运行, 甚至mock都难以下手.

回顾单元/集成测试图, 我们向右走一步, 要引入一些依赖了, 不再是纯模拟环境了:

  • 基于内存的数据库
  • 真实的库: 当使用了一些mysql特有语法的时候, 内存数据库可能不支持
  • docker: 当考虑jenkins之类的环境不能随便加一个库的时候, 可以考虑引入docker

为保证测试用例的repeatable特性, 每次运行测试库都应该是”干净”的.

  • 空的库, 每次运行重新建表&插入数据, 运行完丢掉
  • 表结构提前建好, 每次运行插入数据, 运行完回滚
  • 表结构和数据提前建好, 每次直接运行, 运行完回滚

使用DbUnit库维护测试数据和回滚.

对于mybatis来说, 给他一个DataSource就可以了.

如果再向右一步, 测试方法都依赖一些基础的配置, 为了省事可以一次运行测试多个DAO层类和方法.


难测

依赖太多难测: mock

框架层功能难测: 集成测试或不测

  • DAO层
  • MapReduce的job和input/output的配置
  • spring mvc的请求映射

代码太烂难测: 优化代码

  • 需要模拟不能替换的对象
  • 需要模拟具体的类而不是接口
  • 模拟值类型
  • 膨胀的方法参数
  • 太多依赖关系
  • 对象功能职责太多

参考

UPDATE0 各种mockito骚操作

// 基本操作 mock creation
    List mockedList = mock(List.class);
// some stub
    when(mockedList.get(0)).thenReturn("first");
// using mock object - it does not throw any "unexpected interaction" exception
    System.out.println(mockedList.get(0));
    mockedList.add("one");
    mockedList.clear();
// selective, explicit, highly readable verification
    verify(mockedList).add("one");
    verify(mockedList).clear();

//注入Autowire
    @Mock
    private TaskDAO taskDao;
    @InjectMocks
    private TaskService taskService = new TaskService();

//验证参数具体内容
    ArgumentCaptor<Task> taskCaptor = ArgumentCaptor.forClass(Task.class);
    verify(taskDao).updateStatus(taskCaptor.capture());
    assertTrue(TaskStatus.SUCCESS.getValue() == taskCaptor.getValue().getStatus());

UPDATE1 MapReduce测试

经组内同学发现, MR可以不需要安装任何环境, 直接本地运行, 非常适合调试和写单元测试, 非常赞.

    Configuration conf = new Configuration();
    conf.set("fs.default.name", "file:/");
    conf.set("mapreduce.jobtracker.address", "local");
  1. pom里添加hadoop-mapreduce-client-common依赖
  2. 为job在本地准备几个输入文件, 比如放在src/test/resources文件夹下
  3. 在单元测试类里面, 用上面三行conf生成job, 把正常配置job的代码拷贝过来
  4. Just run it