单元测试作为保证软件质量及重构的基础,早已获得广大开发人员的认可。单元测试是一种细粒度的测试,越来越多的开发人员在提交功能模块时也同时提交相应的单元测试。对于大多数开发人员来讲,编写单元测试已经成为开发过程中必须的流程和最佳实践。
对普通的逻辑组件编写单元测试是一件容易的事情,由于逻辑组件通常只需要内存资源,因此,设置好输入输出即可编写有效的单元测试。对于稍微复杂一点的组件,例如Servlet,我们可以自行编写模拟对象,以便模拟HttpRequest和HttpResponse等对象,或者,使用EasyMock之类的动态模拟库,可以对任意接口实现相应的模拟对象,从而对依赖接口的组件进行有效的单元测试。
在J2EE开发中,对DAO组件编写单元测试往往是一件非常复杂的任务。和其他组件不通,DAO组件通常依赖于底层数据库,以及JDBC接口或者某个ORM框架(如Hibernate),对DAO组件的测试往往还需引入事务,这更增加了编写单元测试的复杂性。虽然使用EasyMock也可以模拟出任意的JDBC接口对象,或者ORM框架的主要接口,但其复杂性往往非常高,需要编写大量的模拟代码,且代码复用度很低,甚至不如直接在真实的数据库环境下测试。不过,使用真实数据库环境也有一个明显的弊端,我们需要准备数据库环境,准备初始数据,并且每次运行单元测试后,其数据库现有的数据将直接影响到下一次测试,难以实现“即时运行,反复运行”单元测试的良好实践。
本文针对DAO组件给出一种较为合适的单元测试的编写策略。在JavaEE开发网的开发过程中,为了对DAO组件进行有效的单元测试,我们采用HSQLDB这一小巧的纯Java数据库作为测试时期的数据库环境,配合Ant,实现了自动生成数据库脚本,测试前自动初始化数据库,极大地简化了DAO组件的单元测试的编写。
在Java领域,JUnit作为第一个单元测试框架已经获得了最广泛的应用,无可争议地成为Java领域单元测试的标准框架。本文以最新的JUnit
4版本为例,演示如何创建对DAO组件的单元测试用例。
JavaEEdev的持久层使用Hibernate 3.2,底层数据库为MySQL。为了演示如何对DAO进行单元测试,我们将其简化为一个DAOTest工程:
|
图1 |
由于将Hibernate的Transaction绑定在Thread上,因此,HibernateUtil类负责初始化SessionFactory以及获取当前的Session:
public class HibernateUtil
{
private static final SessionFactory sessionFactory;
static {
try {
sessionFactory = new AnnotationConfiguration()
.configure()
.buildSessionFactory();
}
catch(Exception e) {
throw new ExceptionInInitializerError(e);
}
}
public static Session getCurrentSession() {
return sessionFactory.getCurrentSession();
}
} |
HibernateUtil还包含了一些辅助方法,如:
public static Object query(Class
clazz, Serializable id);
public static void createEntity(Object entity);
public static Object queryForObject(String hql, Object[] params);
public static List queryForList(String hql, Object[] params);
|
在此不再多述。
实体类User使用JPA注解,代表一个用户:
@Entity
@Table(name="T_USER")
public class User {
public static final String REGEX_USERNAME = "[a-z0-9][a-z0-9\\-]{1,18}[a-z0-9]";
public static final String REGEX_PASSWORD = "[a-f0-9]{32}";
public static final String REGEX_
EMAIL = "([0-9a-zA-Z]([-.\\w]*[0-9a-zA-Z])
*@([0-9a-zA-Z][-\\w]*[0-9a-zA-Z]\\.)+[a-zA-Z]{2,9})";
private String username; //
用户名
private String password; //
MD5口令
private boolean admin;
// 是否是管理员
private String email;
// 电子邮件
private int emailValidation; // 电子邮件验证码
private long createdDate; // 创建时间
private long lockDate;
// 锁定时间
public User() {}
public User(String username, String password, boolean
admin,
long lastSignOnDate) {
this.username = username;
this.password = password;
this.admin = admin;
}
@Id
@Column(updatable=false, length=20)
@Pattern(regex=REGEX_USERNAME)
public String getUsername() { return username; }
public void setUsername(String username) { this.username
= username; }
@Column(nullable=false, length=32)
@Pattern(regex=REGEX_PASSWORD)
public String getPassword() { return password; }
public void setPassword(String password) { this.password
= password; }
@Column(nullable=false, length=50)
@Pattern(regex=REGEX_EMAIL)
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email;
}
@Column(nullable=false)
public boolean getAdmin() { return admin; }
public void setAdmin(boolean admin) { this.admin = admin;
}
@Column(nullable=false, updatable=false)
public long getCreatedDate() { return createdDate; }
public void setCreatedDate(long createdDate) { this.createdDate
= createdDate; }
@Column(nullable=false)
public int getEmailValidation() { return emailValidation;
}
public void setEmailValidation(int emailValidation)
{
this.emailValidation = emailValidation; }
@Column(nullable=false)
public long getLockDate() { return lockDate; }
public void setLockDate(long lockDate) { this.lockDate
= lockDate; }
@Transient
public boolean getEmailValidated() { return emailValidation==0;
}
@Transient
public boolean getLocked() {
return !admin && lockDate>0 && lockDate>System.currentTimeMillis();
}
}
|
实体类PasswordTicket代表一个重置口令的请求:
@Entity
@Table(name="T_PWDT")
public class PasswordTicket {
private String id;
private User user;
private String ticket;
private long createdDate;
@Id
@Column(nullable=false, updatable=false, length=32)
@GeneratedValue(generator="system-uuid")
@GenericGenerator(name="system-uuid",
strategy="uuid")
public String getId() { return id; }
protected void setId(String id) { this.id = id;
}
@ManyToOne
@JoinColumn(nullable=false, updatable=false)
public User getUser() { return user; }
public void setUser(User user) { this.user = user;
}
@Column(nullable=false, updatable=false, length=32)
public String getTicket() { return ticket; }
public void setTicket(String ticket) { this.ticket
= ticket; }
@Column(nullable=false, updatable=false)
public long getCreatedDate() { return createdDate;
}
public void setCreatedDate(long createdDate) { this.createdDate
= createdDate; }
} |
UserDao接口定义了对用户的相关操作:
public interface UserDao {
User queryForSignOn(String username);
User queryUser(String username);
void createUser(User user);
void updateUser(User user);
boolean updateEmailValidation(String username, int
ticket);
String createPasswordTicket(User user);
boolean updatePassword(String username, String oldPassword,
String newPassword);
boolean queryResetPassword(User user, String ticket);
boolean updateResetPassword(User user, String ticket,
String password);
void updateLock(User user, long lockTime);
void updateUnlock(User user);
}
|
UserDaoImpl是其实现类:
public class UserDaoImpl implements UserDao {
public User queryForSignOn(String username) {
User user = queryUser(username);
if(user.getLocked())
throw new LockException(user.getLockDate());
return user;
}
public User queryUser(String username) {
return (User) HibernateUtil.query(User.class, username);
}
public void createUser(User user) {
user.setEmailValidation((int)(Math.random() * 1000000)
+ 0xf);
HibernateUtil.createEntity(user);
}
// 其余方法略
...
}
|
由于将Hibernate事务绑定在Thread上,因此,实际的客户端调用DAO组件时,还必须加入事务代码:
Transaction tx =
HibernateUtil.getCurrentSession().beginTransaction();
try {
dao.xxx();
tx.commit();
}
catch(Exception e) {
tx.rollback();
throw e;
} |
下面,我们开始对DAO组件编写单元测试。前面提到了HSQLDB这一小巧的纯Java数据库。HSQLDB除了提供完整的JDBC驱动以及事务支持外,HSQLDB还提供了进程外模式(与普通数据库类似)和进程内模式(In-Process),以及文件和内存两种存储模式。我们将HSQLDB设定为进程内模式及仅使用内存存储,这样,在运行JUnit测试时,可以直接在测试代码中启动HSQLDB。测试完毕后,由于测试数据并没有保存在文件上,因此,不必清理数据库。
此外,为了执行批量测试,在每个独立的DAO单元测试运行前,我们都执行一个初始化脚本,重新建立所有的表。该初始化脚本是通过HibernateTool自动生成的,稍后我们还会讨论。下图是单元测试的执行顺序:
|
图2 |
在编写测试类之前,我们首先准备了一个TransactionCallback抽象类,该类通过Template模式将DAO调用代码通过事务包装起来:
public abstract
class TransactionCallback {
public final Object execute() throws Exception
{
Transaction tx = HibernateUtil.getCurrentSession().beginTransaction();
try {
Object r = doInTransaction();
tx.commit();
return r;
}
catch(Exception e) {
tx.rollback();
throw e;
}
}
// 模板方法:
protected abstract Object doInTransaction()
throws Exception;
} |
其原理是使用JDK提供的动态代理。由于JDK的动态代理只能对接口代理,因此,要求DAO组件必须实现接口。如果只有具体的实现类,则只能考虑CGLIB之类的第三方库,在此我们不作更多讨论。
下面我们需要编写DatabaseFixture,负责启动HSQLDB数据库,并在@Before方法中初始化数据库表。该DatabaseFixture可以在所有的DAO组件的单元测试类中复用:
public class DatabaseFixture
{
private static Server server = null; // 持有HSQLDB的实例
private static final String DATABASE_NAME
= "javaeedev"; // 数据库名称
private static final String SCHEMA_FILE =
"schema.sql"; // 数据库初始化脚本
private static final List
initSqls = new ArrayList();
@BeforeClass // 启动HSQLDB数据库
public static void startDatabase() throws
Exception {
if(server!=null)
return;
server = new Server();
server.setDatabaseName(0, DATABASE_NAME);
server.setDatabasePath(0, "mem:" + DATABASE_NAME);
server.setSilent(true);
server.start();
try {
Class.forName("org.hsqldb.jdbcDriver");
}
catch(ClassNotFoundException cnfe) {
throw new RuntimeException(cnfe);
}
LineNumberReader reader = null;
try {
reader = new LineNumberReader(new
InputStreamReader(DatabaseFixture.class.getClassLoader()
.getResourceAsStream(SCHEMA_FILE)));
for(;;) {
String line = reader.readLine();
if(line==null) break;
// 将text类型的字段改为varchar(2000),因为HSQLDB不支持text:
line = line.trim().replace(" text ",
" varchar(2000) ").replace("
text,", " varchar(2000),");
if(!line.equals(""))
initSqls.add(line);
}
}
catch(IOException e) {
throw new RuntimeException(e);
}
finally {
if(reader!=null) {
try { reader.close(); } catch(IOException
e) {}
}
}
}
@Before // 执行初始化脚本
public void initTables() {
for(String sql : initSqls) {
executeSQL(sql);
}
}
static Connection getConnection() throws
SQLException {
return DriverManager.getConnection("jdbc:hsqldb:mem:"
+ DATABASE_NAME,
"sa", "");
}
static void close(Statement stmt) {
if(stmt!=null) {
try {
stmt.close();
}
catch(SQLException e) {}
}
}
static void close(Connection conn) {
if(conn!=null) {
try {
conn.close();
}
catch(SQLException e) {}
}
}
static void executeSQL(String sql) {
Connection conn = null;
Statement stmt = null;
try {
conn = getConnection();
boolean autoCommit = conn.getAutoCommit();
conn.setAutoCommit(true);
stmt = conn.createStatement();
stmt.execute(sql);
conn.setAutoCommit(autoCommit);
}
catch(SQLException e) {
log.warn("Execute failed: " + sql +
"\nException: " + e.getMessage());
}
finally {
close(stmt);
close(conn);
}
}
public static Object createProxy(final Object
target) {
return Proxy.newProxyInstance(
target.getClass().getClassLoader(),
target.getClass().getInterfaces(),
new InvocationHandler() {
public Object invoke(Object proxy, final
Method method, final Object[] args)
throws Throwable {
return new TransactionCallback() {
@Override
protected Object doInTransaction() throws Exception
{
return method.invoke(target, args);
}
}.execute();
}
}
);
}
}
|
注意DatabaseFixture的createProxy()方法,它将一个普通的DAO对象包装为在事务范围内执行的代理对象,即对于一个普通的DAO对象的方法调用前后,自动地开启事务并根据异常情况提交或回滚事务。
下面是UserDaoImpl的单元测试类:
public class UserDaoImplTest
extends DatabaseFixture {
private UserDao userDao = new UserDaoImpl();
private UserDao proxy = (UserDao)createProxy(userDao);
@Test
public void testQueryUser() {
User user = newUser("test");
proxy.createUser(user);
User t = proxy.queryUser("test");
assertEquals(user.getEmail(), t.getEmail());
}
} |
注意到UserDaoImplTest持有两个UserDao引用,userDao是普通的UserDaoImpl对象,而proxy则是将userDao进行了事务封装的对象。
由于UserDaoImplTest从DatabaseFixture继承,因此,@Before方法在每个@Test方法调用前自动调用,这样,每个@Test方法执行前,数据库都是一个经过初始化的“干净”的表。
对于普通的测试,如UserDao.queryUser()方法,直接调用proxy.queryUser()即可在事务内执行查询,获得返回结果。
对于异常测试,例如期待一个ResourceNotFoundException,就不能直接调用proxy.queryUser()方法,否则,将得到一个UndeclaredThrowableException:
|
图3 |
这是因为通过反射调用抛出的异常被代理类包装为UndeclaredThrowableException,因此,对于异常测试,只能使用原始的userDao对象配合TransactionCallback实现:
@Test(expected=ResourceNotFoundException.class)
public void testQueryNonExistUser() throws Exception
{
new TransactionCallback() {
protected Object doInTransaction() throws
Exception {
userDao.queryUser("nonexist");
return null;
}
}.execute();
} |
到此为止,对DAO组件的单元测试已经实现完毕。下一步,我们需要使用HibernateTool自动生成数据库脚本,免去维护SQL语句的麻烦。相关的Ant脚本片段如下:
<target name="make-schema"
depends="build" description="create
schema"> <taskdef name="hibernatetool"
classname="org.hibernate.tool.ant.HibernateToolTask">
<classpath refid="build-classpath"/>
</taskdef>
<taskdef name="annotationconfiguration"
classname="org.hibernate.tool.ant.AnnotationConfigurationTask">
<classpath refid="build-classpath"/>
</taskdef>
<annotationconfiguration configurationfile="${src.dir}/hibernate.cfg.xml"/>
<hibernatetool destdir="${gen.dir}">
<classpath refid="build-classpath"/>
<annotationconfiguration configurationfile="${src.dir}/hibernate.cfg.xml"/>
<hbm2ddl
export="false"
drop="true"
create="true"
delimiter=";"
outputfilename="schema.sql"
destdir="${src.dir}"
/>
</hibernatetool>
</target>
|
完整的Ant脚本以及Hibernate配置文件请参考项目工程源代码。
利用HSQLDB,我们已经成功地简化了对DAO组件进行单元测试。我发现这种方式能够找出许多常见的bug:
-
HQL语句的语法错误,包括SQL关键字和实体类属性的错误拼写,反复运行单元测试就可以不断地修复许多这类错误,而不需要等到通过Web页面请求而调用DAO时才发现问题;
-
传入了不一致或者顺序错误的HQL参数数组,导致Hibernate在运行期报错;
-
一些逻辑错误,包括不允许的null属性(常常由于忘记设置实体类的属性),更新实体时引发的数据逻辑状态不一致。
总之,单元测试需要根据被测试类的实际情况,编写最简单最有效的测试用例。本文旨在给出一种编写DAO组件单元测试的有效方法。
|