Java单元测试基础
最近正在搞gitlab+jenkins+nexus的工具链, 其实考虑持续集成的过程, 涉及到的方面很多:
- 配置管理
- 包管理/编译打包
- 单元测试/集成测试/测试覆盖率/静态代码质量分析
单元测试是其中比较简单且实用的功能, 但是其实一直没有写过, 这次做了调研推广.
示例代码见gitee
Intro
整个测试从TDD角度讲是个很大的过程, 用户需求/场景故事/测试验收等等.
从测试方法角度有很多名字黑箱/白箱/冒烟/金丝雀/系统测试/功能性测试.
但从程序员角度讲, 最需要关注的还是单元测试和集成测试.
我理解程序员编写的测试程序正确性的程序. 所谓单元是指要测试尽量小的程序单元.
更简单的理解就是, 把之前总用psvm(main方法)跑一下的习惯, 变成写一个测试用例.
这里废话一下单元测试的好处:
- 反向验证代码是否优雅: 好的代码一定是方便单元测试的.
- 重构友善: 如果有单元测试, 重构一个方法后, 之后测试通过即可保证重构的正确性.
- 尽早’跑’程序, 尽早发现bug. 尤其是类似MapReduce的程序, 上集群运行调试太慢.
如果你的代码是这样的, 也没必要写单元测试:
- 一次性的代码, 只会短暂运行的代码
- 不会被别人拿去用的代码
- 不会涉及修改和重构的代码
- 逻辑简单一目了然的
如果是这样还是尽量写单元测试:
- long term工作的代码, 会迭代更新和维护的代码
- 开源的或者被很多人引用的代码
- 逻辑复杂, 分支较多
FIRST原则
Fast: 测试的尽量是最小单位, 一个类一个方法的测. 一个案例只测一个方法.
Independent:
- 可以独立运行
- 不同测试案例之间不要有依赖.
- 不要对外部系统有依赖
Repeatable:
- 反复执行无副作用
- 不要包含逻辑. 否则方法重构后测试用例可能失效
Self-Validating: 直接可以验证对错的, 不需要外部来判断
Timely: 及时的
怎么写
3A写法:
- arrange: 就是做一些setup的工作,把要测试的东西构造出来, 测试数据准备一下
- act: 执行被测方法
- assert; 断言/验证是否和预想的一样
这里的验证, 简单可以有三种方式:
- 验证返回值
- 验证状态
- 验证对象交互
JUnit
虚的概念扯完了, 具体到Java开发中, 这里选用Junit单元测试框架. Junit使用起来很简单:
- 为每个被测类写一个测试类.
- 每个@Test作为一个测试用例.
- Runner启动和运行测试类.
接下来从注解/断言/runner三方面了解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测试替身
为了解决依赖隔离的问题, 就要引入测试替身的概念, 就是把所需依赖伪造出来. 查这个概念会被如下五个名词迷惑好久.
几种令人迷惑的叫法:
- Dummy 仿制/傀儡: 啥也没有, 只是为了不报错. call return null;
- Stub 桩: 提供一些不方便搞的输入信息. call方法直接返回固定值
- Fake 伪造/骗子: 真的去实现了. Eg: 一个操作数据库的Repository类, 你用List装着元素, 这种实现就是Fake
- Mock 嘲弄: 主要是为了验证交互用的, 可以预设并断言行为, 其他都是断言状态的Eg: logger
- 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的请求映射
代码太烂难测: 优化代码
- 需要模拟不能替换的对象
- 需要模拟具体的类而不是接口
- 模拟值类型
- 膨胀的方法参数
- 太多依赖关系
- 对象功能职责太多
参考
- junit hamcrest mockito powermock dbunit官网
- 示例代码
- Test Double测试替身介绍
- 一个台湾人的博客
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");
- pom里添加hadoop-mapreduce-client-common依赖
- 为job在本地准备几个输入文件, 比如放在src/test/resources文件夹下
- 在单元测试类里面, 用上面三行conf生成job, 把正常配置job的代码拷贝过来
- Just run it