秒级启动的集成测试框架

本文介绍了一种秒级启动的集成测试框架,使用该框架可以方便的修改和完善测试用例,使得测试用例成为测试过程的产物。

背景

传统的单元测试,测试的范围往往非常有限,常常覆盖的是一些工具类、静态方法或者较为底层纯粹的类实现,但是一般整个应用代码是比较复杂的,存在不同分层。在DDD中,一般包括:防腐层、领域层、服务层、应用层。越到上层的类其依赖关系越复杂,这些上层的类对象往往不太适合用单测来覆盖。

但是集成测试的启动速度较慢,随着工程的增大,启动速度会越来越慢。这就导致修改和 Debug 测试用例变得非常耗时,一部分人甚至会放弃写测试,而通过系统界面或手动接口测试(Postman等)方式来保证功能正确性。但这样做之后,在未来重构或者开发新需求时很难完整回归已有功能。完整回归已有功能将是每次发布的负担,回归遗漏可能引发线上故障。

测试的执行速度至关重要,这往往会影响人们是否自觉地完成测试覆盖。在实际开发过程中,我们可能需要本地反复执行某些测试用例,并不断修改,如果应用能在10秒内启动完成,那么开发是高效的,否则可能会让人试图通过其他方式来测试功能的正确性。

解决方案

针对这一问题,一个比较直观的想法是让集成测试执行速度和单元测试一个数量级。

一般的Java工程都使用了Spring框架,其应用启动慢,往往是一些涉及网络通信的Bean的初始化过程比较耗时,比如RPC框架、缓存、数据库等,这些中间件的Bean对象初始化都需要和外部建立网络连接,等待数据推送等,有的涉及多次网络通信,将这些Bean完全Mock掉,可大大加快应用启动速度。

很直观的解法是将这些耗时的Bean替换为MockBean,有两种方式:

  1. 使用Spring的@Primary注解,并禁止耗时Bean的初始化

  2. Mock Spring容器

第一种方式的困难在于Bean初始化的方式多种多样,有的在init方法中,有的通过BeanPostProcessor动态创建,要精准的禁止这类逻辑的执行是比较困难的。

第二种方式则是自己实现一个Mock的Spring框架,基于约定的方式实现Mock对象的自动加载,以及普通Bean对象和Spring一样的方式初始化,从而实现应用的快速启动。

Mock Spring容器

我们基于第二种思路实现了Mock的Spring容器,但仅仅实现其了基础功能,因为通常我们的工程没有用到Spring比较复杂的能力(大多数工程都是如此)。工程中采用约定大于配置的方式,可以减少Mock的工作量。在Mock Spring框架时其实最需要的是自动构建依赖树的能力,即根据当前Bean对象的依赖关系,按需动态创建一系列其关联的Bean。而且对于外部依赖,可以基于某种约定来优先加载Mock对象,保证所有对象Bean创建是按需的,且不需要网络等待,这样可以实现对象依赖树秒级创建,集测秒级启动。其他特殊的功能可以通过其他方式来绕过,本方案也在不断完善中。从实践来看,启动大约需要1-10秒。

本方案的基本思路如下:

  1. 记录接口与实现类的关系,是为了根据接口查找实现类,实现按需加载

  2. Mock对象相当于加了@Primary注解,在同类型中会优先被注入,保证覆盖中间件等外部依赖Bean对象

  3. 初始化基础Bean对象,是优先加载@Configuration修饰的类中定义的Bean对象

以下是工程中定义的扫包代码片段,每个测试执行Bean都是按需加载,不会将所有Bean全部创建。

javascript 复制代码
// 确定扫包路径,扫包规则,只有@Component等注解修饰的类才会被注册为Bean
Predicate<Class<?>> classFilter = clazz -> !clazz.getSimpleName().endsWith("Test");
Set<Class<?>> beanClasses = ClassScanUtil.scanPackages(
    classFilter, 
    // 应用包路径
    "com.nbf.gateway",
);

以上的应用包路径和Spring Boot应用的扫包路径一致。

以下是Bean初始化简化后的逻辑:

typescript 复制代码
protected <T> T getBeanObject(Class<T> requiredType) {
    // 首先查找Bean的真实类型
    Set<Class<?>> beanClassList = implClassMap.get(requiredType);
    int size = CollectionUtils.size(beanClassList);
    Class<?> beanClass;
    if (size > 1) {
        throw new BusinessException(CommonErrorCode.UNKNOWN_EXCEPTION, requiredType.getName() + "包含多个实现类");
    } else if (size == 1){
        beanClass = beanClassList.iterator().next();
    } else {
        beanClass = requiredType;
    }


    T bean;
    Constructor<?> constructor = ListUtils.firstElementOf(beanClass.getConstructors());
    if (null == constructor) {
        throw new BusinessException(new Exception("class: " + beanClass.getName() + " 构造器为null."));
    }
    Class<?>[] classes = constructor.getParameterTypes();
    Object[] params = new Object[classes.length];
    for (int i = 0; i < classes.length; ++i) {
        params[i] = this.getBean(classes[i]);
    }


    try {
        //noinspection unchecked
        bean = (T)constructor.newInstance(params);
    } catch (Exception e) {
        throw new BusinessException(e);
    }


    // 处理@Autowired@和Resource
    this.processMemberBean(bean);


    // 执行初始化逻辑
    Method[] methods = beanClass.getDeclaredMethods();
    for (Method method : methods) {
        if (method.getAnnotation(PostConstruct.class) != null) {
            try {
                method.invoke(bean);
            } catch (Exception e) {
                throw new BusinessException(e);
            }
        }
    }


    return bean;
}

以下是在测试类中获取Bean对象的方法,类似@Autowired。MockApplicationContext即是我们Mock Spring容器类的名字。

swift 复制代码
public class GroupVersionRepositoryUnitTest {


    private final static GroupTunnel groupTunnel = MockApplicationContext.getBeanOfType(GroupTunnel.class);
}

Mock数据库

基于以上的思路,我们还需要Mock数据库、外部依赖、中间件。下面小节将重点介绍Mock数据库的一种实现。

第一层Mock:Example

Mock数据库,最直观的想法就是使用HashMap,也在很多的工程中有用到。看到很多的实现是,在测试中,我们调用DAO层相关代码替换为在HashMap中操作对应数据。这样的实现有两个比较明显的缺点:

  1. 每个数据操作都需要手动翻译为对Map的数据操作,费时费力,容易存在翻译偏差

  2. 每次翻译过程,需要case by case处理

当然能做到这种替换,还有个前提是,我们将DAO层的操作都统一封装到了一层,这样才能实现使用Mock对象替换的方式实现整体替换。

解决方案

目前大多数的Java工程都使用Mybatis,解决思路是实现一套类似Mybatis的查询工具类,让写Mock实现和真实的DAO层方法调用类似,让翻译过程尽量简单直观。

为此,我们定义了MockExample对应Mybatis的查询参数Param,MockCriteria对应Criteria(用户暂时不感知),MockTunnelUtil对应DAO,Mock对象和Mybatis真实对象映射关系如下图所示:

MockExample、MockCiteria都以DO(Data Object)作为泛型参数,用于指定操作哪张表

原始某段真实Mybatis查询代码如下:

typescript 复制代码
@Override
public ApiInfoDO get(String apiInfoId) {    
    ApiInfoQuery query = new ApiInfoQuery();
    query.createCriteria()
       .andApiInfoIdEqualTo(apiInfoId);
    List<ApiInfoDO> apiInfoDOList = apiInfoDao.selectByQueryWithBLOBs(query);
    return firstElementOf(apiInfoDOList);
}

翻译后的Mock实现如下:

typescript 复制代码
@Override
public ApiInfoDO get(String apiInfoId) {    
    MockExample<SlsJobDO> example = new MockExample<>();
    example.createCriteria()   
         .andEqualTo(apiInfoId, ApiInfoDO::getApiInfoId);
    List<ApiInfoDO> apiInfoDOList = MockTunnelUtil.selectByExample(this, example);
    return firstElementOf(apiInfoDOList);
}

这里有三点需要对照修改:

  1. 创建查询参数,比如:ApiInfoQuery,需要替换为创建MockExample

  2. 查询条件增加属性的方法引用,比如:ApiInfoDO::getApiInfoId

  3. 使用MockTunnelUtil代替DAO进行查询

MockExample实现主要使用了断言Predicate,以下是In条件的实现:

php 复制代码
public <F> MockCriteria<DO> andIn(List<F> field, Function<DO, F> getter) {
    Predicate<DO> predicate = obj -> field.contains(getter.apply(obj));
    return this.addCondition(predicate);
}

第二层Mock:DAO

上述实现大大简化了Mock 数据库的难度,但仍然存在如下缺点:

  1. 查询 & 修改逻辑变更,Mock逻辑需要跟着变更,存在比较严重的一致性问题
    1. 很多时候会忘记修改,导致Mock结果和实际运行不一致
  1. 如果Mybatis调用逻辑散落各处,没有统一收敛到一层,则Mock比较困难

为此我们需要将Mock的层再向下降一层,直接Mock DAO,在测试中调用DAO,则会调用到我们的Mock实现,做到Mock实现不依赖业务代码变化。

思路

一个比较直观的解决方案是实现一套通用逻辑,将Mybatis的Param直接转换为MockExample,则不需要再手动去写那段翻译逻辑,即可自动将业务实现转换为Mock实现。

难点

这里的一个难点是Mybatis生成的查询Criteria缺乏公共的父类,每个方法的名称都是和用户参数名相关的,比如andApiInfoIdEqualTo。

解决方案

通过分析,我们可以发现,其实问题的根源在于Mybatis的Example、Criteria、Criterion缺乏公共的接口或基类。为了解决这个问题,我们定义了SqlParam、SqlCriterion、SqlCriteria,用来抽象这三个层次的对象。以下是这三个类的定义:

java 复制代码
public interface SqlCriteria<Criterion> {


    List<Criterion> getCriteria();
}
cs 复制代码
public interface SqlCriterion {


    String getCondition();


    Object getValue();


    Object getSecondValue();
}
cpp 复制代码
public interface SqlParam<Criteria> {


    /**
     * 是否分页
     */
    boolean isPage();


    /**
     * 获取页码(1开始)
     */
    Integer getPageIndex();


    /**
     * 获取页大小
     */
    Integer getPageSize();


    /**
     * 获取排序语句
     */
    String getOrderByClause();


    /**
     * 获取查询条件
     */
    List<Criteria> getOredCriteria();
}

我们在Mock DAO层的实现中,定义不同DO的这三个接口实现即可,这样我们就可以基于这些信息将Mybatis Param转换为MockExample了。以下是Mock DAO实现的样例:

java 复制代码
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class MockApiInfoDaoImpl
extends AbstractMockDaoImpl<ApiInfoDO, ApiInfoQuery, Criteria, Criterion>
implements ApiInfoDao {


    private static final MockApiInfoDaoImpl INSTANCE = new MockApiInfoDaoImpl();


    public static MockApiInfoDaoImpl getInstance() {
        return INSTANCE;
    }


    @Override
    public Function<ApiInfoDO, Object> getIdGetter() {
        return ApiInfoDO::getId;
    }


    @Override
    protected SqlParam<Criteria> getSqlParam(ApiInfoQuery query) {
        return new SqlParam<Criteria>() {
            @Override
            public boolean isPage() {
                return query.getRows() != null;
            }
            @Override
            public Integer getPageIndex() {
                return query.getOffset() / query.getRows() + 1;
            }
            @Override
            public Integer getPageSize() {
                return query.getRows();
            }


            @Override
            public String getOrderByClause() {
                return query.getOrderByClause();
            }


            @Override
            public List<Criteria> getOredCriteria() {
                return query.getOredCriteria();
            }
        };
    }


    @Override
    protected SqlCriteria<Criterion> getSqlCriteria(Criteria criteria) {
        return criteria::getCriteria;
    }


    @Override
    protected SqlCriterion getSqlCriterion(Criterion criterion) {
        return new SqlCriterion() {
            @Override
            public String getCondition() {
                return criterion.getCondition();
            }
            @Override
            public Object getValue() {
                return criterion.getValue();
            }
            @Override
            public Object getSecondValue() {
                return criterion.getSecondValue();
            }
        };
    }
}

以下是Mybatis查询条件Param转换为MockExample的转换逻辑:

typescript 复制代码
protected MockExample<DO> convert(Param param) {
    MockExample<DO> example = new MockExample<>();


    // 设置条件
    boolean first = true;
    SqlParam<Criteria> sqlParam = getSqlParam(param);
    for (Criteria criteria : sqlParam.getOredCriteria()) {
        MockCriteria<DO> mockCriteria;
        if (first) {
            mockCriteria = example.createCriteria();
            first = false;
        } else {
            mockCriteria = example.or();
        }


        SqlCriteria<Criterion> sqlCriteria = getSqlCriteria(criteria);
        for (Criterion criterion : sqlCriteria.getCriteria()) {
            SqlCriterion sqlCriterion = getSqlCriterion(criterion);
            String condition = sqlCriterion.getCondition();
            int index = condition.indexOf(NbfSymbolConstants.SPACE);
            String property = NbfStringUtils.underLineToCamel(condition.substring(0, index).trim());
            String getterMethod = "get" + StringUtils.capitalize(property);
            String operator = condition.substring(index + 1).trim();


            // 添加属性
            List<Object> valueList = new ArrayList<>();
            Object value = sqlCriterion.getValue();
            if (value != null) {
                valueList.add(value);
            }
            Object secondValue = sqlCriterion.getSecondValue();
            if (secondValue != null) {
                valueList.add(secondValue);
            }


            Function<DO, Object> getter = obj -> {
                try {
                    Method method = getDoClass().getDeclaredMethod(getterMethod);
                    return method.invoke(obj);
                } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
                    throw new BusinessException(e);
                }
            };


            // 操作符
            OperatorEnum operatorEnum = OperatorEnum.of(operator);
            mockCriteria.and(operatorEnum, getter, valueList);
        }
    }


    // 设置分页
    if (sqlParam.isPage()) {
        example.setPagination(sqlParam.getPageIndex(), sqlParam.getPageSize());
    }
    // 设置排序
    String orderByClause = sqlParam.getOrderByClause();
    if (StringUtils.isNotBlank(orderByClause)) {
        example.setOrderByClause(orderByClause);
    }
    return example;
}

不同DAO的Mock实现基本类似,只要拷贝并修改泛型参数即可。

在上述的DAO层Mock实现MockApiInfoDaoImpl中,继承了基类AbstractMockDaoImpl,这是由于同一套Mybatis插件生成的DAO接口方法类似,我们可以定义一个抽象类实现这些接口,DAO层Mock实现继承该抽象类,则不需要再去实现DAO层的接口了。其部分实现如下:

java 复制代码
public abstract class AbstractMockDaoImpl<DO, Param, Criteria, Criterion>
    extends AbstractMockTunnelImpl<DO, Param, Criteria, Criterion> {


    public long countByQuery(Param param) {
        MockExample<DO> example = this.convert(param);
        return MockTunnelUtil.countByExample(this, example);
    }


    public int deleteByQuery(Param param) {
        MockExample<DO> example = this.convert(param);
        return MockTunnelUtil.deleteByExample(this, example);
    }
}

小结

这里我们介绍了完整Mock数据库的一种思路,这种Mock实现仍然存在一些缺陷:

  1. 暂时无法支持事务

  2. 无法实现数据库的特性,比如必填校验等

以上两点都可以在未来支持。它的优点也是比较明显的:

  1. 执行速度快

  2. 不依赖数据库已有数据,不会受数据库已有数据的影响,不会造成脏数据

造数据

基于以上的两个基础设施:Mock Spring容器、Mock数据库,可以使得写测试变得更加容易,对于测试中比较费时费力的造数据,也可以更加快速的实现。

在日常的集成测试中,造数据是一个比较麻烦的事情,虽然我们使用测试的RollBack机制,可以保证对现有数据无污染。但是在某些依赖已有数据的情况,则比较麻烦。如果预先造了这样的数据,可能被其他人无意修改。而且在一些查询场景,已有数据可能对测试执行结果造成干扰。

有了这套完整的Mock工具,我们可以使用线上数据进行测试,更加快捷的回归 & 发现问题。

造数据的几种方式

常见造数据的两种方式:

  1. 通过属性设值。即各种New对象,Set属性

  2. 通过JSON解析文件

第一种方式的开发维护成本较高,尤其是构建大对象时。

造数据的来源,也有两种方式:

  1. 通过DO(Data Object)去造数据,即把数据直接插入数据库

  2. 通过领域对象造数据,调用Repository去创建数据

根据数据来源也分为日常、线上。显然线上数据质量远高于日常,更容易发现问题。

方案

将数据库查询到的线上(日常)库数据,转换为领域对象,有比较大的转换成本;如果转换为DO对象,也有一定成本,但成本较低。所以本方案采用了后一种方案。

但是把数据库查询出来的数据拷贝出来,直接转换为DO所需要的JSON格式文件,也有较高的成本,所以这里直接使用字段拆分解析的方式读取其内容,再反射设值到DO对象中。这里有个问题,数据库查询出来的字段顺序可能和DO中字段定义顺序不一致,所以需要有个元信息文件,用于指定数据库查询出来数据的字段顺序。

以下是本测试框架的 TableLoadUtil#Load 方法,用于将数据库查询出来的数据转换为DO数据。第一个参数对应的文件内容是数据库查询出来的各行数据,第二个参数对应的文件内容是DO的字段顺序。

php 复制代码
public class TableLoadUtil {


    /**
     * 根据元信息定义加载数据
     * @param fileName 表数据文件路径
     * @param metadata 表字段顺序元信息定义文件路径
     * @param clazz DO类
     */
    public static <T> List<T> load(String fileName, String metadata, Class<T> clazz);
}

这样就实现了通过数据库数据直接快速造数据的目的,推荐使用线上数据(但对敏感数据需要脱敏),保证测试质量。

我们需要将测试涉及到的表的少量行数据(不需要全量)查询出来,并添加到对应文件中。对于复杂场景,这种造数据的方式显然更加高效。而且可以做到每个测试的数据都是重新初始化的,互相隔离不影响。这些数据还可以在不同测试间共享。不需要启动完整的Spring容器,只需要启动Mock的Spring容器,保证测试启动(无论工程多么庞大)在10秒以内,大部分测试启动在3秒以内。

比较通用的做法是在测试基类里做数据的初始化和清理,具体的测试类继承该类,以下是一个线上应用的测试基类:

php 复制代码
public class DataPrepareBaseOnTable {


    /**
     * 准备数据
     */
    @BeforeClass
    public static void prepare() {
        cleanUp();
        initData(BackendServiceConfigDO.class, MockBackendServiceConfigMapperImpl.getInstance()::insert);
    }


    /**
     * 清理数据
     */
    @AfterClass
    public static void cleanUp() {
        MockBackendServiceConfigMapperImpl.getInstance().getCache().clear();
    }


    /**
     * 初始化数据
     */
    public static <DO> void initData(Class<DO> clazz, Consumer<DO> insertMethod) {
        String objName = clazz.getSimpleName().substring(0, clazz.getSimpleName().length() -2);
        List<DO> doList = TableLoadUtil.load(
            "table/" + objName + "/" + objName + ".txt",
            "table/Metadata/" + objName + ".txt",
            clazz);
        NbfListUtils.forEach(doList, insertMethod);
    }
}

这里可以设置为整个测试类初始化 & 清理一次数据,也可以设置为单个测试初始化一次(推荐)。

造数据的流程大致如下:

小结

上述小节介绍了一种,通过直接将数据库查询到的数据转为测试准备数据的方案,该方案的优点如下:

  1. 构造数据足够简单快捷

  2. 避免了测试数据被外部意外修改,数据变动过程可以通过git记录查到

  3. 各个测试之间测试数据隔离

  4. 测试执行速度快,绝大部分测试启动在10秒以内

  5. 测试数据质量较高,可完全使用线上数据

  6. 测试数据相对干净、纯粹,避免测试环境很多脏数据导致测试不稳定

以上的优点主要是相对于集成测试 + @RollBack的传统测试方式

该方案的成本主要在于:创建字段顺序文件。但对于每张表是一次性的,后续增加字段只需追加新增字段即可

可能的不足是:

  1. 如果数据库新增字段,可能需要更新对应表文件
  • 如果测试不涉及新增字段,大部分是向前兼容的

当数据量较大时,管理表文件可能有一定成本

    • 推荐使用文件行排序,避免插入重复数据,且要使得数据尽量少,仅包含测试需要的行数据

Mock中间件

mock中间件相对较为简单,这里仅把我们的方案做简单介绍。

Mock RPC框架

只需要创建Mock对象,记录测试case情况下,日常或线上该接口的出参即可,推荐用JSON文件保存,让Mock方法根据入参加载对应的出参JSON文件作为结果返回。

Mock Redis

也通过HashMap进行Mock即可,实现复杂度取决于需要覆盖其功能的完整性。

小结

本文介绍了一种秒级启动的集成测试框架,使用该框架可以方便的修改和完善测试用例,使得测试用例成为测试过程的产物。测试通过之后,也同时沉淀了覆盖多种测试场景的测试用例。可以方便的使用线上数据作为数据来源,保证测试的质量。甚至在遇到线上问题时,可以将这些数据作为数据来源,用测试用例执行来反复重现 & Debug这些问题,同时沉淀线上问题的测试用例,保证后续代码改造或重构不会重新触发该故障。

团队介绍

物流技术基础技术团队,主要技术产品:NBF(New-Retail Business Framework), 提供了服务DevOps,LowCode编排和云原生基础设施能力,旨在成为新零售PaaS平台化和SaaS产品化的技术底座。

¤ 拓展阅读 ¤

3DXR技术 | 终端技术 | 音视频技术

服务端技术 | 技术质量 | 数据算法

相关推荐
互联网杂货铺1 天前
单元测试/系统测试/集成测试知识总结
自动化测试·软件测试·测试工具·职场和发展·单元测试·测试用例·集成测试
Feng.Lee1 天前
敏捷测试的特点与价值
功能测试·集成测试·可用性测试
字节程序员2 天前
四种自动化测试模型实例及优缺点详解
开发语言·javascript·ecmascript·集成测试·压力测试
微服务 spring cloud2 天前
配置PostgreSQL用于集成测试的步骤
数据库·postgresql·集成测试
普密斯科技2 天前
手机外观边框缺陷视觉检测智慧方案
人工智能·计算机视觉·智能手机·自动化·视觉检测·集成测试
字节程序员5 天前
使用JUnit进行集成测试
jmeter·junit·单元测试·集成测试·压力测试
普密斯科技9 天前
3D工具显微镜的测量范围
人工智能·计算机视觉·3d·自动化·视觉检测·集成测试
第三方软件测评10 天前
软件集成测试内容和作用简析
软件测试·集成测试
standxy12 天前
SQLServer到MySQL的数据高效迁移方案分享
集成测试