单元测试介绍与实践
为什么单元测试
- 天然的方法说明文档
- 代码质量的保证
- 持续重构的定心丸
什么是好的单元测试
- 单元测试需要自动化执行(CI)
- 单元测试需要快速执行
- 避免改代1行代码,单测跑5分钟的情况,谁也不愿意等
- 单元测试不应该依赖测试的执行顺序,UT相互之间是独立的
- 单元测试不应该依赖数据库,文件IO或任何长耗时任务。相反,单元测试需要与外部依赖隔离。
- 单元测试是持续稳定的,在任何时候,任何环境中都是可执行的
- 单元测试要有意义
- get,set没必要写ut,核心的业务逻辑要写
- 单元测试要尽量简短,及时维护
- 太长太复杂的代码谁也不愿意看,看看是不是代码结构有问题,拆分逻辑单一职责原则(Single Responsibility Principle,SRP)
单元测试原则
AIR原则:
- Automatic自动化
- UT应该全部自动执行
- 不能是通过手动打log验证UT结果,应该使用断言来验证
- Independent独立性
- UT用例之间不匀速相互调用,也不允许有执行次序的依赖
- Repeatable可重复性
- 不受外界环境的影响,尤其是一些网络,服务,中间件等的依赖,要和环境解耦
测试什么
- 某个类或者函数,粒度要足够小
- 常用的输入输出组合
- 正确的输入,得到正确的输出
- 错误的输入(如非法数据,异常流程,非业务允许输入等)得到预期的错误结果
- 边界条件和异常
- 空值,特殊取值,特殊时间点,数据顺序,循环边界等
单测覆盖率
-
行覆盖
- 统计单测执行了多少行
-
分支覆盖
- 统计逻辑分支是否都覆盖
-
条件判定覆盖
- 所有的条件每种可能都执行一次,同时每种条件的结果也都至少执行一次
好用的测试框架
Junit5
常用注解
注解 | 说明 |
---|---|
@Test | 表示方法是测试方法,JUnit5中去掉了该注解的timeout参数支持 |
@BeforeEach | 在每一个测试方法运行前,都运行一个指定的方法 |
@AfterEach | 表示在每个单元测试之后执行 |
@BeforeAll | 表示在所有单元测试之前执行 |
@AfterAll | 表示在所有单元测试之后执行 |
@Tag | 表示单元测试类别 |
@Disabled | 表示测试类或测试方法不执行,类似于JUnit4中的@Ignore |
@Timeout | 表示测试方法运行如果超过了指定时间将会返回错误 |
@ExtendWith | 为测试类或测试方法提供扩展类引用 |
@DisplayName | 为测试类或者测试方法设置展示名称 |
@RepeatedTest | 表示方法可重复执行 |
@ParameterizedTest | **:*表示方法是参数化测试 |
详细注解:https://junit.org/junit5/docs/current/user-guide/
示例
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.*;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
class SortTest {
@BeforeEach
void init() {
System.out.println("create connections...");
}
@AfterEach
void destroy() {
System.out.println("close connections...");
}
@Test
@Tag("fast")
@DisplayName("测试插入排序")
@RepeatedTest(5)
void insertSort() {
int[] arr = {3, 1, 2, 5, 4};
int[] result = Sort.insertSort(arr);
Arrays.sort(arr);
Assertions.assertThat(result)
.isSorted()
.as("测试插入排序");
}
@Test
@Tag("fast")
@DisplayName("测试插入排序--输入为null")
void insertSortNullArr() {
int[] arr = null;
int[] result = Sort.insertSort(arr);
Assertions.assertThat(result)
.isEqualTo(null)
.as("测试插入排序,输入为null,输出为null");
}
@Test
@DisplayName("过期的测试")
@Disabled
void insertSortDisabled() {
//我过期了
}
@Test
@DisplayName("超时测试")
@Timeout(value = 1, unit = TimeUnit.SECONDS)
public void timeoutTest() throws InterruptedException {
TimeUnit.SECONDS.sleep((long) 0.9);
//如果测试方法时间超过1s将会异常
// TimeUnit.SECONDS.sleep((long) 1.9);
}
}
todo Junit5测试SpringBoot
Mockito3
示例
- 一版流程:given-when-then
@Test
public void thenReturn() {
//mock一个List类
List<String> list = mock(List.class);
//预设当list调用get()时返回hello,world
Mockito.when(list.get(anyInt())).thenReturn("hello,world");
String result = list.get(0);
Assert.assertEquals("hello,world", result);
}
- 验证行为是否发生
//模拟创建一个List对象
List<Integer> mock = Mockito.mock(List.class);
//调用mock对象的方法
mock.add(1);
mock.clear();
//验证方法是否执行
Mockito.verify(mock).add(1);
Mockito.verify(mock).clear();
- 多次触发返回不同值
//mock一个Iterator类
List<String> list = mock(List.class);
//预设当list调用get()时第一次返回hello,第n次都返回world
Mockito.when(list.get(anyInt())).thenReturn("hello").thenReturn("world");
// 下面这句和上面的语句等价
Mockito.when(list.get(anyInt())).thenReturn("hello","world");
//使用mock的对象
String result = list.get(0) + " " + list.get(1) + " " + list.get(2);
//验证结果
Assert.assertEquals("hello world world",result);
- 模拟抛出异常
@Test(expected = IOException.class)//期望报IO异常
public void when_thenThrow() throws IOException{
OutputStream mock = Mockito.mock(OutputStream.class);
//预设当流关闭时抛出异常
Mockito.doThrow(new IOException()).when(mock).close();
mock.close();
}
- 匹配任意参数
Mockito.anyInt()
任何 int 值 ;
Mockito.anyLong()
任何 long 值 ;
Mockito.anyString()
任何 String 值 ;
Mockito.eq(Object)
任何匹配值 ;
Mockito.any(XXX.class)
任何 XXX 类型的值 ;
Mockito.any()
任何对象 等
List list = Mockito.mock(List.class);
//匹配任意参数
Mockito.when(list.get(Mockito.anyInt())).thenReturn(1);
Assert.assertEquals(1,list.get(1));Assert.assertEquals(1,list.get(999));
注意:使用了参数匹配,那么所有的参数都必须通过matchers来匹配
@Test
public void matchers() {
Comparator comparator = mock(Comparator.class);
comparator.compare("nihao", "hello");
//如果你使用了参数匹配,那么所有的参数都必须通过matchers来匹配
Mockito.verify(comparator).compare(anyString(), Mockito.eq("hello"));
//下面的为无效的参数匹配使用
// Mockito.verify(comparator).compare(anyString(),"hello");
}
- void方法
@Test
public void Test() {
A a = Mockito.mock(A.class);
//void 方法才能调用doNothing()
Mockito.doNothing().when(a).setName("bb");
a.setName("bb");
// 断言失败
Assert.assertEquals("bb",a.getName());
}
class A {
private String name;
public void setName(String name){
this.name = name;
}
public String getName(){
return name;
}
}
- 预期回调接口生成期望值
@Test
public void answerTest(){
List mockList = Mockito.mock(List.class);
//使用方法预期回调接口生成期望值(Answer结构)
Mockito.when(mockList.get(Mockito.anyInt())).thenAnswer(new CustomAnswer());
Assert.assertEquals("hello world:0",mockList.get(0));
Assert.assertEquals("hello world:999",mockList.get(999));
}
private class CustomAnswer implements Answer<String> {
@Override
public String answer(InvocationOnMock invocation) throws Throwable {
Object[] args = invocation.getArguments();
return "hello world:"+args[0];
}
}
等价于:(也可使用匿名内部类实现)
@Test
public void answer_with_callback() {
//使用Answer来生成我们我们期望的返回
Mockito.when(mockList.get(Mockito.anyInt())).thenAnswer(new Answer<Object>() {
@Override
public Object answer(InvocationOnMock invocation) throws Throwable {
Object[] args = invocation.getArguments();
return "hello world:"+args[0];
}
});
Assert.assertEquals("hello world:0",mockList.get(0));
Assert.assertEquals("hello world:999",mockList.get(999));
}
AssertJ
示例
其他
- 注意bytebuddy的版本,mockito3.3.3 依赖 bytebuddy1.10.5
<!-- 单元测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>1.10.5</version>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.8.0</version>
<scope>test</scope>
</dependency>
<!-- 单测结束 -->
- maven执行测试用例的插件,保证流水线执行测试:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0-M3</version>
<configuration>
<excludes>
<exclude>some test to exclude here</exclude>
</excludes>
</configuration>
</plugin>
- 集成JaCoCo代码测试覆盖率统计
个人理解
- UT代码要简单;
- 善用Mock;
- UT的命名和注释很重要;
- UT并不能保证完全没bug;
- 如果发现单元测试不好写,首先怀疑是代码有问题,优先选择重构代码,实在不行再考虑使用框架的高级特性(如PowerMock可以mock私有方法,static方法,new变量等,这些特性理论上增加了写垃圾代码的可能 );
- UT是体力话,写UT时间和代码开发时间相当;
- UT是一件磨刀不误砍柴工的事,长远收益。故障越早发现,修复成本越低;
- UT会倒逼开发人员白盒地思考代码逻辑和结构,更好地对代码进行设计,我认为这也是UT最重要的意义
- 重构的时候心里有点底(我认为代码应该随时重构,哪怕一个变量名)
- 写单测是为了证明程序有错,而不是程序无错
后续
基于内存数据库的单元测试...
参考
https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html Mockito官方文档
https://junit.org/junit5/docs/current/user-guide/ JUnit5 User Guide
https://time.geekbang.org/column/article/185684 极客时间-对于单元测试和重构的理解
https://www.liaoxuefeng.com/wiki/1252599548343744/1304065789132833