优雅的进行数据库相关的单元测试

自研MyBatis插件 不影响测试库他人数据 + 自动删除测试数据

Posted by songjunhao on May 4, 2020

概述

在开发中,单元测试是必不可少的。对于数据库的测试来说,会出现几个问题。

使用公共测试库进行测试,但是测试完的脏数据,无法自动清除,只能手动删除或者单独写一个 DELETE 语句进行删除,这无疑会对单元测试的编写增加很多额外工作。而且在单元测试过程中的脏数据直接进入业务表中的这段时间,如果测试人员同时进行功能测试,会导致测试人员认为程序有误。

本文将针对单元测试中会出现的上述问题提供一种解决此问题的较为简单的方案。本文的实现代码基于MyBatisPlugin,如果使用 MyBatis 可直接使用本文提供的代码。

目前市面上的方案

但目前的测试框架,并不能直接支持对数据库的测试。想要测试且不出现上述问题,有几种解决方案如下(PS:这几种方案笔者认为并不能解决实际问题,仅提出来用于对比 笔者的方法思路)

  • 1.使用 HSQLDB 内存数据库。该数据库可将数据存储于内存中,单元测试执行完毕内存释放自然业务数据随之删除。同时 MyBatis 源码中的单元测试都是基于此数据库。但 HSQLDB 不支持 MySql 或 Oracle 的全部语法。且目前企业开发中一般都是使用 MySql 或者 Oracle。针对 后者 编写的 Sql 想直接运行到 HSQLDB 难免又是一番折腾。且实际应用中会有大量基础业务数据,例如用户等。如果想用 HSQLDB,需要把这些数据都单独写脚本先插入。非常麻烦。

  • 2.使用 DBUnit 数据库测试框架。该框架的设计理念是 在测试之前,备份数据库,然后给对象数据库植入我们需要的准备数据,最后,在测试完毕后,读入备份数据库,回溯到测试前的状态。(引用一)。使用这个框架问题也很明显,如果用户等基础信息过大,备份数据库的过程非常缓慢,影响效率。而且还要单独去学习该框架的使用,读者可自行查阅相关信息,针对该框架中文资料相当少,且简单的 demo 使用起来也并非易事。

思路

首先明确一下要解决的问题。

  • 1.单元测试执行中的 测试数据不影响 实际业务表中的测试数据,进而不影响 测试人员进行功能测试。

  • 2.单元测试结束 自动清除测试数据。

DBunit 的思路 是通过备份数据库数据,然后再恢复,这样就能解决第二个问题。但是这样太过麻烦。那么笔者的思路是 创建一张临时 测试表,在测试过程中将 执行的 SQL 中涉及 临时表 对应的 实际业务表 都 替换为 临时表。这样插入的数据都是插入到 临时表中,在测试结束后将该测试表删除,这样既解决了第一个问题,同时又解决了第二个问题。流程图如下所示:

测试框架流程图.jpg

代码设计

先看一下图,更好理解。

测试框架类图 _5_.jpg

其中共有六个类,下面逐一说明

  • 1.BaseTest 测试基础抽象类 标注 @RunWith 及 @SpringBootTest 注解,说明此类为测试类.其中关键的两个方法是 initDataToDataBase 标注了 @Before 注解,如 思路部分流程图中 @Before 部分,clearDataToDataBase 标注了 @After 注解,如 思路部分 @After 部分。剩余三个方法为模板方法,需要子类实现,详细见代码。

  • 2.UpdateMapper 用于执行创建和删除的SQL语句。

  • 3.RerouteToTableInterceptor MyBatis 插件,用于将 业务SQL中的 业务表替换为 临时表。

  • 4.Interceptor MyBatis 自带的类,用于实现插件。

  • 5.DataTestSqlObject 用于记录 后缀及需要处理为临时表的表名集合。

  • 6.DataTestContextHolder 作为BaseTest 和 RerouteToTableInterceptor 中参数 DataTestSqlObject 的沟通桥梁。使用 ThreadLocal 将 DataTestSqlObject 在两者之间传递。

代码实现

话不多说,上代码

1.BaseTest

package com.test.base;

import com.AchievementsServiceApplication;
import com.dynamic.aop.DataSourceContextHolder;
import com.dynamic.aop.DataTestContextHolder;
import com.dynamic.dto.DataTestSqlObject;
import com.test.mapper.TestUpdateMapper;
import com.utils.StrUtils;
import org.apache.commons.io.FileUtils;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import java.io.File;
import java.io.IOException;
import java.util.stream.Stream;

@RunWith(SpringRunner.class)
@SpringBootTest(classes = {AchievementsServiceApplication.class})
public abstract class BaseTest {

    @Autowired
    private TestUpdateMapper testUpdateMapper;

    @Before
    public void initDataToDataBase() throws IOException {
        DataSourceContextHolder.setDB(getDataSouce());
        DataTestSqlObject dataTestSqlObject = initDataTestSqlObject();
        if (dataTestSqlObject != null) {
            System.out.println("init data start");
            DataTestContextHolder.setDataTestSqlObject(dataTestSqlObject);

            File file = new File(initSqlScriptFilePath());
            Assert.assertTrue(file.exists());
            String command = FileUtils.readFileToString(file);

            String[] split = command.split(";");
            Stream.of(split).forEach(entity -> {
                testUpdateMapper.insert(entity);
            });

            System.out.println("init data end");
        }
    }

    @After
    public void clearDataToDataBase() {
        DataSourceContextHolder.setDB(getDataSouce());
        DataTestSqlObject dataTestSqlObject = DataTestContextHolder.getDataTestSqlObject();

        if (dataTestSqlObject != null) {
            System.out.println("clear data start");
            String dropTempTableTemplate = "DROP TABLE IF EXISTS {} ";
            for (String tableName : dataTestSqlObject.getTableNameSet()) {
                String tempDropSql = StrUtils.format(dropTempTableTemplate, tableName);
                testUpdateMapper.insert(tempDropSql);
            }
            System.out.println("clear data end");
        }

        DataTestContextHolder.clear();
    }

    protected abstract String getDataSouce();

    protected abstract String initSqlScriptFilePath();

    protected abstract DataTestSqlObject initDataTestSqlObject();

}

2.TestUpdateMapper

package com.test.mapper;

import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Repository;

@Repository
@Mapper
public interface TestUpdateMapper {

    @Insert("${value}")
    void insert(String sql);

}

3.RerouteToTableInterceptor

import com.dynamic.aop.DataTestContextHolder;
import com.dynamic.dto.DataTestSqlObject;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.ParameterMapping;
import org.apache.ibatis.mapping.SqlSource;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;

import java.text.SimpleDateFormat;
import java.util.Properties;

@Intercepts({@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
        @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),})
public class RerouteToTableInterceptor implements Interceptor {

    private static final ThreadLocal<SimpleDateFormat> DATE_FORMAT_THREAD_LOCAL = new ThreadLocal<SimpleDateFormat>() {
        @Override
        protected SimpleDateFormat initialValue() {
            return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
        }
    };

    @Override
    public Object intercept(Invocation invocation) throws Throwable {

        DataTestSqlObject dataTestSqlObject = DataTestContextHolder.getDataTestSqlObject();
        if (dataTestSqlObject == null) {
            return invocation.proceed();
        }

        final Object[] queryArgs = invocation.getArgs();
        final MappedStatement mappedStatement = (MappedStatement) queryArgs[0];
        //0.sql参数获取
        Object parameter = null;
        if (invocation.getArgs()!= null && invocation.getArgs().length > 1) {
            parameter = invocation.getArgs()[1];
        }
        final BoundSql boundSql = mappedStatement.getBoundSql(parameter);

        String newSql = boundSql.getSql();
        if (CollectionUtils.isNotEmpty(dataTestSqlObject.getTableNameSet())) {
            for (String tableName : dataTestSqlObject.getTableNameSet()) {
                newSql = newSql.replace(tableName, tableName + dataTestSqlObject.getSuffix());
                // newSql = newSql.replace(" "+ tableName + " ", " " + tableName + dataTestSqlObject.getSuffix() + " ");
                // newSql = newSql.replace("`"+ tableName + "`", "`" + tableName + dataTestSqlObject.getSuffix() + "`");
            }
        }

        // 重新new一个查询语句对像
        BoundSql newBoundSql = new BoundSql(mappedStatement.getConfiguration(), newSql, boundSql.getParameterMappings(), boundSql.getParameterObject());
        // 把新的查询放到statement里
        MappedStatement newMs = copyFromMappedStatement(mappedStatement, new BoundSqlSqlSource(newBoundSql));
        for (ParameterMapping mapping : boundSql.getParameterMappings()) {
            String prop = mapping.getProperty();
            if (boundSql.hasAdditionalParameter(prop)) {
                newBoundSql.setAdditionalParameter(prop, boundSql.getAdditionalParameter(prop));
            }
        }
        queryArgs[0] = newMs;

        return invocation.proceed();
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {

    }

    private MappedStatement copyFromMappedStatement(MappedStatement ms, SqlSource newSqlSource) {
        MappedStatement.Builder builder = new MappedStatement.Builder(ms.getConfiguration(), ms.getId(), newSqlSource, ms.getSqlCommandType());
        builder.resource(ms.getResource());
        builder.fetchSize(ms.getFetchSize());
        builder.statementType(ms.getStatementType());
        builder.keyGenerator(ms.getKeyGenerator());
        if (ms.getKeyProperties() != null && ms.getKeyProperties().length > 0) {
            builder.keyProperty(ms.getKeyProperties()[0]);
        }
        builder.timeout(ms.getTimeout());
        builder.parameterMap(ms.getParameterMap());
        builder.resultMaps(ms.getResultMaps());
        builder.resultSetType(ms.getResultSetType());
        builder.cache(ms.getCache());
        builder.flushCacheRequired(ms.isFlushCacheRequired());
        builder.useCache(ms.isUseCache());
        return builder.build();
    }

    public static class BoundSqlSqlSource implements SqlSource {
        private BoundSql boundSql;
        public BoundSqlSqlSource(BoundSql boundSql) {
            this.boundSql = boundSql;
        }
        public BoundSql getBoundSql(Object parameterObject) {
            return boundSql;
        }
    }

}

4.DataTestSqlObject

import java.util.List;

public class DataTestSqlObject {

    private String suffix;

    private List<String> tableNameSet;

    public String getSuffix() {
        return suffix;
    }

    public void setSuffix(String suffix) {
        this.suffix = suffix;
    }

    public List<String> getTableNameSet() {
        return tableNameSet;
    }

    public void setTableNameSet(List<String> tableNameSet) {
        this.tableNameSet = tableNameSet;
    }

    @Override
    public String toString() {
        return "DataTestSqlObject{" +
                "suffix='" + suffix + '\'' +
                ", tableNameSet=" + tableNameSet +
                '}';
    }
}

5.DataTestContextHolder

import com.dynamic.dto.DataTestSqlObject;

public class DataTestContextHolder {

    private static ThreadLocal<DataTestSqlObject> threadLocal = new ThreadLocal<>();

    public static DataTestSqlObject getDataTestSqlObject() {
        return threadLocal.get();
    }

    public static void setDataTestSqlObject(DataTestSqlObject dataTestSqlObject) {
        threadLocal.set(dataTestSqlObject);
    }

    public static void clear() {
        threadLocal.remove();
    }

}

使用

使用方法很简单,首先将自定义的 MyBatis 插件 RerouteToTableInterceptor 集成到业务代码中,然后只需要创建一个 业务测试类,继承 BaseTest 类。然后重写 BaseTest 类的三个模板方法。getDataSouce 方法返回使用哪个数据源。initSqlScriptFilePath方法 返回 初始化sql脚本的 路径。initDataTestSqlObject 方法 返回 临时表后缀及需处理为临时表的表名集合。然后就可以正常执行业务代码。

一个简单的 demo 如下:

package com.test.demo;

import com.dynamic.dto.DataTestSqlObject;
import com.test.base.BaseTest;
import org.junit.Test;

import java.util.ArrayList;
import java.util.List;

public class TestDemo extends BaseTest {

    @Test
    public void businessTestDemo() {
        // 任意测试
    }

    @Override
    protected String getDataSouce() {
        return "default";
    }

    @Override
    protected String initSqlScriptFilePath() {
        return "test.sql";
    }

    @Override
    protected DataTestSqlObject initDataTestSqlObject() {
        DataTestSqlObject dataTestSqlObject = new DataTestSqlObject();

        // 设置需要处理为临时表的表
        List<String> tableNameSet = new ArrayList<>();
        tableNameSet.add("base_user");
        tableNameSet.add("base_order");

        // 设置后缀 单元测试中 会将 tableNameSet 中的表 经由 插件 修改为 base_user_test 及 base_order_test
        dataTestSqlObject.setSuffix("_test");
        dataTestSqlObject.setTableNameSet(tableNameSet);

        return dataTestSqlObject;
    }

}

上述代码可以看出,使用非常简单,且这种方法是完全扩展的,无需修改业务代码,也无需额外的开发 对测试数据,进行清除。

总结

以上短短数行代码即可解决单元测试与数据库相关的交互问题,简单又实用,强大又优雅,不需要为了测试而写额外的代码,不需要修改业务代码,实现了完全的扩展,如果问题,欢迎评论交流~