关于数据库单元测试

一般我们都会用JUnit写单元测试,但是如果测试涉及到数据库,就会有点麻烦。
总结下我的一些解决方法,当然未必是最好的。

单元测试的原则是尽量不要有外部依赖,每一个测试都是可独立运行的。“A good test set is self-sufficient and creates all the data it needs”。

如果测试中要连数据库,目前的做法一般是在配置文件中写死测试数据库的地址。如果我换了一个网络环境,比如说从办公网络切换回家里,就没办法跑测试了。对于项目开发而言这是完全可以接受的,但对于完美主义者而言就很别扭。。。我是希望单元测试能够“write once, run everywhere”。

如果测试依赖于数据库的状态,就更糟糕了。可能在某一时刻测试能通过,但谁更新了一条记录,就会fail,这种问题排查起来也很麻烦。如果测试代码本身就要修改数据库,但测试结束后没有改回来,可能会引起更多的隐形问题。理论上测试前后所有的状态应该是一致的。

综上,其实有两个问题:

  1. 测试依赖于具体的数据库实例。这个实例只有在特定条件下能访问(内网/localhost)。
  2. 测试依赖于数据库的状态,而这个状态不可控。

解决思路也有两种:

  1. 每次测试都搞一个“空白”的数据库。
  2. mock一个DAO层,将所有对数据库的操作用mock对象模拟。

Mockito

关于mock首先想到的就是Mockito,我觉得这是用着最简单的mock框架,可以用来mock各种对象,当然也可以mock DAO。
Mockito的用法就不详细说了,而且Mockito的源码可读性很好,碰到问题看源码很方便。

问题在于,如果要把所有DAO的方法都mock一次,工作量也太大了。。。而且这些代码是不能复用的。
能用配置解决的尽量不要写代码。。。

DBUnit

DBUnit是专门用来解决问题2的,用来维护测试前后数据库的状态。它的思路很简单:测试前备份数据库,准备测试需要的数据;测试后,从备份中还原数据库的状态。具体的用法不详细说了。

DBUnit的问题在于:1.不能解决多个人同时更新状态的问题。官方的最佳实践要求每个开发人员有自己测试数据库。2.没解决网络环境的问题。3.要写很多xml配置文件,很烦。。。

HSQLDB

这个要重点说说。其实刚开始碰到数据库单元测试的问题时,我的想法很简单,问题的根源就在于所有的测试共用同一个数据库实例,那就搞个嵌入式数据库好了啊,每个测试初始化自己“专用”的数据库,自己创建schema/创建测试数据。这样可以保证这个测试在任何时间、任何地方都可以执行。正好以前调研WhiteElephant知道了hsqldb,就拿来试试。

其实嵌入式数据库有很多,最出名的应该是SQLite。还有derby,用过hive的人应该都知道。还有一个叫H2的。但HSQLDB的优势在于:1.纯java写的,兼容性好;2.内存模式很强大,类似一个沙盒,很适合用于测试。

首先在maven中引入依赖:

1
2
3
4
5
6
<dependency>
<groupId>org.hsqldb</groupId>
<artifactId>hsqldb</artifactId>
<version>2.3.2</version>
<scope>test</scope>
</dependency>

用hsqldb创建一个内存数据库非常简单,只要写入一个特殊的jdbc url即可:

1
2
3
4
Class.forName("org.hsqldb.jdbc.JDBCDriver");
// 注意url中的syntax_mys参数,会让hsqldb兼容mysql的语法,虽然兼容的不完全。。。
// 对于oracle也有一个类似的参数,可以让hsqldb兼容oracle的varchar2之类的
DriverManager.getConnection("jdbc:hsqldb:mem:db;sql.syntax_mys=true","sa","");

拿到了Connection对象,就可以做自己的事了,可以任意按自己的业务逻辑操作。

我是把建表语句写到一个文件里,然后在测试用例初始化时加载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
private static ApplicationContext ctx;
@BeforeClass
public static void setup() throws SQLException, IOException {
ctx = new ClassPathXmlApplicationContext("spring/applicationContext.xml");
createSchema(); // 创建schema
createData(); // 创建本次测试需要的数据
}
private static void createSchema() throws IOException, SQLException {
Object obj = ctx.getBean("dataSource");
// 我们测试环境用了bonecp连接池,线上环境用了另一个连接池。。。
if (!(obj instanceof BoneCPDataSource)) {
return;
}
@SuppressWarnings("resource")
BoneCPDataSource ds = (BoneCPDataSource) obj;
String driver = ds.getDriverClass();
// 如果不是hsqldb,就跳过创建schema的步骤。如果用mysql测试,应该是事先建好表的。
if (!driver.toLowerCase().contains("hsqldb")) {
System.out.println("not hsqldb. skip createSchema.");
return;
}
Connection conn = ds.getConnection(); // 直接取出数据库连接
// 创建schema,注意hsqldb建表语句和mysql有些不同
Reader reader = Resources.getResourceAsReader("hsqldb_schema.sql"); // 建表语句都存在这个文件中
ScriptRunner runner = new ScriptRunner(conn); // 利用了mybatis的ScriptRunner工具
runner.setLogWriter(null);
runner.runScript(reader);
reader.close();
}
// 数据库初始化完毕,接下来写自己的测试逻辑
// spring中所有涉及到数据操作的bean都是引用的同一个dataSource
@Test
public void testExpireRule(){
// 省略
}

hsqldb_schema.sql例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
-- hsqldb的语法跟mysql有些不同,建表语句必须要修改下才能用
-- 不支持字段级别的comment
-- 字段名、表名不能用`
-- 不支持engine、charset之类的语法
-- 加索引的语法也不一样
-- 有效期规则
CREATE TABLE expire_rule (
-- 基础字段
id int NOT NULL AUTO_INCREMENT,
type int NOT NULL,
status int NOT NULL,
tag varchar(64) NOT NULL,
comment varchar(256),
extend_map text,
-- 其他
update_time bigint NOT NULL DEFAULT 0,
create_time bigint NOT NULL,
PRIMARY KEY (id)
) COMMENT '有效期规则';

这样每个测试都不会互相干扰,有自己专用的数据库,自己维护状态。感觉还是比较方便的。

Liquibase

Liquibase其实跟单元测试没啥关系,只是我觉得比较有用,顺便记录下以备忘。
liquibase的本质就是一个数据库的版本管理工具。我们在项目中,经常涉及数据表schema的变更,比如加个字段/加个索引之类的,所以数据库也是有版本的概念的。以前的管理方法是直接保存所有的sql语句到一个文件中,然后不断追加,比如:

dbschema.sql
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 先是一堆create table语句
create table XXX ...
cretae table YYY ...
# 然后记录每次的修改
# 2016-05-01 modified by xxx
alter table XXX add column yyy
# 2016-05-02 modified by xxx
alter table YYY ad column zzz
# 2016-05-03 modified by jjj
create table ZZZ ...

显然这种管理方式比较麻烦,很容易出错。更关键的是没有rollback过程。如果新版本上线了有bug,要回滚到旧版本,数据库也要回滚到旧版,就只能手动操作。

liquibase就是用来解决这种问题的,它使用一个changelog文件跟踪数据库的变化,每次变化可以抽象为一个changeset。通过changelog实现对数据库schema的管理,用户可以update到任意版本,可以回滚,也可以在不同版本之间diff。changelog一般是个xml文件,但也可以直接写sql语句,很方便。详细的用法参考官方文档,这里不列出了。