单元测试之DBUnit的使用以及原理剖析

前面介绍了不少写单元测试的内容,比方说Mockito和PowerMockito, JUnit 5,经常写单元测试的想必对这些框架都比较熟悉。

这篇博客主要介绍下数据库驱动测试框架–DbUnit(http://dbunit.sourceforge.net/), 主要从DbUnit的设计原理和实际使用来展开,这里的使用我又分为三个部分:

  1. 基于spring-test-dbunit的使用
  2. 基于dbunit本身api的使用
  3. 在dbunit的基础上整合了公司自己的jdbc框架完成的工具类

DBUnit 设计原理

看过我之前关于单元测试的博客和熟悉单元测试的开发人员都知道,在写单元测试时最重要的一点就是单元测试是要求可以反复执行验证的。

那么在我们对数据库进行单元测试的时候,为了保证每次数据库的单元测试都可以得到一个相同的结果,我们就不能直接使用数据库里的数据来进行测试验证,说不定什么时候数据就被别人修改了,而且我们的单测执行最好也不要对数据库的数据有什么修改 — 很容易就想到的数据库的事务特性。

但是考虑到有的数据库本身并不支持事务,比如MyISAM引擎,而由dbunit本身实现事务是比较复杂的,所以dbunit框架本身是没有实现事务的

dbunit的设计原理就是在执行测试用例之前,先备份数据库,然后向数据库中插入我们需要的初始化数据(准备数据),然后,在测试完毕后,清空表数据再将之前的备份的数据还原到数据库,从而回溯到测试前的状态。

乍一看是不是也像是实现了一个"事务" ?但还是有两个问题:

  1. 如果在单测执行过程中遇到问题, 导致执行中断,那么最后可能没有正常还原数据,这样的话就可能导致数据库的数据丢失(所以无论单测执行成功还是失败都记得一定要执行还原数据的代码)
  2. 单测执行过程中修改的数据在还原数据库的时候是会有丢失的,不过因为是测试环境的数据,影响也不是很大

DBUnit 基本概念和流程

基于DBUnit 单元测试的主要接口是IDataSet。IDataSet 数据集代表一个或多个表的数据。
dbunit可以将数据库的全部内容表示为IDataSet 实例。数据库表可以用ITable 实例来表示。

public interface IDataSet
{
    /**
     * 从IDataSet获取表名集合
     */
    public String[] getTableNames() throws DataSetException;

    /**
     * 获取数据库指定表的元数据
     */
    public ITableMetaData getTableMetaData(String tableName)
            throws DataSetException;

    /**
     * 获取指定表
     */
    public ITable getTable(String tableName) throws DataSetException;

    /**
     * 获取所有的表集合
     */
    public ITable[] getTables() throws DataSetException;
}

IDataSet 的实现有很多,每一个都对应一个不同的数据源或加载机制。最常用的几种 IDataSet实现为:
FlatXmlDataSet:数据的简单平面文件 XML 表示
QueryDataSet:用 SQL 查询获得的数据
DatabaseDataSet:数据库表本身内容的一种表示
XlsDataSet :数据的excel表示

我们使用DbUnit进行数据库单元测试的流程如下:

  1. 备份数据库中的表数据
  2. 准备好测试使用的初始化数据和预期的结果数据,一般用xml文件表示
  3. 清空数据表并导入初始化数据。
  4. 执行对应的测试方法,比较实际执行的返回结果与预期结果是否匹配
  5. 使用备份文件还原表数据

DBUnit 使用

spring 结合dbunit完成db测试

dbunit本身并没有提供事务支持的功能,但是spring是可以提供事务支持的,包括声明式事务和程序控制事务。所以dbunit结合spring可以将上述单元测试的执行全都放在一个事务里,这样就可以解决我上面提到的两个问题

如果结合spring使用dbunit进行单元测试,就需要引入dbunit和spring-test-dbunit两个jar包

        <dependency>
            <groupId>com.github.springtestdbunit</groupId>
            <artifactId>spring-test-dbunit</artifactId>
            <version>1.2.0</version>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.dbunit</groupId>
            <artifactId>dbunit</artifactId>
            <version>2.5.0</version>
            <type>jar</type>
            <scope>test</scope>
        </dependency>
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = ServiceInitializer.class)
@TestExecutionListeners({
        DependencyInjectionTestExecutionListener.class,
        DirtiesContextTestExecutionListener.class,
        TransactionalTestExecutionListener.class,
        DbUnitTestExecutionListener.class })
@DbUnitConfiguration(databaseConnection={"dataSource"})
@Transactional
public class BaseTest {}

因为我们使用@DbUnitConfiguration注解传入了dataSource, 这样在dbunit里获取连接的时候得到就是从spring管理的数据源获取的connection,这样事务管理也可以由spring的声明式事务托管。

public class UserMapperDBUnitTest extends BaseTest {

    @Autowired
    private UserMapper userMapper;

    @Test
    @DatabaseSetup("/dbunit/sampleData_initdata.xml",type = DatabaseOperation.CLEAN_INSERT)
    @ExpectedDatabase(value = "/dbunit/sampleData_result_insert.xml", assertionMode = DatabaseAssertionMode.NON_STRICT)
    public void testInsertSelective(){
        User user = new User();
        user.setId("2");
        user.setUserName("Tom");
        user.setAge(28);
        user.setBirthday("1993-03-21");
        user.setAddress("上海市浦东新区");
        userMapper.insertSelective(user);

    }
 }

sampleData_initdata.xml :

 <?xml version="1.0" encoding="UTF-8"?>
<dataset>
    <user id="1" user_name="Bob" age = "20" birthday = "2000-01-02" address = "北京市大兴区" />
</dataset>

sampleData_result_insert.xml :

 <?xml version="1.0" encoding="UTF-8"?>
<dataset>
    <user id="1" user_name="Bob" age = "20" birthday = "2000-01-02" address = "北京市大兴区" />
    <user id="2" user_name="Tom" age = "28" birthday = "1993-03-21" address = "上海市浦东新区" />
</dataset>

@DatabaseSetup: 用于指定初始化数据库的xml文件,以及初始化方式。 默认使用的是CLEAN_INSERT方式,也就是先清除数据库的所有数据再插入准备的数据;如果表中的数据比较多,建议使用REFRESH方式,表示不会将原数据清空,而是直接对数据表中xml中存在的数据进行更新,不存在的就进行插入

@ExpectedDatabase 执行完测试方法后,将数据库中的数据查询出来和xml中的数据进行比较
注解参数query: 如果没有则查询所有的数据,否则按照指定的sql进行查询
参数 assertionMode: 支持两种数据验证方式:1)DatabaseAssertionMode.DEFAULT 要验证所有的字段 2)DatabaseAssertionMode.NON_STRICT则支持只验证部分字段(实际测试中NON_STRICT更为常用)

使用dbunit原生api完成db测试

上述spring-test-dbunit使用的前提是需要结合被spring管理的数据源, 因为公司有的旧项目是使用了自己开发的jdbc框架,其数据源无法直接获取,也没办法使用上面简单的注解方式

所以自己使用了dbunit的API来编写数据库的单元测试,具体代码如下:

public class DBUnitConnection {
    private static IDatabaseConnection CONNECTION_INSTANCE = null;
    //创建DBUnit Connection,先创建数据源, 再从数据源中获取到连接, 封装成MySQLConnection
    public static IDatabaseConnection getConnection() throws Exception {
        if (null == CONNECTION_INSTANCE) {
            //下面三行代码主要是为了获取数据库连接,可以根据你在项目中实际获取数据源和连接的方式调整
            XXDataSourceFactory factory = new XXDataSourceFactory();
            DataSource dataSource = factory.createDataSource();
            Connection connection = dataSource.getConnection();
            CONNECTION_INSTANCE = new MySqlConnection(connection,"userdb");
        }
        return CONNECTION_INSTANCE;
    }

    public void closeConnection() throws Exception {
        if (null != CONNECTION_INSTANCE) {
            if (!CONNECTION_INSTANCE.getConnection().isClosed()) {
                CONNECTION_INSTANCE.close();
            }
            CONNECTION_INSTANCE = null;
        }
    }
}
public class DbUnitUtil {
    //备份表数据
    public static void backupDatabase(String[] tables,File backupFile) throws Exception{
        QueryDataSet dataSet= new QueryDataSet(DBUnitConnection.getConnection());
        for(String _table:tables){
            dataSet.addTable(_table);
        }
        FlatXmlDataSet.write(dataSet, new FileOutputStream(backupFile));
    }

    //清空表数据,并导入测试数据
    public static void importTables(File dataFile) throws Exception{
        IDataSet dataSet=new FlatXmlDataSetBuilder().build(dataFile);
        DatabaseOperation.CLEAN_INSERT.execute(DBUnitConnection.getConnection(), dataSet);
    }

    //清空表数据,恢复备份数据
    public static void resumeDatabase(File backupFile) throws Exception{
        IDataSet dataSet= new FlatXmlDataSetBuilder().build(backupFile);
        DatabaseOperation.CLEAN_INSERT.execute(DBUnitConnection.getConnection(), dataSet);
    }
}
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = {ServiceInitializer.class})
public class UserMapperDBUnitTest {

    @Autowired
    private UserMapper userMapper;

    private static final String TABLE_NAME = "user";

    private static String path = "";

    @Before
    public void init() throws Exception {
        path = UserMapperDBUnitTest .class.getClassLoader().getResource("").getPath()+"dbunit/backupAllData.xml";
        //备份数据表到path路径下的xml文件
        DbUnitUtil.backupDatabase(new String[]{TABLE_NAME},new File(path));
    }

    @After
    public void down() throws Exception {
        //还原表数据
        DbUnitUtil.resumeDatabase(new File(path));
    }

    @Test
    public void testInsertOneRecord() throws Exception {
        String path = getClass().getClassLoader().getResource("").getPath()+"dbunit/sampleData_initdata.xml";
        //清空并导入初始化数据
        DbUnitUtil.importTables(new File(path));
        User user = new User();
        user.setId("2");
        user.setUserName("Tom");
        user.setAge(28);
        user.setBirthday("1993-03-21");
        user.setAddress("上海市浦东新区");
        userMapper.insertSelective(user);
        String resultFile = getClass().getClassLoader().getResource("").getPath()+"dbunit/sampleData_result_insert.xml";
        IDataSet dataSet = new FlatXmlDataSetBuilder().build(new File(resultFile));
        assertDataSet(TABLE_NAME, "SELECT id, user_name, age, birthday, address FROM user", dataSet);

    }

虽然按照上面的方式可以实现数据库的单元测试,但是会出现最早提到的两个问题:

  1. 执行过程中更新的数据会丢失
  2. 执行失败可能会导致原来测试数据库的数据丢失

所以还是需要一个"事务"帮助我们来解决上述问题。

手动实现dbunit与事务的结合

查看了下我们的jdbc框架,它本身也是有事务支持的,既支持声明式事务,也支持编程式事务。我试着按照spring-test-dbunit和dbunit的使用方式来编写测试方法,但是在执行的时候会报错,提示使用事务注解的bean只能事务管理器来创建,所以最后我选择了使用编程式事务来解决上述问题

解决思路 :
我的目的是将dbunit对数据库的操作和应用代码里对数据库的操作放到一个事务里,那么首先二者需要处于一个连接中,我之前的代码中直接从数据源创建新连接的方法是需要修改的;其次就是需要将对数据库操作的代码都放在编程式事务里

为了方便使用,我将代码进一步封装,这样在编写测试用例的时候就可以只使用自定义注解和Rule来完成对数据库的清除,还原等操作。

修改后的代码如下:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface DBUnitAnnotation {
    /**
     * Provides the locations of the datasets that will be used to reset the database.
     */
    String setupFile() ;
    /**
     * Provides the locations of the datasets that will be used to test the database.
     */
    String resultFile() default "";
}
public class DBUnitUtils {

    /**
     * clean table and input init data to table
     * */
    public static void importTables(File dataFile) throws Exception{
        IDataSet dataSet = new FlatXmlDataSetBuilder().build(dataFile);
        //通过反射获取项目中使用的connection实例
        DatabaseOperation.CLEAN_INSERT.execute(getConnectionInTransaction(), dataSet);
    }


    private static IDatabaseConnection CONNECTION_INSTANCE = null;

    /**
     * get the connection which is use in application
     * */
    public static IDatabaseConnection getConnectionInTransaction() throws Exception {
        // 这里是我根据公司的代码写的,你们可以按照自己项目的实际情况调整
        // 通过反射获取事务管理器的transactionHolder静态变量,从中获取项目中使用的connection实例(因为公司的框架并没有提供api让我们可以在项目中获取使用的连接实例)
        Field f = XXTransactionManager.class.getDeclaredField("transactionHolder");
        f.setAccessible(true);
        ThreadLocal<XXTransaction> transactionHolder = (ThreadLocal<XXTransaction>) f.get(null);
        XXTransaction transaction = transactionHolder.get();
        Connection connection = transaction.getConnection();
        CONNECTION_INSTANCE = new MySqlConnection(connection, "");
        return CONNECTION_INSTANCE;
    }

      /**
     *
     * compare the database data with the expectedDatabase
     * @param expectedDataSet
     * @throws Exception
     */
    public static void assertDataSet(IDataSet expectedDataSet) throws Exception {
        String[] tableNames = expectedDataSet.getTableNames();
        for (String tableName : tableNames) {
            //获取dataSet的表元数据,得到对应的Column集合
            Column[] columns = expectedDataSet.getTable(tableName).getTableMetaData().getColumns();
            String queryField = "";
            for (int i = 0 ; i < columns.length ; i++) {
                queryField += columns[i].getColumnName();
                if (i != columns.length - 1) {
                    queryField += " , ";
                }
            }
            String sql = "select " + queryField + " from " + tableName;
            QueryDataSet loadedDataSet = new QueryDataSet(DBUnitUtils.getConnectionInTransaction());
            loadedDataSet.addTable(tableName, sql);
            //从当前数据库中查询所有数据 并和预期的数据集进行比较
            ITable table1 = loadedDataSet.getTable(tableName);
            ITable table2 = expectedDataSet.getTable(tableName);

            Assert.assertEquals(table2.getRowCount(), table1.getRowCount());
            DefaultColumnFilter.includedColumnsTable(table1, table2.getTableMetaData().getColumns());
            Assertion.assertEquals(table2, table1);
        }
    }
}
public class DbUnitTransactionRule implements TestRule {

    @Override
    public Statement apply(final Statement base, Description description) {
        if (description.getAnnotation(DBUnitAnnotation.class) == null) {
            return new Statement() {
                @Override
                public void evaluate() throws Throwable {
                    base.evaluate();
                }
            };
        }
        final DBUnitAnnotation dbUnitAnnotation = description.getAnnotation(DBUnitAnnotation.class);
        //如果有DBUnitAnnotation注解
        return new Statement() {
            @Override
            public void evaluate() throws Throwable {
                try {
                    //开启事务
                    String path = getClass().getClassLoader().getResource("").getPath() + dbUnitAnnotation.setupFile();
                    DBUnitUtils.importTables(new File(path));
                    base.evaluate();
                    if (StringUtils.isNotEmpty(dbUnitAnnotation.resultFile())) {
                        String resultFile = DBUnitUtils.class.getClassLoader().getResource("").getPath() + dbUnitAnnotation.resultFile();
                        IDataSet expectedDataSet = new FlatXmlDataSetBuilder().build(new File(resultFile));
                        DBUnitUtils.assertDataSet(expectedDataSet);
                    }
                } catch (Throwable e) {
                    e.printStackTrace();
                    //如果原来的单测有异常,则抛出断言失败也就是测试用例执行失败
                    throw new AssertionError();
                } finally {
                    //TODO 回滚,哪怕单测执行成功也要还原现场
                }
               
            }
        };
    }
}

使用的时候只需要加上DbUnitTransactionRule 和 @DBUnitAnnotation 注解就可以了,是不是很方便

@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = {ServiceInitialier.class})
public class TRedPointRecordDaoDBUnitTest2 {

    @Autowired
    private UserMapper userMapper;

    @Rule
    public DbUnitTransactionRule rule = new DbUnitTransactionRule();

    @Test
    @DBUnitAnnotation(setupFile = "dbunit/sampleData_initdata.xml", resultFile = "dbunit/sampleData_result_insert.xml")
    public void testInsertOneRecord() throws Exception {
        User user = new User();
        user.setId("2");
        user.setUserName("Tom");
        user.setAge(28);
        user.setBirthday("1993-03-21");
        user.setAddress("上海市浦东新区");
        userMapper.insertSelective(user);
    }
}

封装的代码有一些考虑的还不是很完整,比如不支持多个xml文件;在校验数据的时候也没有对两个DataSet里的表做完全的相等判断

我本来是想写成两个注解,但是在测试的时候发现Rule的Description只能拿到两个注解,所以我就把两个注解定义成一个了 – 目前还没找到原因,如果有读者知道这个问题的答案欢迎在评论区分享下

总结

基本关于DBUnit的介绍就到这里了。

使用DBUnit进行数据库的单元测试,最好是可以结合事务来执行,这样可以避免出现测试数据没有被正常还原或者丢失执行过程中更新的数据的问题。

基本思路就是 开启事务 --> 清空表数据 --> 插入初始化数据 --> 执行测试方法 --> 查询表数据,比较预期结果和执行结果是否一致 --> 回滚事务(无论测试方法是否正确执行,最后都需要回滚)

最后的一部分是我基于工作中整合dbunit和内部的jdbc框架的需要,因为不同的jdbc框架获得connection的方式不一样(甚至有的框架可能也支持类似spring-test-dbunit的声明式事务的写法),所以我只是写了自己项目中的代码实现,希望对有同样需求的开发者可以提供一些思路。

参考资料:

JUnit单元测试6—@Rule注解

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值