使用DBUnit测试数据库交互代码

By | 2017年3月20日

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. 写测试代码

数据库测试的代码,分为两类:

  1. 查询数据库,查询完数据后,直接比对就好。
  2. 更新数据库(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);