前言

开发人员写的常常是“单元测试”,但其实可以细分成 单元测试集成测试 两个。

划分的原因拿常见的 Spring IoC 举例。Spring 不同Bean之间相互依赖,例如某API业务逻辑中会依赖不同模块的 Service,Service 方法中又可能依赖不同的 Dao 层方法,甚至还会通过 RPC、HTTP 调用外部服务方法。这给我们写测试用例带来了难度,本来只想测试某个方法的功能,却要考虑一连串的依赖关系。

单元测试

单元测试:是指对软件中的最小可测试单元进行检查和验证。

通常任何软件都会划分为不同的模块和组件。单独测试一个组件时,我们叫做单元测试。单元测试用于验证相关的一小段代码是否正常工作。单元测试不是用于发现应用程序范围内的 bug,或者回归测试的 bug,而是分别检测每个代码片段。

单元测试不验证应用程序代码是否和外部依赖正常工作。它聚焦与单个组件并且 Mock 所有和它交互的依赖。例如,方法中调用发短信的服务,以及和数据库的交互,我们只需要 Mock 假执行即可,毕竟测试的焦点在当前方法上。

单元测试的特点:

  • 不依赖任何模块。
  • 基于代码的测试,不需要在 ApplicationContext 中运行。
  • 方法执行快,500ms以内(也和不启动 Spring 有关)。
  • 同一单元测试可重复执行N次,并每次运行结果相同。

集成测试

集成测试:在单元测试的基础上,将所有模块按照设计要求组装成为子系统或系统,进行集成测试。

集成测试主要用于发现用户端到端请求时不同模块交互产生的问题。集成测试范围可以是整个应用程序,也可以是一个单独的模块,取决于要测试什么。

在集成测试中,我们应该聚焦于从控制器层到持久层的完整请求。应用程序应该运行嵌入服务(例如:Tomcat)以创建应用程序上下文和所有 bean。这些 bean 有的可能会被 Mock 覆盖。

集成测试的特点:

  • 集成测试的目的是测试不同的模块一共工作能否达到预期。
  • 应用程序应该在 ApplicationContext 中运行。Spring boot 提供 @SpringBootTest 注解创建运行上下文。
  • 使用 @TestConfiguration 等配置测试环境。

测试框架

spring-boot-starter-test

SpringBoot中有关测试的框架,主要来源于 spring-boot-starter-test。一旦依赖了spring-boot-starter-test,下面这些类库将被一同依赖进去:

  • JUnit:java测试事实上的标准。
  • Spring Test & Spring Boot Test:Spring的测试支持。
  • AssertJ:提供了流式的断言方式。
  • Hamcrest:提供了丰富的matcher。
  • Mockito:mock框架,可以按类型创建mock对象,可以根据方法参数指定特定的响应,也支持对于mock调用过程的断言。
  • JSONassert:为JSON提供了断言功能。
  • JsonPath:为JSON提供了XPATH功能。

测试环境自定义Bean

  • @TestComponent:该注解是另一种@Component,在语义上用来指定某个Bean是专门用于测试的。该注解适用于测试代码和正式混合在一起时,不加载被该注解描述的Bean,使用不多。
  • @TestConfiguration:该注解是另一种@TestComponent,它用于补充额外的Bean或覆盖已存在的Bean。在不修改正式代码的前提下,使配置更加灵活。

JUnit

前者说了,JUnit 是一个Java语言的单元测试框架,但同样可用于集成测试。当前最新版本是 JUnit5。

常见区别有:

  • JUnit4 所需 JDK5+ 版本即可,而 JUnit5 需 JDK8+ 版本,因此支持很多 Lambda 方法。
  • JUnit 4将所有内容捆绑到单个jar文件中。Junit 5由3个子项目组成,即JUnit Platform,JUnit Jupiter和JUnit Vintage。核心是JUnit Jupiter,它具有所有新的junit注释和TestEngine实现,以运行使用这些注释编写的测试。而JUnit Vintage包含对JUnit3、JUnit4的兼容,所以spring-boot-starter-test新版本pom中往往会自动exclusion它。
  • SpringBoot 2.2.0 开始引入 JUnit5 作为单元测试默认库,在 SpringBoot 2.2.0 之前,spring-boot-starter-test 包含了 JUnit4 的依赖,SpringBoot 2.2.0 之后替换成了 Junit Jupiter。

JUnit5 和 JUnit4 在注解上的区别在于:

功能JUnit4JUnit5
声明一种测试方法@Test@Test
在当前类中的所有测试方法之前执行@BeforeClass@BeforeAll
在当前类中的所有测试方法之后执行@AfterClass@AfterAll
在每个测试方法之前执行@Before@BeforeEach
在每个测试方法之后执行@After@AfterEach
禁用测试方法/类@Ignore@Disabled
测试工厂进行动态测试NA@TestFactory
嵌套测试NA@Nested
标记和过滤@Category@Tag
注册自定义扩展NA@ExtendWith

RunWith 和 ExtendWith

在 JUnit4 版本,在测试类加 @SpringBootTest 注解时,同样要加上 @RunWith(SpringRunner.class)才生效,即:

1
2
3
4
5
@SpringBootTest
@RunWith(SpringRunner.class)
class HrServiceTest {
...
}

但在 JUnit5 中,官网告知 @RunWith 的功能都被 @ExtendWith 替代,即原 @RunWith(SpringRunner.class) 被同功能的 @ExtendWith(SpringExtension.class) 替代。但 JUnit5 中 @SpringBootTest 注解中已经默认包含了 @ExtendWith(SpringExtension.class)。

因此,在 JUnit5 中只需要单独使用 @SpringBootTest 注解即可。其他需要自定义拓展的再用 @ExtendWith,不要再用 @RunWith 了。

Mockito

测试驱动的开发(TDD)要求我们先写单元测试,再写实现代码。在写单元测试的过程中,我们往往会遇到要测试的类有很多依赖,这些依赖的类/对象/资源又有别的依赖,从而形成一个大的依赖树。而 Mock 技术的目的和作用是模拟一些在应用中不容易构造或者比较复杂的对象,从而把测试与测试边界以外的对象隔离开。

Mock 框架有很多,除了传统的 EasyMock、Mockito以外,还有PowerMock、JMock、JMockit等。这里选用 Mockito ,是因为 Mockito 在社区流行度较高,而且是 SpringBoot 默认集成的框架。

Mockito 框架中最核心的两个概念就是 MockStub。测试时不是真正的操作外部资源,而是通过自定义的代码进行模拟操作。我们可以对任何的依赖进行模拟,从而使测试的行为不需要任何准备工作或者不具备任何副作用。

当我们在测试时,如果只关心某个操作是否执行过,而不关心这个操作的具体行为,这种技术称为 mock。比如我们测试的代码会执行发送邮件的操作,我们对这个操作进行 mock;测试的时候我们只关心是否调用了发送邮件的操作,而不关心邮件是否确实发送出去了。

另一种情况,当我们关心操作的具体行为,或者操作的返回结果的时候,我们通过执行预设的操作来代替目标操作,或者返回预设的结果作为目标操作的返回结果。这种对操作的模拟行为称为 stub(打桩)。比如我们测试代码的异常处理机制是否正常,我们可以对某处代码进行 stub,让它抛出异常。再比如我们测试的代码需要向数据库插入一条数据,我们可以对插入数据的代码进行stub,让它始终返回1,表示数据插入成功。

推荐一个 Mockito 中文文档

项目中,有些函数需要处理某个服务的返回结果,而在对函数单元测试的时候,又不能启动那些服务,这里就可以利用Mockito工具,其中有如下三种注解

@InjectMocks

创建一个实例,简单的说是这个Mock可以调用真实代码的方法,其余用@Mock(或@Spy)注解创建的mock将被注入到用该实例中。

@Mock

对函数的调用均执行mock(即虚假函数),不执行真正部分。

@Spy

对函数的调用均执行真正部分。

mock 和 spy 的区别

Mockito中的Mock和Spy都可用于拦截那些尚未实现或不期望被真实调用的对象和方法,并为其设置自定义行为。二者的区别在于Mock不真实调用,Spy会真实调用。

如现在mock了List.size()方法=》mockList,mockList是通过mock生成的,List的add、get等其他方法都失效,返回的数据都为null。但如果是通过spy生成的,则会实际走其方法生成真实数据。

平时开发过程中,我们通常只需要mock类的某些方法,用mock即可。

案例问题 - 解决方案

实例对象的注入对象和注入对象含有相同的属性。举例说明:实例对象ClassA中含有注入对象ClassB、ClassC,实例对象ClassB中也含有ClassC。

实例对象中含有太多的注入类,并且大部分的代码走真实调用方式,只想要数据库操作的部分代码走Mock调用的方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* @使用@Autowired注解是让实例对象正常注入
* 使用@InjectMocks注解是为了向里面添加@Mock注入的对象
*/
@Autowired
@InjectMocks
private ClassA classA;

/**
* @使用@Autowired注解是让实例对象正常注入
* 使用@InjectMocks注解是为了向里面添加@Mock注入的对象
*/
@Autowired
@InjectMocks
private ClassB classB;

/**
* 使用@Mock注解封装需要被模拟调用的对象
*/
@Mock
private ClassC classC;

插件 Squaretest

强烈推荐这个自动生成单元测试代码的插件,像本文说的框架搭配,默认的模板就是 JUnit5Mockito.java.ft

在选中类右键Generate -> Generate Test 后,不光能生成测试类和方法,甚至连Mockito 数据、方法和 Assertions 等都写好了,只需要自己改一改即可。

示例

单元测试示例

因为 JUnit5、Mockito 都是 spring-boot-starter-test 默认依赖的,所以 pom 中无需引入其他特殊依赖。先写个简单的 Service 层方法,通过两张表查询数据。
HrService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
@AllArgsConstructor
@Service
public class HrService {
private final OrmDepartmentDao ormDepartmentDao;
private final OrmUserDao ormUserDao;

List<OrmUserPO> findUserByDeptName(String deptName) {
return ormDepartmentDao.findOneByDepartmentName(deptName)
.map(OrmDepartmentPO::getId)
.map(ormUserDao::findByDepartmentId)
.orElse(Collections.emptyList());
}
}

IDEA 创建测试类

接下来针对该 Service 类创建测试类,我们使用的开发工具是 IDEA。点进当前类,右键->Go To->Test->Create New Test,在 Testing library 中选择 Junit5,则在对应目录生成测试类和方法。

HrServiceTest.java

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
@ExtendWith(MockitoExtension.class)
class HrServiceTest {
@Mock
private OrmDepartmentDao ormDepartmentDao;
@Mock
private OrmUserDao ormUserDao;
@InjectMocks
private HrService hrService;

@DisplayName("根据部门名称,查询用户")
@Test
void findUserByDeptName() {
Long deptId = 100L;
String deptName = "行政部";
OrmDepartmentPO ormDepartmentPO = new OrmDepartmentPO();
ormDepartmentPO.setId(deptId);
ormDepartmentPO.setDepartmentName(deptName);
OrmUserPO user1 = new OrmUserPO();
user1.setId(1L);
user1.setUsername("001");
user1.setDepartmentId(deptId);
OrmUserPO user2 = new OrmUserPO();
user2.setId(2L);
user2.setUsername("002");
user2.setDepartmentId(deptId);
List<OrmUserPO> userList = new ArrayList<>();
userList.add(user1);
userList.add(user2);

Mockito.when(ormDepartmentDao.findOneByDepartmentName(deptName))
.thenReturn(
Optional.ofNullable(ormDepartmentPO)
.filter(dept -> deptName.equals(dept.getDepartmentName()))
);
Mockito.doReturn(
userList.stream()
.filter(user -> deptId.equals(user.getDepartmentId()))
.collect(Collectors.toList())
).when(ormUserDao).findByDepartmentId(deptId);

List<OrmUserPO> result1 = hrService.findUserByDeptName(deptName);
List<OrmUserPO> result2 = hrService.findUserByDeptName(deptName + "error");

Assertions.assertEquals(userList, result1);
Assertions.assertEquals(Collections.emptyList(), result2);
}

因为单元测试不用启动 Spring 容器,则无需加 @SpringBootTest,因为要用到 Mockito,只需要自定义拓展 MockitoExtension.class 即可,依赖简单,运行速度更快。

可以明显看到,单元测试写的代码,怎么是被测试代码长度的好几倍?其实单元测试的代码长度比较固定,都是造数据和打桩,但如果针对越复杂逻辑的代码写单元测试,还是越划算的。

集成测试示例

还是那个方法,如果使用Spring上下文,真实的调用方法依赖,可直接用下列方式:

1
2
3
4
5
6
7
8
9
10
11
12
@SpringBootTest
class HrServiceTest {
@Autowired
private HrService hrService;

@DisplayName("根据部门名称,查询用户")
@Test
void findUserByDeptName() {
List<OrmUserPO> userList = hrService.findUserByDeptName("行政部");
Assertions.assertTrue(userList.size() > 0);
}
}

还可以使用@MockBean@SpyBean替换Spring上下文中的对应的Bean:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@SpringBootTest
class HrServiceTest {
@Autowired
private HrService hrService;
@SpyBean
private OrmDepartmentDao ormDepartmentDao;

@DisplayName("根据部门名称,查询用户")
@Test
void findUserByDeptName() {
String deptName="行政部";
OrmDepartmentPO ormDepartmentPO = new OrmDepartmentPO();
ormDepartmentPO.setDepartmentName(deptName);
Mockito.when(ormDepartmentDao.findOneByDepartmentName(ArgumentMatchers.anyString()))
.thenReturn(Optional.of(ormDepartmentPO));
List<OrmUserPO> userList = hrService.findUserByDeptName(deptName);
Assertions.assertTrue(userList.size() > 0);
}
}

小提示:@SpyBean 和 spring boot data 的问题

当用 @SpyBean 添加到 spring data jpa 的dao层上时(继承 JpaRepository 的接口),会无法启动容器,报错 org.springframework.beans.factory.BeanCreationException: Error creating bean with name。包括 mongo 等 spring data 都会有此问题,是 spring boot 官方不支持,可查看 Issues-7033,已在 spring boot 2.5.3 版本修复。