单元测试的最佳实践

1、背景

本文1-2章是单测的介绍,如果需要直接看整合教程,直接跳至第3章看干货;

在复杂系统的开发中,我们经常需要做单元测试。但又由于单元测试没有形成标准,而经常遇到这样的问题:

  • 直接写一个main函数对需要的方法进行测试
  • 单元测试过于复杂,有时候又懒得做单元测试
  • 开发时需要将项目启动起来对功能进行验证,但同时又没有一个独立的环境;
  • 对功能验证时,Debug10秒钟、启动项目5分钟,修改后又要重新启动项目;
  • 测试时外部依赖不可用,影响正常开发;

而不做单元测试,将又会带来:

  • 业务与逻辑愈发复杂,不敢随便乱改动代码,久而久之就在屎山上面堆屎;
  • 一段时间不看逻辑,就会忘记这段代码应该怎么跑
  • 改动别人的代码后,不知道是否会引起其他连锁反应
  • 需求复杂,需要边测试验证边开发

针对以上问题,本文将介绍基于集成Mockito + PowerMock + H2 + EmbededRedis 的单元测试实践方案,整套单元测试环境将完全脱离Spring框架进行,使得功能验证更加纯粹简单。

2、单元测试介绍

Java的单元测试就是测试各个独立方法的功能是否符合预期以及边界值限定的情况。单元测试不只是为了验证你当前写的代码是否存在问题,更为重要的是他可以很大程度保障日后因业务变更,开发成员变更,修复BUG或者重构引起的代码变更而导致(或新增)的风险

2.1、单元测试的AIR原则

A:Automatic(自动化)

即单元测试具备可自动化运行的能力,测试用例通常是被定期执行的,执行过程必须完全自动化才有意义。如: 集成CI能力,在每一次commit过后,在pipeline中进行各项单元测试集合的自动化运行(不准使用 System.out 来进行人肉验证,必须使用 assert 来验证),并给出自动化单元测试运行的结果(pass/fail);

I:Independent(独立性)

为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能依赖执行的先后次序;单元测试也不依赖任何外部的系统或组件(如外部系统、外部Mysql、外部密钥等),而是自身具备独立可测试的能力,方便于任何第三方拿到单测之后都可以直接运行并验证功能;

R:Repeatable(可重复)

单元测试应该是可重复的,每次运行的运行都应该不应该受到外部系统或者外部数据影响。一旦数据和可用性问题,结果就会像幽灵一样时好时坏;也不能因为这次创建了数据,下次就因为主键约束不能再创建。

2.2、使用到的工具与开源组件

  • 单元测试框架:Junit4/5
  • 单元测试工具:Mockito + PowerMock
  • 内存数据库:H2 / EmbededMysql / EmbededRedis
  • 内存中间件:TestContainer

单元测试的最佳实践_第1张图片

2.3、单元测试能干啥

  • 有更快的 【开发-验证】循环,无需等待依赖实现,外部实现,无需特定环境,帮助开发应对业务的快速变化;
  • 节省调试时间(越复杂的功能,潜在收益越大,简单CRUD可能看不出什么);
  • 便于多人协作与项目交接;
  • 极大增强了变革的信心,增加重构自信;
  • 单元测试与程序设计互相赋能;
  • 提升项目效率,快速、高质量发布;

2.4、单元测试常见的问题

  • 自身依赖:单测需要依赖自身服务和自身环境,必须要完整启动项目才能做测试;
  • 幽灵测试:一旦有数据和可用性问题,结果就像幽灵一样时好时坏;
  • 外部依赖:有些单测写成了集成测试,依赖外部服务,或者单个测试庞大而臃肿
  • 效率低下:本地启动不起来,提交到远程又时好时坏,跑一次还10分钟
  • 场景不全:多数只是关注了正常场景,边界值和异常指标没有覆盖到
  • 指标不足:测试粒度太大,场景不全,没有关注到覆盖率,异常率和逻辑覆盖率

3、单元测试的实践介绍

单元测试实际上建议单独一个项目模块(实在不行则在项目顶级依赖如start项目下做),否则每个模块都要重新做一次单独单测环境;

简单来说,本章节将会脱离Spring环境,转而使用完全的Mockito环境进行单元测试。

即:使用内存数据库H2作为数据源,使用Mockito对目标对象进行打桩与验证、使用PowerMock对静态方法、私有方法进行模拟与打桩。

有什么好处吗? 完全脱离Spring环境之后,使得单元测试更加纯粹,项目启动速度特别快;

有什么坏处吗?Spring的特性就无法使用了,比如Autowire一个List和Autowire一个Map等;Mybatis等数据源的注入需要手工注入了;

3.1、引入开源组件

具体版本以最新为准

3.1.1、Mockito

用于脱离Sping环境进行单元测试,对数据进行打桩,对外部数据源、外部服务进行模拟

        
            org.mockito
            mockito-core
            3.9.0
            test
        

3.1.2、PoweMock

用于mock静态方法、私有方法等,是mockito的增强使用


        
            org.powermock
            powermock-module-junit4
            2.0.2
            test
        

        
            org.powermock
            powermock-api-mockito2
            2.0.2
            test
        

3.1.3、H2内存数据库

用于单侧时启动一个本地的内存数据库进行数据源准备与数据库数据的初始化

        
            com.h2database
            h2
            test
        

3.1.4、EmbededRedis (可选)

若单测时需要使用到redis的能力,可以使用embededRedis

        
            com.github.kstyrc
            embeded-redis
            0.6
        

实际上,实际上,可以使用testcontainer拉取一个镜像并运行一个临时实例来代替(但个人感觉这样有点慢)

3.2、单元测试的准备工作

完成了如上的依赖引入之后,我们便有了一套真正意义上的单元测试工具了。

本节将阐明如何脱离Spring进行单元测试前的准备:

  • 使用原生mybatis进行数据操作
  • 准备前期的打桩数据构造器
  • 编写单元测试基类

3.2.1、增加适用于H2数据库连接的mybatis配置

因为单元测试脱离了Spring,因此我们要引入新的mybatis.conf,因此我们在test目录下,添加一个适用于H2数据库连接的mabatis-test.conf







    
        
    

    
        
            
            
                
                
                
                
            
        
    

    
        
    

3.2.2、增加本地文件读取工具

先增加一个可以读取本地文件的工具ReadFileUtil,专门用于读取ddl.sql与预置sql数据。(这里偷懒,直接用了Spring的StreamUtils和Fastjson的序列化能力)

package com.teamer.teapot.util;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.TypeReference;
import org.springframework.core.io.ClassPathResource;
import org.springframework.util.StreamUtils;

import java.io.IOException;
import java.nio.charset.Charset;

/**
 * @author tanzj
 * @date 2022/9/17
 */
public class ReadFileUtil {

    /**
     * 用于从指定路径中读取资源,并转换为特定对象
     *
     * @param path          路径
     * @param typeReference 类型
     * @param            类型
     * @return 指定类型的对象
     */
    public static  T readJsonFromResource(String path, TypeReference typeReference) {

        try {
            String jsonString = StreamUtils.copyToString(
                new ClassPathResource(path).getInputStream(), Charset.defaultCharset()
            );
            return JSON.parseObject(jsonString, typeReference);
        } catch (IOException e) {
            e.printStackTrace();
            return null;
        }
    }

    public static String readStringFromResource(String fileName) throws IOException {
        return StreamUtils.copyToString(new ClassPathResource(fileName).getInputStream(), Charset.defaultCharset());
    }

}

3.2.3、搭建H2连接工具

编写一个H2DbSupportFactory工具,此工具用于单元测试时Mapper对象的导入

package com.teamer.teapot.util;

import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.testcontainers.shaded.org.apache.commons.lang.StringUtils;

import java.io.IOException;
import java.io.Reader;
import java.sql.SQLException;
import java.util.Arrays;


public class H2DbSupportFactory {

    public static SqlSessionFactory sqlSessionFactory;

    public static SqlSession sqlSession;

    public boolean dataInited = false;

    static {
        //测试用的mybatis-test文件
        try(Reader reader = Resources.getResourceAsReader("mybatis-test.xml")) {
            sqlSessionFactory = new SqlSessionFactoryBuilder().build(reader);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    public static H2DbSupportFactory getInstance(String[] ddlPath, String[] initDataPath) throws IOException, SQLException {

        //获取数据库建表语句脚本
        StringBuilder ddlSqlStrBuilder = new StringBuilder();
        for (String eachDdlSqlPath: ddlPath) {
            String currentSql = ReadFileUtil.readStringFromResource(eachDdlSqlPath);
            ddlSqlStrBuilder.append(currentSql);
            if (!currentSql.endsWith(";")) {
                ddlSqlStrBuilder.append(";");
            }
        }

        //获取初始化数据
        StringBuilder initDataPathStringBuilder = new StringBuilder();
        if (!Arrays.stream(initDataPath).allMatch(StringUtils::isEmpty)) {
            for (String eachInitDataPath : initDataPath) {
                String currentSql = ReadFileUtil.readStringFromResource(eachInitDataPath);
                initDataPathStringBuilder.append(currentSql);
                if (!currentSql.endsWith(";")) {
                    initDataPathStringBuilder.append(";");
                }
            }
        }

        String ddlSql = ddlSqlStrBuilder.toString();
        String initDataSql = initDataPathStringBuilder.toString();

        H2DbSupportFactory factory = new H2DbSupportFactory();
        factory.setSqlSession(getSqlSessionFactory().openSession());
        if (factory.getSqlSession() == null) {
            sqlSession = getSqlSessionFactory().openSession();
        }

        if (StringUtils.isNotBlank(ddlSql)) {
            //h2每次只能创建一张表
            String[] ddlSqlList = ddlSql.split(";;");
            for (String eachDdlSql : ddlSqlList) {
                sqlSession.getConnection().createStatement().execute(eachDdlSql);
            }
        }

        if (StringUtils.isNotBlank(initDataSql) || !factory.dataInited) {
            sqlSession.getConnection().createStatement().execute(initDataSql);
            factory.dataInited = true;
        }
        sqlSession.commit();;
        return factory;
    }


    public SqlSession getSqlSession() {
        return sqlSession;
    }

    public boolean isDataInited() {
        return dataInited;
    }

    public H2DbSupportFactory setDataInited(boolean dataInited) {
        this.dataInited = dataInited;
        return this;
    }

}

3.2.4、编写单元测试基类

编写单元测试基类,让所有基于此方案的单元测试直接继承这个基类,从而省去每次编写单测的前置工作。


@RunWith(PowerMockRunner.class)
public abstract class BaseMockitoTest {

    protected static H2DbSupportFactory h2DbSupportFactory;
    
    /**
     * 用于为target注入对象,目前用于Mapper层的手工注入
     * @param target 目标业务对象
     * @param field 字段
     * @param dependency 依赖的对象
     * @throws NoSuchFieldException e
     * @throws IllegalAccessException e
     */
    public void setter(Object target, String field, Object dependency) throws NoSuchFieldException, IllegalAccessException {
        Field targetField = target.getClass().getField(field);
        if (!targetField.isAccessible()) {
            targetField.setAccessible(true);
        }
        targetField.set(target, dependency);
    }

    /**
     * 子类集成时,需要在方法增加@Before注解,用于提前打桩
     * @see org.junit.Before
     * @throws Exception e
     */
    public abstract void before() throws Exception;

    /**
     * 子类集成时,需要在方法增加@BeforeClass注解,用于提前静态类初始化能力的集成
     * @see org.junit.BeforeClass
     */
    public static void init() {

    };


}

3.2.5、增加EmbededRedis的启动器

若是单测需要依赖redis的能力,则需要提前配置好redis的基础配置。案例使用jedis作为redis-client,若使用其他工具,配置也大同小异。


/**
 * @author tanzj
 * @date 2022/9/19
 */
public class EmbeddedRedisHolder {

    private static RedisServer redisServer;
    
    private static Jedis jedis;
    
    private static JedisPool jedisPool;

    /**
     * 启动器,需要在@BeforeClass中调用初始化
     */
    public static void startUp() {
        redisServer = RedisServer.builder()
            .port(63792)
            .setting("maxmemory 64m")
            .build();
        redisServer.start();

        JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
        jedisPoolConfig.setMaxTotal(200);
        jedisPoolConfig.setMaxWaitMillis(2000);
        jedisPoolConfig.setMaxIdle(8);
        jedisPoolConfig.setMinIdle(0);
        jedisPool = new JedisPool(jedisPoolConfig, "127.0.0.1", 63792, 10000);
    }


    /**
     * 进程销毁,需要在@AfterClass中调用销毁
     */
    public static void end() {
        redisServer.stop();
    }
    
    public static Jedis getJedis() {
        if (jedis != null) {
            return jedis;
        }
        if (jedisPool == null) {
            initJedisPool();
        }
        jedis = jedisPool.getResource();
        return jedis;
    }

    private static JedisPool initJedisPool() {
        JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
        jedisPoolConfig.setMaxTotal(200);
        jedisPoolConfig.setMaxWaitMillis(2000);
        jedisPoolConfig.setMaxIdle(8);
        jedisPoolConfig.setMinIdle(0);
        jedisPool = new JedisPool(jedisPoolConfig, "127.0.0.1", 63792, 10000);
        return jedisPool;
    }


    public static JedisPool getJedisPool() {
        return jedisPool;
    }
}

3.3、编写业务单元测试

开始编写业务单元测试。我们先模拟一个新增订单的一个业务逻辑。

我们需要对Controller层是简单的路由转发,我们在开发中只需要对OrderServicecreateOrder方法进行业务的单元测试,具体的代码层级结构如下:

  • OrdereController:业务入口
  • OrderService:是业务逻辑层,主要提供了创建单据与查询单据详情的方法,Service层聚合了OrderInfoManager(单据通用逻辑层)与OrderItemDetailManager(单据明细通用逻辑层)
  • OrderInfoManager/OrderItemDetailManager:通用逻辑层,包装了DAO与单据特性的通用能力
  • DAO层:数据访问层
  • 各模块的注入使用了@Autowire注入

单元测试的最佳实践_第2张图片

3.3.1、新增业务单元测试

根据单元测试规范,我们需要在test目录下新建一个与被测类相同路径的单元测试方法,并且以Test结尾,如我们需要测试的方法是:com.teamer.teapot.order.service.impl.OrderServiceImpl,那么此时我们就需要在test目录下新建com.teamer.teapot.order.service.OrderServiceImplTest的单元测试方法:


/**
 * @author tanzj
 * @date 2022/9/21
 */
public class OrderServiceTest extends BaseMockitoTest {
    
    
    /**
     * 子类集成时,需要在方法增加@Before注解,用于提前打桩
     *
     * @throws Exception e
     * @see Before
     */
    @Before
    @Override
    public void before() throws Exception {
        
    }
}

3.3.2、使用@InjectMock注入依赖项

如3.3的大图所示,我们知道OrderService是依赖了OrderInfoManager 与 OrderItemDetailManager 的两个bean,因此在单元测试中,我们需要使用InjectMocks将这两个依赖也定义出来。

@InjectMocks可以将所有使用@InjectMocks, @Spy,@Mock 修饰的字段,全部注入到当前修饰的字段中,有点类似Spring的注入。


public class OrderServiceTest extends BaseMockitoTest {

    /**
     * 
使用InjectMocks定义一个对象时,这个对象会自动注入所有使用@InjectMocks与@Spy,@Mock 注解的字段 *
这里使用spy构造是因为我们确实要走他的创建方法 */ @InjectMocks private OrderService orderService = Mockito.spy(new OrderServiceImpl()); /** * orderService 依赖了orderInfoManager,我们这里同样要注入MockOrderInfoManager */ @InjectMocks private OrderInfoManager orderInfoManager = Mockito.spy(new OrderInfoManagerImpl()); /** * 同理 */ @InjectMocks private OrderItemDetailManager orderItemDetailManager = Mockito.spy(new OrderItemDetailManagerImpl()); /** * 子类集成时,需要在方法增加@Before注解,用于提前打桩 * * @throws Exception e * @see Before */ @Before @Override public void before() throws Exception { } }

此时如果我们任意启动一个单元测试,通过debug,便会发现所有的对象都被注入成功了

单元测试的最佳实践_第3张图片

3.3.3、对value对象进行打桩 

Mockito不推荐我们对value对象进行注入模拟,因此针对于DAO对象,我们直接使用打桩返回的形式进行测试,在单元测试类中注入两个DAO

单元测试的最佳实践_第4张图片

在before方法加上@Before注解,并对这两个Bean进行提前打桩。

单元测试的最佳实践_第5张图片

对mock的对象,打桩格式是:

when(mockBean.doSomething(any())).thenReturn(requireObject);

对spy对象,打桩格式是

doReturn(requireObject).when(spyBean).doSomething(any())

注意:这里的Mock更可以用在第三方系统或者接口、服务尚未准备好的情况下,我们去提前在单元测试中模拟返回(不仅如此,根据AIR原则,我们也必须对第三方接口和服务进行Mock)

3.3.4、使用到了Mysql

基于3.3.3的前提下,我们可以完成大部分的单元测试。但在很多时候,只有真正走到了SQL层面,才能验证你的sql是否准确,字段约束是否齐全,这个时候就需要引入H2内存数据库进行单元测试了。

因此我们在准备好3.2.3的DB连接与初始化工具之后,便可以准备db的初始化了。

我们为单元测试增加init()方法,并为其加上@BeforeClass注解,提前准备好对应表的ddl语句和事先准备好的的数据初始化sql脚本

单元测试的最佳实践_第6张图片

在mybatis-test.conf中注册这两个DAO

单元测试的最佳实践_第7张图片

然后在before()方法中,对DAO进行初始化(注意,如果使用这种方法,需要取消3.3.3中,对dao的@Mock注解)

单元测试的最佳实践_第8张图片

 这样便完成了DAO对象的注入。

此时,我们需要调用基类的setter()方法,利用反射对业务对象中的DAO进行赋值(这里笔者没有找到更好的办法对dao进行注入,如果有更好的办法请一定不吝赐教)

单元测试的最佳实践_第9张图片

完成此步骤之后,单元测试中便完成了数据源与DAO的注入配置。值得注意的是,H2的语法与Mysql有些许不同,比如H2便不支持json操作。若设计到json的字段,请在ddl脚本将其设置为text字段。

3.3.5、使用到了Redis

若被测方法使用到了redis的特性,我们可以使用3.2.5中提前准备好的embededRedis启动器进行redis测试。

我们依然在beforeClass中先启动redis

单元测试的最佳实践_第10张图片

 新建一个方法:destroy,并为此方法新增注解@AfterClass,在此方法中调用redisEnd

 
  

此时便完成了embededRedis的启动了。而后在业务单元测试方法中,对jedisPool进行注入

这样在你使用到redis的位置,用此jedisPool来拉取jedisClient时便可以拿到我们预先配置的bean了

 3.4、编写单元测试

而后,我们针对OrderService每一个业务方法,都进行单元测试,并基于单元测试的标准对其边界值进行测试。并对其结果进行断言(不对结果进行断言的单元测试都是耍流氓)

如:

单元测试的最佳实践_第11张图片

 实际上,参考Mockito官方的写法,我们建议单元测试的方法命名为:

test + ${method} + "_success"

test + ${method} + "${用下划线隔开的场景}"

因为这样,在跑单元测试的边界值测试时,才更能一幕了然的看到所有被测试的场景

你可能感兴趣的:(Java,单元测试,junit,java)