单元测试之DBUnit的使用以及原理剖析
前面介紹了不少寫單元測(cè)試的內(nèi)容,比方說Mockito和PowerMockito, JUnit 5,經(jīng)常寫單元測(cè)試的想必對(duì)這些框架都比較熟悉。
這篇博客主要介紹下數(shù)據(jù)庫(kù)驅(qū)動(dòng)測(cè)試框架–DbUnit(http://dbunit.sourceforge.net/), 主要從DbUnit的設(shè)計(jì)原理和實(shí)際使用來展開,這里的使用我又分為三個(gè)部分:
DBUnit 設(shè)計(jì)原理
看過我之前關(guān)于單元測(cè)試的博客和熟悉單元測(cè)試的開發(fā)人員都知道,在寫單元測(cè)試時(shí)最重要的一點(diǎn)就是單元測(cè)試是要求可以反復(fù)執(zhí)行驗(yàn)證的。
那么在我們對(duì)數(shù)據(jù)庫(kù)進(jìn)行單元測(cè)試的時(shí)候,為了保證每次數(shù)據(jù)庫(kù)的單元測(cè)試都可以得到一個(gè)相同的結(jié)果,我們就不能直接使用數(shù)據(jù)庫(kù)里的數(shù)據(jù)來進(jìn)行測(cè)試驗(yàn)證,說不定什么時(shí)候數(shù)據(jù)就被別人修改了,而且我們的單測(cè)執(zhí)行最好也不要對(duì)數(shù)據(jù)庫(kù)的數(shù)據(jù)有什么修改 — 很容易就想到的數(shù)據(jù)庫(kù)的事務(wù)特性。
但是考慮到有的數(shù)據(jù)庫(kù)本身并不支持事務(wù),比如MyISAM引擎,而由dbunit本身實(shí)現(xiàn)事務(wù)是比較復(fù)雜的,所以dbunit框架本身是沒有實(shí)現(xiàn)事務(wù)的
dbunit的設(shè)計(jì)原理就是在執(zhí)行測(cè)試用例之前,先備份數(shù)據(jù)庫(kù),然后向數(shù)據(jù)庫(kù)中插入我們需要的初始化數(shù)據(jù)(準(zhǔn)備數(shù)據(jù)),然后,在測(cè)試完畢后,清空表數(shù)據(jù)再將之前的備份的數(shù)據(jù)還原到數(shù)據(jù)庫(kù),從而回溯到測(cè)試前的狀態(tài)。
乍一看是不是也像是實(shí)現(xiàn)了一個(gè)"事務(wù)" ?但還是有兩個(gè)問題:
DBUnit 基本概念和流程
基于DBUnit 單元測(cè)試的主要接口是IDataSet。IDataSet 數(shù)據(jù)集代表一個(gè)或多個(gè)表的數(shù)據(jù)。
dbunit可以將數(shù)據(jù)庫(kù)的全部?jī)?nèi)容表示為IDataSet 實(shí)例。數(shù)據(jù)庫(kù)表可以用ITable 實(shí)例來表示。
IDataSet 的實(shí)現(xiàn)有很多,每一個(gè)都對(duì)應(yīng)一個(gè)不同的數(shù)據(jù)源或加載機(jī)制。最常用的幾種 IDataSet實(shí)現(xiàn)為:
FlatXmlDataSet:數(shù)據(jù)的簡(jiǎn)單平面文件 XML 表示
QueryDataSet:用 SQL 查詢獲得的數(shù)據(jù)
DatabaseDataSet:數(shù)據(jù)庫(kù)表本身內(nèi)容的一種表示
XlsDataSet :數(shù)據(jù)的excel表示
我們使用DbUnit進(jìn)行數(shù)據(jù)庫(kù)單元測(cè)試的流程如下:
DBUnit 使用
spring 結(jié)合dbunit完成db測(cè)試
dbunit本身并沒有提供事務(wù)支持的功能,但是spring是可以提供事務(wù)支持的,包括聲明式事務(wù)和程序控制事務(wù)。所以dbunit結(jié)合spring可以將上述單元測(cè)試的執(zhí)行全都放在一個(gè)事務(wù)里,這樣就可以解決我上面提到的兩個(gè)問題
如果結(jié)合spring使用dbunit進(jìn)行單元測(cè)試,就需要引入dbunit和spring-test-dbunit兩個(gè)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 {、 }因?yàn)槲覀兪褂?#64;DbUnitConfiguration注解傳入了dataSource, 這樣在dbunit里獲取連接的時(shí)候得到就是從spring管理的數(shù)據(jù)源獲取的connection,這樣事務(wù)管理也可以由spring的聲明式事務(wù)托管。
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("上海市浦東新區(qū)");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 = "北京市大興區(qū)" /> </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 = "北京市大興區(qū)" /><user id="2" user_name="Tom" age = "28" birthday = "1993-03-21" address = "上海市浦東新區(qū)" /> </dataset>@DatabaseSetup: 用于指定初始化數(shù)據(jù)庫(kù)的xml文件,以及初始化方式。 默認(rèn)使用的是CLEAN_INSERT方式,也就是先清除數(shù)據(jù)庫(kù)的所有數(shù)據(jù)再插入準(zhǔn)備的數(shù)據(jù);如果表中的數(shù)據(jù)比較多,建議使用REFRESH方式,表示不會(huì)將原數(shù)據(jù)清空,而是直接對(duì)數(shù)據(jù)表中xml中存在的數(shù)據(jù)進(jìn)行更新,不存在的就進(jìn)行插入
@ExpectedDatabase 執(zhí)行完測(cè)試方法后,將數(shù)據(jù)庫(kù)中的數(shù)據(jù)查詢出來和xml中的數(shù)據(jù)進(jìn)行比較
注解參數(shù)query: 如果沒有則查詢所有的數(shù)據(jù),否則按照指定的sql進(jìn)行查詢
參數(shù) assertionMode: 支持兩種數(shù)據(jù)驗(yàn)證方式:1)DatabaseAssertionMode.DEFAULT 要驗(yàn)證所有的字段 2)DatabaseAssertionMode.NON_STRICT則支持只驗(yàn)證部分字段(實(shí)際測(cè)試中NON_STRICT更為常用)
使用dbunit原生api完成db測(cè)試
上述spring-test-dbunit使用的前提是需要結(jié)合被spring管理的數(shù)據(jù)源, 因?yàn)楣居械呐f項(xiàng)目是使用了自己開發(fā)的jdbc框架,其數(shù)據(jù)源無法直接獲取,也沒辦法使用上面簡(jiǎn)單的注解方式
所以自己使用了dbunit的API來編寫數(shù)據(jù)庫(kù)的單元測(cè)試,具體代碼如下:
public class DBUnitConnection {private static IDatabaseConnection CONNECTION_INSTANCE = null;//創(chuàng)建DBUnit Connection,先創(chuàng)建數(shù)據(jù)源, 再?gòu)臄?shù)據(jù)源中獲取到連接, 封裝成MySQLConnectionpublic static IDatabaseConnection getConnection() throws Exception {if (null == CONNECTION_INSTANCE) {//下面三行代碼主要是為了獲取數(shù)據(jù)庫(kù)連接,可以根據(jù)你在項(xiàng)目中實(shí)際獲取數(shù)據(jù)源和連接的方式調(diào)整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 {//備份表數(shù)據(jù)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));}//清空表數(shù)據(jù),并導(dǎo)入測(cè)試數(shù)據(jù)public static void importTables(File dataFile) throws Exception{IDataSet dataSet=new FlatXmlDataSetBuilder().build(dataFile);DatabaseOperation.CLEAN_INSERT.execute(DBUnitConnection.getConnection(), dataSet);}//清空表數(shù)據(jù),恢復(fù)備份數(shù)據(jù)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";//備份數(shù)據(jù)表到path路徑下的xml文件DbUnitUtil.backupDatabase(new String[]{TABLE_NAME},new File(path));}@Afterpublic void down() throws Exception {//還原表數(shù)據(jù)DbUnitUtil.resumeDatabase(new File(path));}@Testpublic void testInsertOneRecord() throws Exception {String path = getClass().getClassLoader().getResource("").getPath()+"dbunit/sampleData_initdata.xml";//清空并導(dǎo)入初始化數(shù)據(jù)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("上海市浦東新區(qū)");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);}雖然按照上面的方式可以實(shí)現(xiàn)數(shù)據(jù)庫(kù)的單元測(cè)試,但是會(huì)出現(xiàn)最早提到的兩個(gè)問題:
所以還是需要一個(gè)"事務(wù)"幫助我們來解決上述問題。
手動(dòng)實(shí)現(xiàn)dbunit與事務(wù)的結(jié)合
查看了下我們的jdbc框架,它本身也是有事務(wù)支持的,既支持聲明式事務(wù),也支持編程式事務(wù)。我試著按照spring-test-dbunit和dbunit的使用方式來編寫測(cè)試方法,但是在執(zhí)行的時(shí)候會(huì)報(bào)錯(cuò),提示使用事務(wù)注解的bean只能事務(wù)管理器來創(chuàng)建,所以最后我選擇了使用編程式事務(wù)來解決上述問題
解決思路 :
我的目的是將dbunit對(duì)數(shù)據(jù)庫(kù)的操作和應(yīng)用代碼里對(duì)數(shù)據(jù)庫(kù)的操作放到一個(gè)事務(wù)里,那么首先二者需要處于一個(gè)連接中,我之前的代碼中直接從數(shù)據(jù)源創(chuàng)建新連接的方法是需要修改的;其次就是需要將對(duì)數(shù)據(jù)庫(kù)操作的代碼都放在編程式事務(wù)里
為了方便使用,我將代碼進(jìn)一步封裝,這樣在編寫測(cè)試用例的時(shí)候就可以只使用自定義注解和Rule來完成對(duì)數(shù)據(jù)庫(kù)的清除,還原等操作。
修改后的代碼如下:
@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);//通過反射獲取項(xiàng)目中使用的connection實(shí)例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 {// 這里是我根據(jù)公司的代碼寫的,你們可以按照自己項(xiàng)目的實(shí)際情況調(diào)整// 通過反射獲取事務(wù)管理器的transactionHolder靜態(tài)變量,從中獲取項(xiàng)目中使用的connection實(shí)例(因?yàn)楣镜目蚣懿]有提供api讓我們可以在項(xiàng)目中獲取使用的連接實(shí)例)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的表元數(shù)據(jù),得到對(duì)應(yīng)的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);//從當(dāng)前數(shù)據(jù)庫(kù)中查詢所有數(shù)據(jù) 并和預(yù)期的數(shù)據(jù)集進(jìn)行比較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 {//開啟事務(wù)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();//如果原來的單測(cè)有異常,則拋出斷言失敗也就是測(cè)試用例執(zhí)行失敗throw new AssertionError();} finally {//TODO 回滾,哪怕單測(cè)執(zhí)行成功也要還原現(xiàn)場(chǎng)}}};} }使用的時(shí)候只需要加上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("上海市浦東新區(qū)");userMapper.insertSelective(user);} }封裝的代碼有一些考慮的還不是很完整,比如不支持多個(gè)xml文件;在校驗(yàn)數(shù)據(jù)的時(shí)候也沒有對(duì)兩個(gè)DataSet里的表做完全的相等判斷
我本來是想寫成兩個(gè)注解,但是在測(cè)試的時(shí)候發(fā)現(xiàn)Rule的Description只能拿到兩個(gè)注解,所以我就把兩個(gè)注解定義成一個(gè)了 – 目前還沒找到原因,如果有讀者知道這個(gè)問題的答案歡迎在評(píng)論區(qū)分享下
總結(jié)
基本關(guān)于DBUnit的介紹就到這里了。
使用DBUnit進(jìn)行數(shù)據(jù)庫(kù)的單元測(cè)試,最好是可以結(jié)合事務(wù)來執(zhí)行,這樣可以避免出現(xiàn)測(cè)試數(shù)據(jù)沒有被正常還原或者丟失執(zhí)行過程中更新的數(shù)據(jù)的問題。
基本思路就是 開啟事務(wù) --> 清空表數(shù)據(jù) --> 插入初始化數(shù)據(jù) --> 執(zhí)行測(cè)試方法 --> 查詢表數(shù)據(jù),比較預(yù)期結(jié)果和執(zhí)行結(jié)果是否一致 --> 回滾事務(wù)(無論測(cè)試方法是否正確執(zhí)行,最后都需要回滾)
最后的一部分是我基于工作中整合dbunit和內(nèi)部的jdbc框架的需要,因?yàn)椴煌膉dbc框架獲得connection的方式不一樣(甚至有的框架可能也支持類似spring-test-dbunit的聲明式事務(wù)的寫法),所以我只是寫了自己項(xiàng)目中的代碼實(shí)現(xiàn),希望對(duì)有同樣需求的開發(fā)者可以提供一些思路。
參考資料:
JUnit單元測(cè)試6—@Rule注解
總結(jié)
以上是生活随笔為你收集整理的单元测试之DBUnit的使用以及原理剖析的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 记一次OpenJDK替换java JDK
- 下一篇: QUIC实战(四) 设置应用开机自启动