1. 为什么Database unit test不好写
1.1 需要初始化Database
在运行tests时,需要保证数据库处于一个正确的状态(包含必要的测试数据)。最原始的方法,就是写SQL代码,创建干净的tables,然后insert准备好的数据。使用DBUnit,就不必写SQL语句,可以方很方便地构造Database data。
1.2 Unit test需要容易编写
如果unit test不好编写,谁都不愿意写。在unit test中写SQL语句,需要处理SQL异常,处理SQL数据类型和Java数据类型的转换。使用DBunit可以减轻这个负担。
1.3 Unit test需要运行的很快
如果在build的时候,database相关的unit tests占用了大部分的时间,那应该怎么解决呢?
这种时间的浪费,大部分消耗在访问远程的数据库上。DBUnit使用DatabaseConnection封装一个真实的数据库Connection,我么可以传入一个内存数据库(Sqlite)connection,解决访问速度问题。
2. DBUnit简介
DBUnit(http://www.dbunit.org)是Junit的扩展,由Manuel LaFlamme在2002年创建。
DBUnit虽然包含了数百个classes和interfaces,但它的使用,无非就是把dataset放入数据库中,或从数据库中取出dataset。IDataSet是interface,它有很多的实现类。
2.1 引入DBUnit库和相关依赖
DBUnit库本身,只有一个文件(dbunit.jar),它仅依赖于SLF4J(logging framework)。下面是引入DBUnit的Maven配置:
<dependency> <groupId>org.dbunit</groupId> <artifactId>dbunit</artifactId> <version>2.5.3</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>1.7.24</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-nop</artifactId> <version>1.7.24</version> </dependency>
2.2 测试Sample application
Sample数据库,只有一个recipe表。
CREATE TABLE recipe( id LONG, wavelength DOUBLE, aperture TEXT);
对应的domain类是Recipe。
public class Recipe { private long id = -1; private double wavelength = 0.0; private String aperture = "unknown"; }
要测试的对象是AnalysisManipulator。
public class AnalysisManipulator { private Connection conn; public Recipe getRecipeById(int recipeId) throws Exception { // ...... } public void addRecipes(List<Recipe> recipes) throws Exception { // ...... } }
3. 使用dataset初始化数据库
dataset是DBUnit的数据结构,代表数据库中的数据。IDataSet有很多实现,最好用也最常用的是FlatXmlDataSet,使用XML格式表示数据库中的数据。
- tag name表示database table的名字。
- attribute name表示database table column的名字。
- attribute value表示database table column的值。
db-initialize-data.xml,包含recipe表的三行记录,用于初始化数据库。
<?xml version="1.0" encoding="UTF-8"?> <dataset> <recipe id="60" wavelength="420" aperture="aperture1"/> <recipe id="12" wavelength="450" aperture="aperture2"/> <recipe id="45" wavelength="600" aperture="aperture3"/> <!-- Other table rows --> </dataset>
加载XML dataset,初始化数据库。
public class AnalysisManipulatorTest { //Real SQL connection. private Connection memoryConn; // DBUnit connection. private DatabaseConnection dbunitConn; @Before public void setupDatabase() throws Exception { Class.forName("org.sqlite.JDBC"); // Get SQLite memory database connection. memoryConn = DriverManager.getConnection("jdbc:sqlite::memory:"); dbunitConn = new DatabaseConnection(memoryConn); // DBunit cannot add dataset to non-existing table. createRecipeTable(); // Load XML dataset. InputStream inputStream = getClass().getResourceAsStream("db-initialize-data.xml"); InputSource inputSource = new InputSource(inputStream); FlatXmlProducer flatXmlProducer = new FlatXmlProducer(inputSource); IDataSet setupDataSet = new FlatXmlDataSet(flatXmlProducer); // Clean database and insert dataset to database. DatabaseOperation.CLEAN_INSERT.execute(dbunitConn, setupDataSet); } }
3.1 DatabaseOperation分析
DatabaseOperation.CLEAN_INSERT.execute(dbunitConn, setupDataSet);
DatabaseOperation将数据库的操作,都封装成一系列的静态方法。
- UPDATE — 使用dataset中的数据更新数据库,如果dataset中某行数据的主键在database中不存在,则抛出异常。
- INSERT — 使用dataset向database插入数据,如果某行已经存在,则抛出异常。
- REFRESH — 是INSERT和UPDATE的合体。dataset中的数据,在database中不存在则insert进去,如果存在则update database该行数据。没有异常抛出。
- DELETE — 将dataset中的数据,从database中删除。
- DELETE_ALL — 将dataset中包含的tables清空,不管dataset的行数据,只要是出现过的table,都被清空。
- CLEAN_INSERT — 先执行DELETE_ALL,然后执行INSERT,database只包含dataset中的数据。
- CLOSE_CONNECTION(operation) — 执行一个DatabaseOperation,然后关闭connection,可以用在junit @After 或 @AfterClass 方法中。
- NONE – An empty operation that does nothing.
3.2 构造Abstract基类,简化测试
上面的code已经初始化好database了,可以开始写测试code了。但是上面的成员变量,和初始化流程,都是数据库测试必要的流程,而且还要在测试后关闭connection,有必要将它们抽取出来构造一个基类。
public abstract class AbstractDbUnitTestCase { /** * A real database connection. */ protected Connection memoryConn; /** * A wrapped DBUnit connection. */ protected DatabaseConnection dbunitConn; @Before public void setupDatabase() throws Exception { Class.forName("org.sqlite.JDBC"); // Get SQLite memory database connection. memoryConn = DriverManager.getConnection("jdbc:sqlite::memory:"); dbunitConn = new DatabaseConnection(memoryConn); createEmptyTables(); } /** * Create empty database tables. * * @throws Exception when create failed. */ protected abstract void createEmptyTables() throws Exception; @After public void closeDatabase() throws Exception { if (memoryConn != null) { memoryConn.close(); } if (dbunitConn != null) { dbunitConn.close(); } } /** * Get DBUnit IDataSet. * * @param inputStream a data set input stream. * @return an IDataSet. * @throws Exception when cannot load given data set file. */ protected IDataSet getDataSet(InputStream inputStream) throws Exception { return new FlatXmlDataSet( new FlatXmlProducer(new InputSource(inputStream))); } }
4. 写测试代码
数据库测试的代码,分为两类:
- 查询数据库,查询完数据后,直接比对就好。
- 更新数据库(Insert, delete, and upate),使用org.dbunit.Assertion.assertEquals(expectedDataSet, databaseDataSet)。
我们要测试AnalysisManipulator的getRecipeById(…)方法和addRecipes(…)方法,前者是查询数据库,后者是更新数据库。
public class AnalysisManipulatorTest extends AbstractDbUnitTestCase { private AnalysisManipulator manipulator; @Before public void setup() throws Exception { manipulator = new AnalysisManipulator(memoryConn); } @Override protected void createEmptyTables() throws Exception { Statement st = memoryConn.createStatement(); st.execute("CREATE TABLE recipe(" + "id LONG," + "wavelength DOUBLE," + "aperture TEXT);"); st.close(); } }
4.1 测试getRecipeId(…)
在AnalysisManipulatorTest类中添加测试方法:testGetRecipeById()。
@Test public void testGetRecipeById() throws Exception { IDataSet setupDataSet = getDataSet( getClass().getResourceAsStream("db-initialize-data.xml")); DatabaseOperation.CLEAN_INSERT.execute(dbunitConn, setupDataSet); Recipe recipe = manipulator.getRecipeById(60); assertEquals(60, recipe.getId()); assertEquals(420, recipe.getWavelength(), 1e-6); assertEquals("aperture1", recipe.getAperture()); }
4.2 测试addRecipes(…)
db-inserted-recipes.xml是我们期望的,数据库被更新后的状态。
<?xml version="1.0" encoding="UTF-8"?> <dataset> <!-- Inserted recipe rows --> <recipe id="68" wavelength="710"/> <recipe id="67" wavelength="700"/> </dataset>
在AnalysisManipulatorTest类中添加测试方法:testAddRecipes()。
@Test public void testAddRecipes() throws Exception { List<Recipe> recipes = Lists.newArrayList( new Recipe(68, 710, "aperture50"), new Recipe(67, 700, "aperture45")); manipulator.addRecipes(recipes); IDataSet expectedDataSet = getDataSet( getClass().getResourceAsStream("db-inserted-recipes.xml")); IDataSet dbDataSet = dbunitConn.createDataSet(); // org.dbunit.Assertion. Assertion.assertEquals(expectedDataSet, dbDataSet); }
5. 过滤dataset中的数据
在4.2中,dbunitConn.createDataSet()是将数据库中的所有表,所有数据导出成一个dataset。因为4.2中是个clean的database,数据很少,可以这样直接快速比较。
但是如果数据库中已经有大量的数据了,则需要构造一个和数据库中数据量一样的XML dataset,来进行assert比较。这种体力活,太大了,没人愿意做。
5.1 传入String数组
IDataSet actualDataset = dbunitConn.createDataSet( new String[] {"recipe"});
5.2 使用FilteredDataSet封装
FilteredDataSet filteredDataSet = new FilteredDataSet(new String[] {"recipe"}, actualDataset);
FilteredDataSet还有一个好处是,可以和ITableFilter连用。例如,保证导出dataset时,table是按照SQL foreign-key依赖顺序的。
5.3 使用QueryDataSet查询
它可以添加SQL select语句,只有被查询出来的数据,被导出成dataset。如果不指定SQL语句,默认是“SELECT * FROM table_name”。
QueryDataSet actualDataset = new QueryDataSet(dbunitConn); actualDataset.addTable("recipe"); actualDataset.addTable("target_result", "SELECT target_id, recipe_id FROM result where id in (3, 5, 9)");
5.4 单个table比较,忽略某些Column
Assertion.assertEqualsIgnoreCols(expectedDataset, actualDataset, "recipe", new String[] {"id"});
第三个参数,指定比较那个table,dataset中的其它table不参与比较。第四个参数,指定哪些column不参与比较。
比如说数据库中没有result table,recipe数据ID不确定(因为是自动生成的)。下面的XML dataset,比较时可以通过。因为只比较recipe表,忽略id列。
<?xml version="1.0" encoding="UTF-8"?> <dataset> <!-- database doesn't have result table. --> <result id="88"/> <!-- Id column is unknown. --> <recipe id="XXX" wavelength="710" aperture="aperture50"/> <recipe id="XXX" wavelength="700" aperture="aperture45"/> </dataset>
6. ReplacementDataSet
考虑这种情况:向database中插入一行数据,不确定ID是多少(database自动生成),dataset可以这样写。
<?xml version="1.0" encoding="UTF-8"?> <dataset> <recipe id="[ID]" wavelength="710" aperture="aperture50"/> </dataset>
使用ReplacementDataSet替换[ID]为database返回的真是id,然后就可以正确比较了。
Long id = manipulator.addRecipes(recipe); IDataSet expectedDataSet = getDataSet( getClass().getResourceAsStream("db-inserted-recipes.xml")); ReplacementDataSet replacementDataSet = new ReplacementDataSet(expectedDataSet); replacementDataSet.addReplacementObject("[ID]", id); IDataSet dbDataSet = dbunitConn.createDataSet(); Assertion.assertEquals(replacementDataSet, dbDataSet);
7. SortedDataSet
IDataSet sortedDataSet = new SortedDataSet(aDataSet);
封装一个dataset,生成一个新的dataset,新dataset的table中行数据是排好序的。按照table创建时column的顺序进行排序,如果某列同名,按照后面的列进行子排序。
8. 从已有database中创建dataset
如果一个数据测试数据量非常大,手动生成dataset会很麻烦,而且会很容易出错。这是我们可以从已有数据库导出dataset,作为我么期望的dataset。
IDataSet fullDataSet = dbunitConnection.createDataSet(); FileOutputStream xmlStream = new FileOutputStream("full-database.xml"); FlatXmlDataSet.write(fullDataSet, xmlStream);
同样,我们可以生成dataset文件(XML文件)的DTD。
FileOutputStream dtdStream = new FileOutputStream("full-database.dtd"); FlatDtdDataSet.write(fullDataSet, dtdStream);
上面导出dataset通常没有问题,但是导出到dataset中的tables顺序不确定,如果尝试将导出的dataset再insert到database中,可能会出现foreign-key所依赖的数据不存在,抛异常。
解决这个问题,可以使用DatabaseSequenceFilter,保证导出的tables顺序按照foreign-key依赖顺序。
IDataSet fullDataSet = dbunitConnection.createDataSet(); ITableFilter filter = new DatabaseSequenceFilter(dbunitConnection); FilteredDataSet filteredDatSet = new FilteredDataSet(filter, fullDataSet); FileOutputStream xmlStream = new FileOutputStream("full-database.xml") FlatXmlDataSet.write(fullDataSet, xmlStream);