Spring Boot 单元测试实践(二)

4,242 阅读9分钟

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

前言

在前文《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)

image.png 图片截图自 JUnit 5 和 JUnit 4 比较

而就 Spring 而言,最大差别就是想要使用 Spring 容器就得使用以下方式:

@RunWith(SpringRunner.class)  => @ExtendWith(SpringExtension.class)

image.png

@RunWith(SpringRunner.class) 能用但是无法再注入 Bean 了,包括 MockBean,下图 debug 可以看到: image.png

image.png

准备环境

说明

业务场景为某个活动海报的一个阶段领奖操作,根据现有业务逻辑简化改造而来

  • 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("需要依赖基础设施");
    }

}

image.png

image.png

Case 2:不依赖基础设施

@Slf4j
@Import({ActivityService.class})
@ExtendWith(SpringExtension.class)
public class Case2Test {
    @Resource
    private ActivityService service;
    @MockBean
    private ActivityRepository repository;
    @MockBean
    private RedisTemplate<String, Object> redisTemplate;

    @Test
    @DisplayName("无需依赖基础设施测试")
    void noInfrastructureRequired() {
        log.info("无需基础设施也能运行");

        Assertions.assertNotNull(service);
        Assertions.assertNotNull(repository);
        Assertions.assertNotNull(redisTemplate);
    }
}

image.png

可以看到并没有,也不需要启动 Spring 容器

case2.gif

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

key.gif

  • 由于针对 ActivityService 进行单元测试,因此通过注解@Import({ActivityService.class}) 注入 Bean (参考 Spring Boot 单元测试实践 @Import 章节)

  • ActivityService 中依赖 ActivityRepositoryRedisTemplate ,而在此单元测试中关注的是 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<String, Object> redisTemplate;
    
}

image.png

image.png

Stub 1

award() 方法中会执行 repository.selectById(id) 语句,而 repository 会去操作数据库, 因此需要通过 mock/stub 来替换实际的 JDBC 操作.

可以利用 doReturn().when() 或者 when().thenReturn() 进行 stub.

doReturnthenReturn 在针对 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 此对象呢?

image.png

自建一个相同路径的包,然后自定义 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 分支)

image.png

opsForHash.gif

any() 用法见 Mockito 操作

此外还有另一种方式,封装辅助类来完成对 Redis 操作(将 ActivityServiceRedisTeamplate 解耦),此时只需要 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<String, Object> 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());
    }

}

case3.gif

Case 4:doReturn 与 thenReturn

在操作 mock 对象时,doReturnthenReturn 是一样的,操作 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());
        });
    }
}

image.png

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

image.png

固定参数

  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()

image.png

多种参数

    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() 去包装.

image.png

image.png

参数匹配器有很多的组合方式,比较灵活,有兴趣的可以自己去尝试尝试.

Mockito 更多使用方式,请自行搜索吧~

总结

单元测试应当只关注当前方法的业务逻辑,其它的外部依赖都应通过 Mock 的方式完成.

@SpringBootTest 应当用于集成测试,非特别必要的单元测试不推荐使用,每次调试都需要启动 Spring 容器,个人觉得效率太低(当然最高效的还是不写啦 (⊙︿⊙)..)

最后,本文仅展示了一个 case 来示范如何 mock 的,但是 mock 思路基本上差不多,有机会会再输出一些相关的测试用例来进行示范.

其它

单元测试覆盖率

IDEA 支持覆盖率查看,测试目录或者测试类右键 Run 'xxTest' with Coverage

通过此操作能够针对不同测试路径来编写不同的测试用例

image.png image.png image.png

红色为未覆盖的,绿色为已覆盖

除此之外,在 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.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <scope>provided</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>io.codearte.jfairy</groupId>
        <artifactId>jfairy</artifactId>
        <version>0.5.9</version>
        <scope>test</scope>
    </dependency>
</dependencies>

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-dependencies</artifactId>
            <version>2.3.12.RELEASE</version>
            <type>pom</type>
            <scope>import</scope>
        </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<String, Object> redisTemplate;

    public ActivityService(ActivityRepository repository, RedisTemplate<String, Object> 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

附部分参考文章,更多内容请自行搜索 -.-

参考

Footnotes

  1. mock测试

  2. 桩(计算机) - 维基百科,自由的百科全书