使用内存数据库来为mapper层的接口编写单元测试

发布于:2025-04-08 ⋅ 阅读:(35) ⋅ 点赞:(0)

简介

使用内存数据库来测试mapper层的sql代码,这种方式可以让测试案例摆脱对数据库的依赖,进而变得可重复执行。

这里选择的内存数据库是h2,它是纯java编写的关系型数据库,开源免费,而且轻量级的,性能较好,可以内嵌进java应用中做内存数据库。

编写方式

开发一个比较基础的组件,必须要为mapper层写单元测试,当前项目之前的sql代码都没有单元测试,同时,每次代码合并时都要跑自动化测试,需要把之前所有的单元测试跑一遍。

在这样的背景下,考虑以内存数据库为基础来为mapper接口编写单元测试,它足够稳定,可以支持自动化测试。

被测代码的sql写在注解上。

实现步骤

基本原理:使用内存数据库,构建mabatis的运行环境

第一步:配置建表语句。在项目路径下,编写一个配置文件,里面是每张表的建表语句,要注意,h2数据库的建表语句和其他数据库的略有不同,它不支持索引,因为它的数据是在内存中。

第二步:配置mybatis运行环境

public class BaseMapperTestConfig {
    // 支持跨线程运行
    private static final ThreadLocal<SqlSession> threadLocalSession = new ThreadLocal<>();

    // 获取单个mapper接口的实例
    public static <T> T getMapper(Class<T> clazz) {
        // 配置MyBatis环境
        TransactionFactory transactionFactory = new JdbcTransactionFactory();
        Environment environment = new Environment("dev", transactionFactory, getDataSource());
        Configuration configuration = new Configuration(environment);

        // 添加Mapper扫描路径
        configuration.addMapper(clazz);
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(configuration);
        SqlSession sqlSession = sqlSessionFactory.openSession();

        threadLocalSession.set(sqlSession);
        return sqlSession.getMapper(clazz);
    }

    // 获取多个mapper接口的实例,依照传入顺序依次返回
    public static List<Object> getMappers(Class<?> ...clazzs) {
        // 配置MyBatis环境
        TransactionFactory transactionFactory = new JdbcTransactionFactory();
        Environment environment = new Environment("dev", transactionFactory, getDataSource());
        Configuration configuration = new Configuration(environment);

        // 添加Mapper扫描路径
        for (Class<?> clazz : clazzs) {
            configuration.addMapper(clazz);
        }
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(configuration);
        SqlSession sqlSession = sqlSessionFactory.openSession();
        threadLocalSession.set(sqlSession);

        // 多个mapper要在同一个sqlSession之下
        List<Object> result = new ArrayList<>();
        for (Class<?> clazz : clazzs) {
            result.add(sqlSession.getMapper(clazz));
        }
        return result;
    }

    // 关闭sqlSession
    public static void close() {
        SqlSession sqlSession = threadLocalSession.get();
        if (sqlSession != null) {
            sqlSession.close();
            threadLocalSession.remove();
        }
    }
    
    // 清空数据表
    public static void clearData(String tableName) throws SQLException {
        SqlSession sqlSession = threadLocalSession.get();
        if (sqlSession != null) {
            Connection connection = sqlSession.getConnection();
            Statement statement = connection.createStatement();
            String sql = "delete from " + tableName;
            // update类型的语句返回false,表示没有resultSet对象
            statement.execute(sql);
            statement.close();
        }
    }

    // 配置数据库连接池,这里就是使用了内存数据库
    private static DataSource getDataSource() {
        EmbeddedDatabaseBuilder databaseBuilder = new EmbeddedDatabaseBuilder();
        return databaseBuilder
                .setType(EmbeddedDatabaseType.H2)
                // 设置数据库名称和锁超时时间,时间是10秒;启用mvcc;设置隔离级别是串行化。
                // 每次都使用不同的数据库实例
                .setName("testdb" + System.currentTimeMillis() +";LOCK_TIMEOUT=10000;MVCC=TRUE;LOCK_MODE=3")
                .addScript("classpath:db/schema.sql") // 启动时初始化建表语句
                .build();
    }
}

第三步:单测案例,从之前配置好的mybatis环境中获取mapper实例,然后测试,每个单测运行前向数据库中插入一条数据,运行后删除数据,确保运行环境的稳定。

public class MapperTest {
    private static StudentMapper studentMapper;
    private final String TABLE_NAME = "t_student";

    @BeforeClass
    public static void init() {
        studentMapper = BaseMapperTestConfig.getMapper(StudentMapper.class);
    }

    @AfterClass
    public static void destroy() {
        BaseMapperTestConfig.close();
    }

    @Before
    public void before() throws SQLException {
        PO po = createPO(1L, 2L);  // 单测执行前向数据库中插入一条数据
        studentMapper.insert(po);
    }

    @After
    public void after() throws SQLException {
        BaseMapperTestConfig.clearData(TABLE_NAME);  // 单测执行完之后清空数据库
    }

    @Test
    public void testInsert() {
        // 执行insert语句
        PO po = createPO(2L, 3L);
        int insertNum = studentMapper.insert(po);
        assert insertNum == 1;
    }

    public PO createPO(Long d1, Long d2) {
       // 创建一个po类
    }
}

总结:

  • 关键是在单测中配置mybatis的执行环境,这样可以避免启动spring容器,加快测试速度

  • 每个单测执行前,向数据库中插入固定的数据,执行完成后,情况数据库中的数据,保证测试环境的稳定。

  • h2数据库提供了web页面,供用户访问,不过这里并没有用到,建议用户在增删改查四个测试方法中做好充分的断言,保证数据的正确。

踩坑记录

h2数据库 并发修改异常

在使用内存数据库进行单元测试时,一个常见的问题是并发修改异常。这通常发生在多线程环境或多个测试类同时运行时。如果测试类在同一个JVM实例中运行,会共享同一个内存数据库实例,从而导致并发修改问题。

问题原因:

  • 内存数据库共享:在同一个 JVM 中,多个测试类共享同一个内存数据库实例,导致并发操作冲突。
  • 事务管理:测试类之间未正确隔离事务,导致并发操作冲突。
  • 数据库初始化:每个测试类分别进行数据库初始化时,可能会导致并发请求处理不当。

错误案例:获取锁超时,原因是并发修改。具体情况是,单独执行测试类没有问题,通过mvn clean test执行时,某些测试类就会报并发修改异常

org.apache.ibatis.exceptions.PersistenceException: ### Error updating database.  Cause: org.h2.jdbc.JdbcSQLTimeoutException: Timeout trying to lock table {0}; 
org.h2.message.DbException: Concurrent update in table "SCENE_SET_DEV": another transaction has updated or deleted the same row [90131-199]

解决方案

  1. 使用不同的数据库实例:为每个测试类使用独立的内存数据库实例
public class TestDataSourceConfig {
    private static DataSource getDataSource() {
        EmbeddedDatabaseBuilder databaseBuilder = new EmbeddedDatabaseBuilder();
        return databaseBuilder
                .setType(EmbeddedDatabaseType.H2)
                // 设置数据库名称,每次都生成一个单独的数据库。
                // 锁超时时间,时间是10秒;
                // 启用mvcc;设置隔离级别是串行化;使用不同的数据库实例
                .setName("testdb" + System.currentTimeMillis() +";LOCK_TIMEOUT=10000;MVCC=TRUE;LOCK_MODE=3")
                .addScript("classpath:db/schema.sql") // 启动时初始化建表语句
                .build();
    }
}

在这段代码中,通过加入 System.currentTimeMillis() 方法确保每次测试都使用唯一的数据库实例。

参考

  • https://www.jianshu.com/p/3f34b1c584c3