IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 开发测试 -> Spring Boot 单元测试实践(二) -> 正文阅读

[开发测试]Spring Boot 单元测试实践(二)

6870ecfb226c4562925eea805958d8b8~tplv-k3u1fbpfcp-watermark.image

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)

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?中依赖?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<String, Object> redisTemplate;
    
}
复制代码

image.png

image.png

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

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 操作(将?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<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 对象时,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());
        });
    }
}
复制代码

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

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

参考

  开发测试 最新文章
pytest系列——allure之生成测试报告(Wind
某大厂软件测试岗一面笔试题+二面问答题面试
iperf 学习笔记
关于Python中使用selenium八大定位方法
【软件测试】为什么提升不了?8年测试总结再
软件测试复习
PHP笔记-Smarty模板引擎的使用
C++Test使用入门
【Java】单元测试
Net core 3.x 获取客户端地址
上一篇文章      下一篇文章      查看所有文章
加:2021-08-02 11:05:29  更:2021-08-02 11:06:02 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/17 20:41:15-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码