前面介绍了不少写单元测试的内容,比方说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 {@Autowiredprivate 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,先创建数据源, 再从数据源中获取到连接, 封装成MySQLConnectionpublic 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 {@Autowiredprivate UserMapper userMapper;private static final String TABLE_NAME = "user";private static String path = "";@Beforepublic void init() throws Exception {path = UserMapperDBUnitTest .class.getClassLoader().getResource("").getPath()+"dbunit/backupAllData.xml";//备份数据表到path路径下的xml文件DbUnitUtil.backupDatabase(new String[]{TABLE_NAME},new File(path));}@Afterpublic void down() throws Exception {//还原表数据DbUnitUtil.resumeDatabase(new File(path));}@Testpublic 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 {@Overridepublic Statement apply(final Statement base, Description description) {if (description.getAnnotation(DBUnitAnnotation.class) == null) {return new Statement() {@Overridepublic void evaluate() throws Throwable {base.evaluate();}};}final DBUnitAnnotation dbUnitAnnotation = description.getAnnotation(DBUnitAnnotation.class);//如果有DBUnitAnnotation注解return new Statement() {@Overridepublic 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 {@Autowiredprivate UserMapper userMapper;@Rulepublic 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注解

单元测试之DBUnit的使用以及原理剖析相关推荐

  1. Java单元测试之JUnit4详解

    2019独角兽企业重金招聘Python工程师标准>>> Java单元测试之JUnit4详解 与JUnit3不同,JUnit4通过注解的方式来识别测试方法.目前支持的主要注解有: @B ...

  2. Java基础学习总结(24)——Java单元测试之JUnit4详解

    Java单元测试之JUnit4详解 与JUnit3不同,JUnit4通过注解的方式来识别测试方法.目前支持的主要注解有: @BeforeClass 全局只会执行一次,而且是第一个运行 @Before  ...

  3. 基本功 | Litho的使用及原理剖析

    1. 什么是Litho? Litho是Facebook推出的一套高效构建Android UI的声明式框架,主要目的是提升RecyclerView复杂列表的滑动性能和降低内存占用.下面是Litho官网的 ...

  4. 网关,路由,局域网内的通信及不同的网络间通信实现的原理剖析

    百度百科定义网关: 网关(Gateway)又称网间连接器.协议转换器.网关在网络层以上实现网络互连,是最复杂的网络互连设备,仅用于两个高层协议不同的网络互连.网关既可以用于广域网互连,也可以用于局域网 ...

  5. Android 单元测试之Mockito

    在博客Android 单元测试之JUnit4中,我们简单地介绍了:什么是单元测试,为什么要用单元测试,并展示了一个简单的单元测试例子.在文章中,我们只是展示了对有返回类型的目标public方法进行了单 ...

  6. 基本功 | Litho的使用及原理剖析(转)

    美团技术团队分享: https://tech.meituan.com/2019/03/14/litho-use-and-principle-analysis.html 1. 什么是Litho? Lit ...

  7. 【基本功】Litho的使用及原理剖析

    总第344篇 2019年 第22篇 美美导读:[基本功]专栏又上新了,本期介绍一套高效构建Android UI的声明式框架--Litho.作者将带领大家深入剖析它的原理和用法. 1. 什么是Litho ...

  8. socket之send和recv原理剖析

    socket之send和recv原理剖析 1. 认识TCP socket的发送和接收缓冲区 当创建一个TCP socket对象的时候会有一个发送缓冲区和一个接收缓冲区,这个发送和接收缓冲区指的就是内存 ...

  9. fastText的原理剖析

    fastText的原理剖析 1. fastText的模型架构 fastText的架构非常简单,有三层:输入层.隐含层.输出层(Hierarchical Softmax) 输入层:是对文档embeddi ...

最新文章

  1. windows下自制动画层引擎 - 放两个demo
  2. 会议交流 | 如何提升推荐系统的可解释性?——DataFunSummit2022知识图谱在线峰会...
  3. wlan端口服务器无响应,wlan项目遇到的问题,总结
  4. webpack使用文档
  5. SpringBoot - 多Profile使用与切换
  6. 微课|Python程序设计开发宝典(5.1.2节):修饰器
  7. Bailian2694 逆波兰表达式(POJ NOI0202-1696, POJ NOI0303-1696)【文本】
  8. CentOS下的sudo相关配置的总结归纳
  9. STL之set的应用
  10. oc传参数给js_【一句话攻略】彻底理解JS中的回调(Callback)函数
  11. Ruby 的 FileUtils 模块
  12. 【有限元分析】提高有限元分析计算精度的h方法和p方法
  13. .NET报表控件ActiveReports 教程:应用系统中如何完成各种报表系统的需求
  14. TI DSP C64X 优化基本方法
  15. 【书影观后感 十三】甲申三百七十八年祭
  16. WindwosServer系统一些设置【网卡驱动修复】【安装UWP应用】【服务器管理取消开机自启动】
  17. 【GAMES101】作业4(提高)含Bazier曲线的反走样处理
  18. hach vue 跳转_Vue路由实现、路由导航、路由模式
  19. 12道Java高级面试题:java时间差计算
  20. CSS样式怎样修改滚动条的样式

热门文章

  1. 为内置对象添加原型方法 把局部变量编程全局变量
  2. php redis删除所有key,php redis批量删除key的方法
  3. js验证开始日期不能大于结束日期_Excel之日期与时间函数YEAR/MONTH/DAY/DATE/DATEFIF...
  4. html 手机a标签点不动,htmlunit单击javascript a标签不起作用
  5. 【小题目】 输出分数对应的等级 >=90-A >=80-B >=70-C >=60-D <60-E,从控制台获取数据
  6. 深度学习原理与框架-卷积网络细节-数据增强策略 1.翻转 2.随机裁剪 3.平移 4.旋转角度...
  7. 网站安装打包 软件环境检测与安装[二] 下
  8. 基于jquery的侧边栏分享导航
  9. 让linux服务器支持安全http协议(https)
  10. [SEO]让你的Asp.Net网站自动生成Sitemap——XmlSitemap