浅谈单元测试

这是单元测试吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Log4j2
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = {CpsApplication.class})
public class FeatureServiceTest {

@Resource
private FeatureService featureService;

@Test
public void getFeatureTest() {
// 构造入参
FeatureInputData featureInputData = new FeatureInputData();
featureInputData.setAlgoKey("AlgoKey");
featureInputData.setStrategyKey("StrategyKey");
featureInputData.setVersion("v0.0.1");
featureInputData.setFeatureType(FeatureTypeEnum.ALL);

// 方法调用
FeatureOutputData featureData = featureService.getFeatureData(featureInputData);

Assert.assertNotNull(featureData);
}
}

单元测试、集成测试的区别

单元测试(模块测试):是针对程序模块来进行正确性检验的测试工作,程序单元是应用的最小可测试部件。

  • 在过程化编程中,一个单元就是单个程序、函数、过程等
  • 对于面向对象编程,最小单元就是方法,包括基类、抽象类、或者派生类中的方法

集成测试:启动应用,连db等中间件。

系统级别测试:全链路,涉及的应用都要部署起来。包括端到端测试、链路测试、自动化回归测试、UI测试等。

单元测试 集成测试 系统级别测试
编写人员 开发 开发 开发 / 测试
编写场地 生产代码仓库内 生产代码仓库内 生产代码仓库内 / 生产代码仓库外
编写时间 代码发布前 代码发布前 代码发布前 / 代码发布后
编写成本
编写难度
反馈速度 极快,秒级 较慢,分钟级 慢,天级别
覆盖面积 代码行覆盖60-80% 分支覆盖40-60% 功能级别覆盖HappyPath 核心保障链路
环境依赖 代码级别,不依赖环境 依赖日常或本地环境 依赖预发或生产环境
外部依赖模拟 全部模拟 部分模拟 不模拟,完全使用真实环境

Junit

JUint是Java编程语言的单元测试框架,用于编写和运行可重复的自动化测试。

Quick Start

一个简单的目标测试方法:

1
2
3
4
5
public class NumberUtil {
public static int add(int a, int b) {
return a + b;
}
}

引入maven依赖:

1
2
3
4
5
6
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>

单元测试代码:

1
2
3
4
5
6
7
8
public class NumberUtilTest {

@Test
public void addTest() {
int res = NumberUtil.add(1, 1);
Assert.assertEquals("add方法结果异常", 2, res);
}
}

@Test
在junit4中,定义一个测试方法只需要在方法前加上@Test就行了。
注意:测试方法必须是public void,即公共、无返回数据。可以抛出异常。

Junit提供的断言测试方法

断言 描述
void assertEquals([String message],expected value,actual value) 断言两个值相等
void assertTrue([String message],boolean condition) 断言一个条件为真
void assertFalse([String message],boolean condition) 断言一个条件为假
void assertNotNull([String message],java.lang.Object object) 断言一个对象不为空
void assertNull([String message],java.lang.Object object) 断言一个对象为空
void assertSame([String message],java.lang.Object expected,java.lang.Object actual) 断言两个对象引用相同的对象
void assertNotSame([String message],java.lang.Object unexpected,java.lang.Object actual) 断言两个对象不是引用同一个对象
void assertArrayEquals([String message],expectedArray,resultArray) 断言预期数组和结果数组相等

Junit生命周期

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class LifeCircleTest {

@BeforeClass
public static void beforeClass() {
System.out.println("in before class");
}

@AfterClass
public static void afterClass() {
System.out.println("in after class");
}

@Before
public void before() {
System.out.println("in before");
}

@After
public void after() {
System.out.println("in after");
}

@Test
public void testCase() {
System.out.println("in test case");
}

@Test
public void testCase() {
System.out.println("in test case");
}

}

运行结果:

unit_test01
unit_test01

异常测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ExceptionTest {

@Test()
public void exceptionTest() {
int a = 0;
int b = 1 / a;
}

@Test(expected = ArithmeticException.class)
public void noExceptionTest() {
int a = 0;
int b = 1 / a;
}
}

运行结果:

unit_test02
unit_test02

超时时间测试

1
2
3
4
5
6
7
8
9
10
11
12
public class TimeoutTest {

@Test(timeout = 1000)
public void timeoutFailTest() throws InterruptedException {
Thread.sleep(5000);
}

@Test(timeout = 6000)
public void timeoutSuccessTest() throws InterruptedException {
Thread.sleep(5000);
}
}

运行结果:

unit_test03
unit_test03

Spring 单测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Log4j2
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = {CpsApplication.class})
public class FeatureServiceTest {

@Resource
private FeatureService featureService;

@Test
public void getFeatureTest() {
// 构造入参
FeatureInputData featureInputData = new FeatureInputData();
featureInputData.setAlgoKey("AlgoKey");
featureInputData.setStrategyKey("StrategyKey");
featureInputData.setVersion("v0.0.1");
featureInputData.setFeatureType(FeatureTypeEnum.ALL);

// 方法调用
FeatureOutputData featureData = featureService.getFeatureData(featureInputData);

Assert.assertNotNull(featureData);
}
}

@Runwith就是放在测试类名之前,用来确定这个类怎么运行的。也可以不标注,会使用默认运行器JUnit4.class。

  1. @RunWith(JUnit4.class) junit4的默认运行器
  2. @RunWith(Parameterized.class) 参数化运行器,配合@Parameters使用junit的参数化功能
  3. @RunWith(Suite.class) @SuiteClasses({ATest.class,BTest.class,CTest.class})测试集运行器配合使用测试集功能
  4. @RunWith(SpringJUnit4ClassRunner.class)集成了spring的一些功能

其他能力

参数化测试@RunWith(Parameterized.class)
套件测试@RunWith(Suite.class)
测试顺序@FixMethodOrder(MethodSorters.NAME_ASCENDING)

Mockito

什么是 Mock 测试

我们在写单元测试时,总会遇到类似这些问题:

  1. 构造的入参,对于极值、异常边界场景不好复现,相关的逻辑测不到,只能依靠测试环境或预发跑,运气不好可能要改好几次代码重启机器验证,费时费力
  2. 依赖别人接口,可能需要别人协助测试环境数据库插数才能跑通
  3. 依赖的别人的接口还没有开发完,为了不影响提测,如何完成单元测试
  4. 编写的单元测试依赖测试数据库的数据,每次跑都要数据库改数
  5. 对service层加了逻辑,跑单元测试本地验证的时候,由于种种原因,本地环境跑不起来,折腾半天跑起来验证完了,下次开发需求又遇到了另一个问题本地环境启动报错
  6. 我就想dubug到某一行代码,但是逻辑复杂,东拼西凑的参数就是走不到,自己看代码逻辑还要去问别人接口的返回值逻辑
    unit_test04
    unit_test04

Mock有模仿、伪造的含义。Mock测试就是在测试过程中,对于某些不容易构造或者不容易获取的对象,用一个虚拟的对象来创建以便测试的测试方法。

为什么选择Mockito

EasyMock与Mockito的对比文章:
https://code.google.com/archive/p/mockito/wikis/MockitoVSEasyMock.wiki
Mockito官网的与EasyMock对比文章:
https://github.com/mockito/mockito/wiki/Mockito-vs-EasyMock

其各有优劣,但主要还是Mockito的社区相对于其它Mock框架比较活跃。

Quick Start

简单的目标测试方法:

1
2
3
4
5
6
7
public UserVO queryUserByUserId(Long userId) {
UserVO userVO = userDAO.queryByUserId(userId);
if (userVO == null) {
log.warn("查询用户[{}]为空", userId);
}
return userVO;
}

引入maven依赖:

1
2
3
4
5
6
7
8
9
10
11
12
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>3.7.7</version>
<scope>test</scope>
</dependency>

单元测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@RunWith(MockitoJUnitRunner.class)
public class UserServiceTest {

/** mock依赖对象 */
@Mock
private UserDAO userDAO;

/** 测试对象 */
@InjectMocks
private UserService userService;

@Test
public void queryUserByUserId_Succeed() {
// 构造出参
UserVO userVO = new UserVO();
userVO.setUserName("username");
userVO.setAge(18);

// queryByUserId方法打桩
Mockito.doReturn(userVO).when(userDAO).queryByUserId(any());

// 实际测试方法
UserVO res = userService.queryUserByUserId(10000L);

Assert.assertEquals(userVO.getUserName(), res.getUserName());
}
}

@InjectMocks
作用于真实执行对象,即上文提及的A,针对实现类使用,不能作用在接口上。
@Mock
作用于需要mock的对象,即上文提及的B、C,对其进行虚拟、伪造。
any()

函数名 匹配类型
any() 所有对象类型
anyInt() 基本类型 int、非 null 的 Integer 类型
anyChar() 基本类型 char、非 null 的 Character 类型
anyShort() 基本类型 short、非 null 的 Short 类型
anyBoolean() 基本类型 boolean、非 null 的 Boolean 类型
anyDouble() 基本类型 double、非 null 的 Double 类型
anyFloat() 基本类型 float、非 null 的 Float 类型
anyLong() 基本类型 long、非 null 的 Long 类型
anyByte() 基本类型 byte、非 null 的 Byte 类型
anyString() String 类型(不能是 null)
anyList() List<T> 类型(不能是 null)
anyMap() Map<K, V>类型(不能是 null)
anyCollection() Collection<T>类型(不能是 null)
anySet() Set<T>类型(不能是 null)
any(Class<T> type) type类型的对象(不能是 null)
isNull() null
isNotNull() 非 null

PowerMock

为什么需要PowerMock补充

Mockito使用继承的方式实现mock的,用CGLIB生成mock对象代替真实的对象进行执行,所以无法mock私有方法、静态方法、Final方法。

PowerMock会根据你的mock要求,去修改写在注解@PrepareForTest里的class文件(当前测试类会自动加入注解中),以满足特殊的mock需求。例如:去除final方法的final标识,在静态方法的最前面加入自己的虚拟实现等。

Quick Start

简单的目标测试方法

1
2
3
4
public UserVO queryUser(Long userId) {
UserPO userPO = userDAO.queryByUser(userId);
return UserServiceConverter.convertUserVo(userPO);
}

单元测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@RunWith(PowerMockRunner.class)
@PrepareForTest( { UserServiceConverter.class })
public class UserServiceTest {

/** 模拟依赖对象 */
@Mock
private UserDAO userDAO;

/** 定义测试对象 */
@InjectMocks
private UserService userService;


@Test
public void testQueryUser_Succeed() {
PowerMockito.mockStatic(UserServiceConverter.class);
UserVO userVO = new EasyRandom().nextObject(UserVO.class);
PowerMockito.when(UserServiceConverter.convertUserVo(Mockito.any())).thenReturn(userVO);

UserPO userPO = new UserPO();
userPO.setUserName(userVO.getUserName());
userPO.setAge(userVO.getAge());
doReturn(userPO).when(userDAO).queryByUser(anyLong());


UserVO res = userService.queryUser(0L);

Assert.assertEquals(userVO.getUserName(), res.getUserName());
}

}

@RunWith(PowerMockRunner.class)
我们用了 PowerMockRunner ,MockitoJUnitRunner 就不能用了。但不要担心, @Mock 等注解还能用。
@PrepareForTest
告诉PowerMock准备测试某些类。需要使用此注释定义的类通常是需要进行字节码操作的类。这包括final类,带有final,private,static或本地方法的类,这些方法应该被mock,并且类应该在实例化时返回一个模拟对象。

工具

easy-random

Mock对象,构造pojo的每个属性,不需要自己逐个属性设置

引入maven依赖:

1
2
3
4
5
6
<dependency>
<groupId>org.jeasy</groupId>
<artifactId>easy-random-core</artifactId>
<version>4.2.0</version>
<scope>test</scope>
</dependency>

代码示例:

1
2
3
4
5
6
@Test
public void testQueryUser_Succeed() {
PowerMockito.mockStatic(UserServiceConverter.class);
UserVO userVO = new EasyRandom().nextObject(UserVO.class);
System.out.println(JSON.toJSONString(userVO));
}

运行结果:

unit_test05
unit_test05

TestMe

TestMe是Idea的插件,可以自动生成单元测试模板

unit_test06
unit_test06

Alt+Shift+Q选择Junit4&Mockito生成模板:

unit_test07
unit_test07

对于代码:

unit_test08
unit_test08

生成的模板:

unit_test09
unit_test09

本地查看单测覆盖率

查看某个单测类的覆盖率:

unit_test10
unit_test10

运行结果:

unit_test11
unit_test11

查看某个包的单测覆盖率:

unit_test12
unit_test12

单测实战

我们结合下面这三个问题,一起来思考一下如何写一个好的单测:

  1. 需要写几个单元测试方法?
  2. ValidationUtil.validate等方法需要mock吗?
  3. 需要校验哪些参数?
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    /**
    * 查询用户
    *
    * @param companyId 公司标识
    * @param startIndex 开始序号
    * @param pageSize 分页大小
    * @return 用户分页数据
    */
    public PageInfo<UserVO> queryUser(Long companyId, Long startIndex, Integer pageSize) {
    //入参校验
    if(ValidationUtil.validate(companyId)){
    throw new IllegalArgumentException("Invalid company Id");
    }

    // 查询用户数据
    // 查询用户数据: 总共数量
    Long totalSize = userDAO.countByCompany(companyId);
    // 查询接口数据: 数据列表
    List<UserVO> dataList = null;
    if (NumberHelper.isPositive(totalSize)) {
    dataList = userDAO.queryByCompany(companyId, startIndex, pageSize);
    }

    // 返回分页数据
    return new PageInfo<>(dataList);
    }

参考示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
@RunWith(MockitoJUnitRunner.class)
public class UserServiceTest {

/** 模拟依赖对象 */
/** 用户DAO */
@Mock
private UserDAO userDAO;

/** 定义测试对象 */
/** 用户服务 */
@InjectMocks
private UserService userService;

/**
* 测试: 查询用户-入参校验
*/
@Test
public void testQueryUser_Fail_WithBadInput() {
// 模拟依赖方法: userDAO.countByCompany
Long companyId = 123L;
Long startIndex = 90L;
Integer pageSize = 10;

Throwable throwable = catchThrowable(() -> userService.queryUser(companyId, startIndex, pageSize));
Assert.assertTrue(throwable instanceof IllegalArgumentException);
}

/**
* 测试: 查询用户-无数据
*/
@Test
public void testQueryUser_Succeed_NoData() {
// 模拟依赖方法
// 模拟依赖方法: userDAO.countByCompany
Long companyId = 12L;
Long startIndex = 90L;
Integer pageSize = 10;
Mockito.doReturn(0L).when(userDAO).countByCompany(companyId);

// 调用测试方法
PageInfo<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);
Assert.assertEquals(0, pageData.getSize());

// 验证依赖方法
// 验证依赖方法: userDAO.countByCompany
Mockito.verify(userDAO).countByCompany(companyId);

// 验证依赖对象
Mockito.verifyNoMoreInteractions(userDAO);
}

/**
* 测试: 查询用户-有数据
*/
@Test
public void testQueryUser_Succeed_WithData() {
// 模拟依赖方法: userDAO.countByCompany
Long companyId = 12L;
Mockito.doReturn(91L).when(userDAO).countByCompany(companyId);
// 模拟依赖方法: userDAO.queryByCompany
Long startIndex = 90L;
Integer pageSize = 10;

UserVO userVO = new EasyRandom().nextObject(UserVO.class);
ArrayList<UserVO> userVOS = Lists.newArrayList(userVO);
Mockito.doReturn(userVOS).when(userDAO).queryByCompany(companyId, startIndex, pageSize);

// 调用测试方法
PageInfo<UserVO> pageData = userService.queryUser(companyId, startIndex, pageSize);

Assert.assertEquals(1, pageData.getSize());
Assert.assertEquals(userVO.getUserName(), pageData.getList().get(0).getUserName());
Assert.assertEquals(userVO.getAge(), pageData.getList().get(0).getAge());

// 验证依赖方法
// 验证依赖方法: userDAO.countByCompany
Mockito.verify(userDAO).countByCompany(companyId);
// 验证依赖方法: userDAO.queryByCompany
Mockito.verify(userDAO).queryByCompany(companyId, startIndex, pageSize);

// 验证依赖对象
Mockito.verifyNoMoreInteractions(userDAO);
}

}

需要加上verify的原因:

  1. 验证mock的方法是否有调用及调用次数
  2. 入参是否正确

单元测试最佳实践

【强制】单元测试需要写在各自模块下的test包下,路径与实际代码一致

【强制】单元测试覆盖率的统计是以执行过测试代码为准,假如有多个分支逻辑,单测里面必须覆盖所有逻辑

【强制】返回结果必须做断言判断,看情况判断具体字段,每层关注点不一样,最主要的是关注有效性,哪个地方容易出问题单元测试要更详细,单元测试的有效性保证代码质量

  • manager层:正常manager层只关心事务及缓存相关,若无特殊逻辑则简单校验即可
  • service层:重点为service层的逻辑分支需全部覆盖,关键字段、逻辑需判断是否符合预期
  • rpc层:需判断调用方的返回参数是否为空,校验必须字段是否符合预期。部分抛异常再自己捕获的不需要mock异常情况
    • 带有属性转换的必须单独抽离到assember类,单元测试判断入参出参是否正确
  • provider层、mq层:
    • 既要包含调用JSF的功能测试类,也要有单独Mock单元测试
    • 带有属性转换的必须单独抽离到assember类,单元测试判断入参出参是否正确
    • 对于JSF功能测试这种单元测试需要在类名加上@ignore注解,以免gitrunner执行这样的单元测试,避免浪费不必要的资源
  • api层:配置忽略,不需要写单元测试
  • domain层:配置忽略,不需要写单元测试
  • common层:对于工具类等需要单独写单元测试

【强制】核心业务、核心应用、核心模块的增量代码确保单元测试通过

【推荐】编写单元测试代码遵守BCDE原则,以保证被测试模块的交付质量

  • B:Border,边界值测试,包括循环边界、特殊取值、特殊时间点、数序等
  • C:Correct,正确的输入,并得到预期的结果
  • D:Design,与设计文档相结合,来编写单元测试
  • E:Error,强制错误信息输入(如:非法数据、异常流程、非业务允许输入等),并得到预期的结果

【推荐】不要对单元测试存在如下误解

  • 那是测试同学干的事情
  • 单元测试代码不需要维护
  • 单元测试与线上故障没有辩证关系

附录

FIRST原则F-FAST(快速原则)

单元测试应该是可以快速运行的,在各种测试方法中,单元测试的运行速度是最快的,通常应该在几分钟内运行完毕
I-Independent(独立原则)
单元测试应该是可以独立运行的,单元测试用例互相无强依赖,无对外部资源的强依赖
R-Repeatable(可重复原则)
单元测试应该可以稳定重复的运行,并且每次运行的结果都是相同的
S-Self Validating(自我验证原则)
单元测试应该是用例自动进行验证的,不能依赖人工验证
T-Timely(及时原则)
单元测试必须及时的进行编写,更新和维护,以保证用例可以随着业务代码的变化动态的保障质量

AIR原则

A-Automatic(自动化原则)
单元测试应该是自动运行,自动校验,自动给出结果
I-Independent(独立原则)
单元测试应该是独立运行,互相之间无依赖,对外部资源无依赖,多次运行之间无依赖
R-Repeatable(可重复原则)
单元测试是可重复运行的,每次的结果都稳定可靠

Junit注解说明

@Test
在junit3中,是通过对测试类和测试方法的命名来确定是否是测试,且所有的测试类必须继承junit的测试基类。在junit4中,定义一个测试方法变得简单很多,只需要在方法前加上@Test就行了。
注意:测试方法必须是public void,即公共、无返回数据。可以抛出异常。

@Ignore
有时候我们想暂时不运行某些测试方法\测试类,可以在方法前加上这个注解。在运行结果中,junit会统计忽略的用例数,来提醒你。

@BeforeClass
当我们运行几个有关联的用例时,可能会在数据准备或其它前期准备中执行一些相同的命令,这个时候为了让代码更清晰,更少冗余,可以将公用的部分提取出来,放在一个方法里,并为这个方法注解@BeforeClass。意思是在测试类里所有用例运行之前,运行一次这个方法。例如创建数据库连接、读取文件等。
注意:方法名可以任意,但必须是public static void,即公开、静态、无返回。这个方法只会运行一次。

@AfterClass
跟@BeforeClass对应,在测试类里所有用例运行之后,运行一次。用于处理一些测试后续工作,例如清理数据,恢复现场。
注意:同样必须是public static void,即公开、静态、无返回。这个方法只会运行一次。

@Before
与@BeforeClass的区别在于,@Before不止运行一次,它会在每个用例运行之前都运行一次。主要用于一些独立于用例之间的准备工作。
比如两个用例都需要读取数据库里的用户A信息,但第一个用例会删除这个用户A,而第二个用例需要修改用户A。那么可以用@BeforeClass创建数据库连接。用@Before来插入一条用户A信息。
注意:必须是public void,不能为static。不止运行一次,根据用例数而定。

@After
与@Before对应。

@Runwith

  • 首先要分清几个概念:测试方法、测试类、测试集、测试运行器。
  • 其中测试方法就是用@Test注解的一些函数。
  • 测试类是包含一个或多个测试方法的一个Test.java文件。
  • 测试集是一个suite,可能包含多个测试类。
  • 测试运行器则决定了用什么方式偏好去运行这些测试集/类/方法。
  • 而@Runwith就是放在测试类名之前,用来确定这个类怎么运行的。也可以不标注,会使用默认运行器。常见的运行器有:
    • @RunWith(Parameterized.class) 参数化运行器,配合@Parameters使用junit的参数化功能
    • @RunWith(Suite.class) @SuiteClasses({ATest.class,BTest.class,CTest.class})测试集运行器配合使用测试集功能
    • @RunWith(JUnit4.class) junit4的默认运行器
    • @RunWith(JUnit38ClassRunner.class) 用于兼容junit3.8的运行器
    • 一些其它运行器具备更多功能。例如@RunWith(SpringJUnit4ClassRunner.class)集成了spring的一些功能

@Parameters
用于使用参数化功能。