Spring Boot 单元测试实践(二)
前言
在前文《Spring Boot 单元测试实践》中讲了在单元测试中外部依赖需要进行 Mock,从而保证测试用例的 R(可重复的) 原则。
那么如何对依赖 MySQL,Redis、MQ 等相关操作去进行 Mock 呢?本文基于 Spring Boot 2.3、Junit 5、Mockito 来进行一个简单的示范,来说明如何去进行 Mock 和 Stub 的,同时附带一些 Junit5 的简单操作.
MocK
Mock 方法是单元测试中常见的一种技术,它的主要作用是模拟一些在应用中不容易构造或者比较复杂的对象,从而把测试与测试边界以外的对象隔离开。1
Stub
桩(Stub / Method Stub)是指用来替换一部分功能的程序段。桩程序可以用来模拟已有程序的行为(比如一个远端机器的过程)或是对将要开发的代码的一种临时替代。因此,打桩技术在程序移植、分布式计算、通用软件开发和测试中用处很大。2
实践
引入依赖
Spring Boot 2.3.12.RELEASE、JPA、RabbitMQ、Redis,高版本的 Spring Boot 已经升级为 Junit 5 (但还保留了 Junit 4 的依赖,可以 exclude 掉)
完整依赖
Junit 5
Junit 5 与 4 有一些差异,但差异并不大,同时在断言方面提供了更全的功能(相对 Junit 4)
而就 Spring 而言,最大差别就是想要使用 Spring 容器就得使用以下方式:
@RunWith(SpringRunner.class) => @ExtendWith(SpringExtension.class)
复制代码
@RunWith(SpringRunner.class)
能用但是无法再注入 Bean 了,包括 MockBean,下图 debug 可以看到:
准备环境
说明
业务场景为某个活动海报的一个阶段领奖操作,根据现有业务逻辑简化改造而来
ActivityRepository
:活动仓储类,操作数据ActivityService
:此类依赖ActivityRepository
以及RedisTemplate
ActivityService#award
:本次需要进行单元测试的业务方法,此法方法依赖了数据库和 Redis
领奖伪代码:
public void award(activityId, posterId, stageId, userId) {
// 根据 activityId 检查活动是否存在
// 从 Redis 获取阶段的领奖状态(Redis 以 Hash 结构存储活动海报的阶段领奖状态,key 为 posterId(一个用户在一个活动内 posterId 唯一), field 为 stageId)
if (status == null) {
// 状态数据不存在,查询数据库是否有领奖记录
if (exist) {
// 同步至 redis 并返回
return;
}
} else (status) {
return;
}
/* 没有领奖则进行领奖操作 */
// 查询阶段
// 保存至数据库
// 领奖状态写入 Redis
}
复制代码
完整代码
设计 Case
case 1,2,4 为附加的,如何 mock 请看 case 3
Case 1:依赖基础设施
使用 @SpringBooTest
注解需要配置相关依赖才能启动
@Slf4j
@SpringBootTest
public class Case1Test {
@Test
// 此为 Junit 5 的注解,别名
@DisplayName("依赖基础设施测试")
void infrastructureRequired() {
log.info("需要依赖基础设施");
}
}
复制代码
Case 2:不依赖基础设施
@Slf4j
@Import({ActivityService.class})
@ExtendWith(SpringExtension.class)
public class Case2Test {
@Resource
private ActivityService service;
@MockBean
private ActivityRepository repository;
@MockBean
private RedisTemplate redisTemplate;
@Test
@DisplayName("无需依赖基础设施测试")
void noInfrastructureRequired() {
log.info("无需基础设施也能运行");
Assertions.assertNotNull(service);
Assertions.assertNotNull(repository);
Assertions.assertNotNull(redisTemplate);
}
}
复制代码
可以看到并没有,也不需要启动 Spring 容器
Case 3:award() 单测
前文提到 award()
方法会查询数据库以及 Redis,因此需要对这一部分操作进行 Mock 和 Stub.
可以利用 Mockito 对下述代码中 # ----- stub {num} -----
后所跟随语句进行 stub(完整方法见文末)
因为存在分支控制语句,所以只演示了一条基本路径进行单元测试,而且刚好覆盖大部分 stub 1,2,3,5,6,7
// com.jingwu.example.service.ActivityService#award
public void award(AwardDTO dto) {
String id = dto.getActivityId(), stageId = dto.getStageId(), userId = dto.getUserId();
# ----- stub 1 -----
final ActivityDO activity = repository.selectById(id);
if (Objects.isNull(activity)) throw new RuntimeException();
String hashKey = String.format(FISSION_POSTER_AWARD, dto.getPosterId());
String key = String.valueOf(stageId);
# ----- stub 2 -----
Object result = redisTemplate.opsForHash().get(hashKey, key);
if (Objects.isNull(result)) {
# ----- stub 3 -----
Boolean exist = repository.exist(id, stageId, userId);
if (exist) {
# ----- stub 4 -----
redisTemplate.opsForHash().put(hashKey, key, true);
redisTemplate.expire(hashKey, 2L, TimeUnit.HOURS);
return;
}
} else if ((Boolean) result) {
return;
}
# ----- stub 5 -----
ActivityStageDO stage = repository.selectStage(stageId, id);
if (Objects.isNull(stage)) throw new RuntimeException();
ActivityStageAwardDO entity = new ActivityStageAwardDO()
.setActivityId(id).setStageId(stageId)
.setUserId(userId).setStageNum(stage.getStageNum());
# ----- stub 6 -----
repository.saveAward(entity);
# ----- stub 7 -----
redisTemplate.opsForHash().put(hashKey, key, true);
redisTemplate.expire(hashKey, 2L, TimeUnit.HOURS);
}
复制代码
Stub 0
- 选择
award()
方法,Ctrl+Shift+T
,选择需要进行单元测试的方法,回车创建ActivityServiceTest
-
由于针对
ActivityService
进行单元测试,因此通过注解@Import({ActivityService.class})
注入 Bean (参考 Spring Boot 单元测试实践 @Import 章节) -
ActivityService
中依赖ActivityRepository
和RedisTemplate
,而在此单元测试中关注的是award()
方法的业务逻辑,并不关注两者的 Bean 在 Spring 容器中是否真的存在或者能注入,因此可以通过spring boot test
提供的 @MockBean 注解来 Mock 注入 依赖的 Bean(有多少 Bean 依赖就需要 Mock 多少 Bean,否则会IllegalStateException: Failed to load ApplicationContext
)
@Import({ActivityService.class})
@ExtendWith(SpringExtension.class)
public class ActivityServiceTest {
@Resource
private ActivityService service;
@MockBean
private ActivityRepository repository;
@MockBean
private RedisTemplate redisTemplate;
}
复制代码
Stub 1
award()
方法中会执行 repository.selectById(id)
语句,而 repository
会去操作数据库, 因此需要通过 mock/stub 来替换实际的 JDBC 操作.
可以利用 doReturn().when()
或者 when().thenReturn()
进行 stub.
doReturn
和thenReturn
在针对Mock
对象是一样的效果,仅语法存在差异,只有在使用Spy
对象时会有所不同(参见 Case 4)
repository.selectById(id)
=> ActivityDO activity = mockActivity();
doReturn(activity).when(repository).selectById(ACTIVITY_ID);
// 或者
=> when(repository.selectById(ACTIVITY_ID)).thenReturn(activity);
复制代码
Stub 2
redisTemplate.opsForHash().get(hashKey, key)
复制代码
由于 redisTemplate.opsForHash().get(hashKey, key)
是链式操作,需要分步 stub,而 opsForHash
会返回一个包访问权限的对象,即 DefaultHashOperations
,此类在自己的包目录下是无法访问的,那么如何去 Mock 此对象呢?
自建一个相同路径的包,然后自定义 public 类去继承 DefaultHashOperations
(你学废没有?)
MyDefaultHashOperations mockOpt = mock(MyDefaultHashOperations.class);
doReturn(mockOpt).when(redisTemplate).opsForHash();
doReturn(null).when(mockOpt).get(any(), any());
复制代码
当 award()
方法中执行至 redisTemplate.opsForHash()
时,返回 mockOpt
,然后 mockOpt
再调用 get()
方法时 返回 null(为了走进 if 分支)
any() 用法见 Mockito 操作
此外还有另一种方式,封装辅助类来完成对 Redis 操作(将 ActivityService
与 RedisTeamplate
解耦),此时只需要 mock 一次辅助类即可.
@MockBean
private RedisHelper helper;
method(){
...
helper.hget(key, field);
...
}
@Test
method(){
...
doReturn(object).when(helper).hget(any(), any());
...
}
复制代码
当对业务逻辑的 Mock 和 Stub 很难去进行下去时 ,有可能是代码结构存在一些问题,此时需要及时调整,进行小范围重构.
Stub 7
redisTemplate.opsForHash().put(hashKey, key, true);
redisTemplate.expire(hashKey, 2L, TimeUnit.HOURS);
=> doNothing().when(spyOpt).put(any(), any(), any());
doReturn(true).when(redisTemplate).expire(anyString(), anyLong(), eq(TimeUnit.HOURS));
复制代码
void
方法使用 doNothing
来进行 Stub;mock 方法的传参见 任意参数
stub 5、6 参考 stub 1 即可,stub 4 不在此测试路径内,Stub 方式参考 stub 7
完整 Case
@Slf4j
@Import({ActivityService.class})
@ExtendWith(SpringExtension.class)
public class ActivityServiceTest {
@Resource
private ActivityService service;
@MockBean
private ActivityRepository repository;
@MockBean
private RedisTemplate redisTemplate;
private final Fairy fairy = Fairy.create(Locale.CHINA);
private static final String ACTIVITY_ID = "1";
private static final String POSTER_ID = "10";
private static final String STAGE_ID = "100";
@SuppressWarnings("unchecked")
@Test
@DisplayName("活动阶段领奖测试")
void award() {
AwardDTO dto = new AwardDTO();
dto.setActivityId(ACTIVITY_ID);
dto.setStageId(STAGE_ID);
dto.setPosterId(POSTER_ID);
ActivityDO activity = mockActivity();
MyDefaultHashOperations mockOpt = mock(MyDefaultHashOperations.class);
doReturn(mockOpt).when(redisTemplate).opsForHash();
doReturn(null).when(mockOpt).get(any(), any());
doReturn(activity).when(repository).selectById(ACTIVITY_ID);
doReturn(mockStage()).when(repository).selectStage(any(), any());
doReturn(false).when(repository).exist(any(), any(), any());
when(repository.saveAward(any())).thenReturn(true);
doNothing().when(spyOpt).put(any(), any(), any());
doReturn(true).when(redisTemplate).expire(anyString(), anyLong(), eq(TimeUnit.HOURS));
service.award(dto);
verify(repository, times(1)).saveAward(any());
verify(redisTemplate, times(2)).opsForHash();
verify(redisTemplate, times(1)).expire(anyString(), anyLong(), eq(TimeUnit.HOURS));
verify(spyOpt, times(1)).put(any(), any(), any());
}
}
复制代码
Case 4:doReturn 与 thenReturn
在操作 mock 对象时,doReturn
与 thenReturn
是一样的,操作 spy 对象时会不一样,thenReturn
在操作 spy 对象会调用真实方法,再返回 mock 数据,而 doReturn
则直接返回,并不会调用实际方法.
public class ActivityRepository {
public Boolean saveAward(ActivityStageAwardDO entity) {
final boolean result = RandomUtil.randomBoolean();
log.info("保存结果:{}", result);
function();
return result;
}
private void function() {
log.info("抛了异常");
throw new NullPointerException();
}
}
public class Case4Test {
@BeforeEach
void setUp() {
log.info("---- UT Start ----");
}
@AfterEach
void tearDown() {
log.info("---- UT End ----\n");
}
@Test
void doReturnTest() {
Assertions.assertDoesNotThrow(() -> {
final ActivityRepository spy = spy(ActivityRepository.class);
doReturn(true).when(spy).saveAward(any());
spy.saveAward(mockStageAward());
});
}
@Test
void thenReturnTest() {
Assertions.assertThrows(NullPointerException.class, () -> {
final ActivityRepository spy = spy(ActivityRepository.class);
when(spy.saveAward(any())).thenReturn(true);
spy.saveAward(mockStageAward());
});
}
}
复制代码
Mockito 操作
连续执行
# stub
// 第一次执行 返回 true, 第二次执行 返回 false. 1 和 2 等价
1. doReturn(true).doReturn(false).when(repository).exist(ACTIVITY_ID);
2. when(repository.exist(ACTIVITY_ID)).thenReturn(true).thenReturn(false);
method(id) {
bool r1 = repository.exist(id); // r1 = true
// do something()
bool r2 = repository.exist(id); // r2 = flase
}
复制代码
Stub 传参
参数匹配器
见 org.mockito.ArgumentMatchers
固定参数
doReturn(activity).when(repository).selectById(ACTIVITY_ID);
复制代码
以上语句表示当 award()
方法执行 repository.selectById()
语句且参数为 ACTIVITY_ID 则返回 mock 的 activity 对象. 如果传入的参数不等于 ACTIVITY_ID
时,则不会进行 stub.
任意参数
doReturn(activity).when(repository).selectById(any());
复制代码
以上语句表示当 award()
方法执行 repository.selectById()
语句参数为 任意值 时则返回 activity 对象.
可以使用具体参数类型的参数匹配器,如
selectById(Long id) => anyLong()
复制代码
多种参数
redisTemplate.expire(hashKey, 2L, TimeUnit.HOURS);
=> doReturn(true).when(redisTemplate).expire(anyString(), anyLong(), eq(TimeUnit.HOURS));
// 或者
doReturn(true).when(redisTemplate).expire(anyString(), eq(2L), eq(TimeUnit.HOURS));
// 或者
Long time = 2L;
doReturn(true).when(redisTemplate).expire(anyString(), eq(time), eq(TimeUnit.HOURS));
doReturn(true).when(redisTemplate).expire(anyString(), eq(2L), eq(TimeUnit.HOURS));
doReturn(true).when(redisTemplate).expire(anyString(), anyLong(), any());
doReturn(true).when(redisTemplate).expire(any(), anyLong(), any());
...
复制代码
当使用参数匹配器时,必须所有的参数都要用匹配器的方式,而不允许一部分参数是固定值,一部分参数使用匹配器,使用常量/固定值需要用 eq()
去包装.
参数匹配器有很多的组合方式,比较灵活,有兴趣的可以自己去尝试尝试.
Mockito 更多使用方式,请自行搜索吧~
总结
单元测试应当只关注当前方法的业务逻辑,其它的外部依赖都应通过 Mock 的方式完成.
@SpringBootTest
应当用于集成测试,非特别必要的单元测试不推荐使用,每次调试都需要启动 Spring 容器,个人觉得效率太低(当然最高效的还是不写啦 (⊙︿⊙)..)
最后,本文仅展示了一个 case 来示范如何 mock 的,但是 mock 思路基本上差不多,有机会会再输出一些相关的测试用例来进行示范.
其它
单元测试覆盖率
IDEA 支持覆盖率查看,测试目录或者测试类右键 Run 'xxTest' with Coverage
通过此操作能够针对不同测试路径来编写不同的测试用例
红色为未覆盖的,绿色为已覆盖
除此之外,在 CI/CD 利用 Jacoco 中设置质量门禁,单元测试覆盖率低于多少的流水线会执行失败,不允许提测、发布、上线(照这样,仅定个 10 % 可能大部分项目都无法发布上线了).
Fairy(Mock 数据)
// java.util.Locale 指定区域,默认 ENGLISH
private final Fairy fairy = Fairy.create(Locale.CHINA);
@Test
void fairy() {
Person person = fairy.person();
Company company = fairy.company();
CreditCard creditCard = fairy.creditCard();
TextProducer textProducer = fairy.textProducer();
BaseProducer baseProducer = fairy.baseProducer();
DateProducer dateProducer = fairy.dateProducer();
NetworkProducer networkProducer = fairy.networkProducer();
}
复制代码
jFairy by Codearte
完整依赖
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-jpaartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-amqpartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
<scope>providedscope>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-testartifactId>
<scope>testscope>
dependency>
<dependency>
<groupId>io.codearte.jfairygroupId>
<artifactId>jfairyartifactId>
<version>0.5.9version>
<scope>testscope>
dependency>
dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-dependenciesartifactId>
<version>2.3.12.RELEASEversion>
<type>pomtype>
<scope>importscope>
dependency>
dependencies>
dependencyManagement>
复制代码
业务逻辑
/**
* @author 菁芜
* @since 2021/7/22 - 20:17
*/
@Service
public class ActivityService {
private static final String FISSION_POSTER_AWARD = "activity:poster:award:%s";
private final ActivityRepository repository;
private final RedisTemplate redisTemplate;
public ActivityService(ActivityRepository repository, RedisTemplate redisTemplate) {
this.repository = repository;
this.redisTemplate = redisTemplate;
}
public void award(AwardDTO dto) {
String id = dto.getActivityId();
String stageId = dto.getStageId();
String userId = dto.getUserId();
final ActivityDO activity = repository.selectById(id);
if (Objects.isNull(activity)) {
throw new RuntimeException();
}
String hashKey = String.format(FISSION_POSTER_AWARD, dto.getPosterId());
String key = String.valueOf(stageId);
Object result = redisTemplate.opsForHash().get(hashKey, key);
if (Objects.isNull(result)) {
Boolean exist = repository.exist(id, stageId, userId);
if (exist) {
redisTemplate.opsForHash().put(hashKey, key, true);
redisTemplate.expire(hashKey, 2L, TimeUnit.HOURS);
return;
}
} else if ((Boolean) result) {
return;
}
ActivityStageDO stage = repository.selectStage(stageId, id);
if (Objects.isNull(stage)) {
throw new RuntimeException();
}
ActivityStageAwardDO entity = new ActivityStageAwardDO()
.setActivityId(id).setStageId(stageId)
.setUserId(userId).setStageNum(stage.getStageNum());
repository.saveAward(entity);
redisTemplate.opsForHash().put(hashKey, key, true);
redisTemplate.expire(hashKey, 2L, TimeUnit.HOURS);
}
}
复制代码
项目地址
Spring Boot UT 之 Junit5
附部分参考文章,更多内容请自行搜索 -.-
- Junit 5 官方文档中文版
- JUnit 5 常见用法介绍
- Mock 模拟测试简介及 Mockito 使用入门
参考
- mock测试 ↩
- 桩(计算机) - 维基百科,自由的百科全书 ↩