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);