| 時(shí)間:2007-08-20 作者:廖雪峰 瀏覽次數: 620 本文關(guān)鍵字:單元測試,DAO,JUnit |
|

單元測試作為保證軟件質(zhì)量及重構的基礎,早已獲得廣大開(kāi)發(fā)人員的認可。單元測試是一種細粒度的測試,越來(lái)越多的開(kāi)發(fā)人員在提交功能模塊時(shí)也同時(shí)提交相應的單元測試。對于大多數開(kāi)發(fā)人員來(lái)講,編寫(xiě)單元測試已經(jīng)成為開(kāi)發(fā)過(guò)程中必須的流程和最佳實(shí)踐。
對普通的邏輯組件編寫(xiě)單元測試是一件容易的事情,由于邏輯組件通常只需要內存資源,因此,設置好輸入輸出即可編寫(xiě)有效的單元測試。對于稍微復雜一點(diǎn)的組件,例如Servlet,我們可以自行編寫(xiě)模擬對象,以便模擬HttpRequest和HttpResponse等對象,或者,使用EasyMock之類(lèi)的動(dòng)態(tài)模擬庫,可以對任意接口實(shí)現相應的模擬對象,從而對依賴(lài)接口的組件進(jìn)行有效的單元測試。
在J2EE開(kāi)發(fā)中,對DAO組件編寫(xiě)單元測試往往是一件非常復雜的任務(wù)。和其他組件不通,DAO組件通常依賴(lài)于底層數據庫,以及JDBC接口或者某個(gè)ORM框架(如Hibernate),對DAO組件的測試往往還需引入事務(wù),這更增加了編寫(xiě)單元測試的復雜性。雖然使用EasyMock也可以模擬出任意的JDBC接口對象,或者ORM框架的主要接口,但其復雜性往往非常高,需要編寫(xiě)大量的模擬代碼,且代碼復用度很低,甚至不如直接在真實(shí)的數據庫環(huán)境下測試。不過(guò),使用真實(shí)數據庫環(huán)境也有一個(gè)明顯的弊端,我們需要準備數據庫環(huán)境,準備初始數據,并且每次運行單元測試后,其數據庫現有的數據將直接影響到下一次測試,難以實(shí)現“即時(shí)運行,反復運行”單元測試的良好實(shí)踐。
本文針對DAO組件給出一種較為合適的單元測試的編寫(xiě)策略。在JavaEE開(kāi)發(fā)網(wǎng)(http://www.javaeedev.com)的開(kāi)發(fā)過(guò)程中,為了對DAO組件進(jìn)行有效的單元測試,我們采用HSQLDB這一小巧的純Java數據庫作為測試時(shí)期的數據庫環(huán)境,配合Ant,實(shí)現了自動(dòng)生成數據庫腳本,測試前自動(dòng)初始化數據庫,極大地簡(jiǎn)化了DAO組件的單元測試的編寫(xiě)。
在Java領(lǐng)域,JUnit作為第一個(gè)單元測試框架已經(jīng)獲得了最廣泛的應用,無(wú)可爭議地成為Java領(lǐng)域單元測試的標準框架。本文以最新的JUnit 4版本為例,演示如何創(chuàng )建對DAO組件的單元測試用例。
JavaEEdev的持久層使用Hibernate 3.2,底層數據庫為MySQL。為了演示如何對DAO進(jìn)行單元測試,我們將其簡(jiǎn)化為一個(gè)DAOTest工程:

由于將Hibernate的Transaction綁定在Thread上,因此,HibernateUtil類(lèi)負責初始化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);
在此不再多述。
實(shí)體類(lèi)User使用JPA注解,代表一個(gè)用戶(hù):
@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; // 用戶(hù)名
private String password; // MD5口令
private boolean admin; // 是否是管理員
private String email; // 電子郵件
private int emailValidation; // 電子郵件驗證碼
private long createdDate; // 創(chuàng )建時(shí)間
private long lockDate; // 鎖定時(shí)間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();
}
}
實(shí)體類(lèi)PasswordTicket代表一個(gè)重置口令的請求:
@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接口定義了對用戶(hù)的相關(guān)操作:
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是其實(shí)現類(lèi):
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事務(wù)綁定在Thread上,因此,實(shí)際的客戶(hù)端調用DAO組件時(shí),還必須加入事務(wù)代碼:
Transaction tx = HibernateUtil.getCurrentSession().beginTransaction();
try {
dao.xxx();
tx.commit();
}
catch(Exception e) {
tx.rollback();
throw e;
}
下面,我們開(kāi)始對DAO組件編寫(xiě)單元測試。前面提到了HSQLDB這一小巧的純Java數據庫。HSQLDB除了提供完整的JDBC驅動(dòng)以及事務(wù)支持外,HSQLDB還提供了進(jìn)程外模式(與普通數據庫類(lèi)似)和進(jìn)程內模式(In-Process),以及文件和內存兩種存儲模式。我們將HSQLDB設定為進(jìn)程內模式及僅使用內存存儲,這樣,在運行JUnit測試時(shí),可以直接在測試代碼中啟動(dòng)HSQLDB。測試完畢后,由于測試數據并沒(méi)有保存在文件上,因此,不必清理數據庫。
此外,為了執行批量測試,在每個(gè)獨立的DAO單元測試運行前,我們都執行一個(gè)初始化腳本,重新建立所有的表。該初始化腳本是通過(guò)HibernateTool自動(dòng)生成的,稍后我們還會(huì )討論。下圖是單元測試的執行順序:

在編寫(xiě)測試類(lèi)之前,我們首先準備了一個(gè)TransactionCallback抽象類(lèi),該類(lèi)通過(guò)Template模式將DAO調用代碼通過(guò)事務(wù)包裝起來(lái):
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提供的動(dòng)態(tài)代理。由于JDK的動(dòng)態(tài)代理只能對接口代理,因此,要求DAO組件必須實(shí)現接口。如果只有具體的實(shí)現類(lèi),則只能考慮CGLIB之類(lèi)的第三方庫,在此我們不作更多討論。
下面我們需要編寫(xiě)DatabaseFixture,負責啟動(dòng)HSQLDB數據庫,并在@Before方法中初始化數據庫表。該DatabaseFixture可以在所有的DAO組件的單元測試類(lèi)中復用:
public class DatabaseFixture {
private static Server server = null; // 持有HSQLDB的實(shí)例
private static final String DATABASE_NAME = "javaeedev"; // 數據庫名稱(chēng)
private static final String SCHEMA_FILE = "schema.sql"; // 數據庫初始化腳本
private static final List<String> initSqls = new ArrayList<String>();@BeforeClass // 啟動(dòng)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類(lèi)型的字段改為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()方法,它將一個(gè)普通的DAO對象包裝為在事務(wù)范圍內執行的代理對象,即對于一個(gè)普通的DAO對象的方法調用前后,自動(dòng)地開(kāi)啟事務(wù)并根據異常情況提交或回滾事務(wù)。
下面是UserDaoImpl的單元測試類(lèi):
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持有兩個(gè)UserDao引用,userDao是普通的UserDaoImpl對象,而proxy則是將userDao進(jìn)行了事務(wù)封裝的對象。
由于UserDaoImplTest從DatabaseFixture繼承,因此,@Before方法在每個(gè)@Test方法調用前自動(dòng)調用,這樣,每個(gè)@Test方法執行前,數據庫都是一個(gè)經(jīng)過(guò)初始化的“干凈”的表。
對于普通的測試,如UserDao.queryUser()方法,直接調用proxy.queryUser()即可在事務(wù)內執行查詢(xún),獲得返回結果。
對于異常測試,例如期待一個(gè)ResourceNotFoundException,就不能直接調用proxy.queryUser()方法,否則,將得到一個(gè)UndeclaredThrowableException:

這是因為通過(guò)反射調用拋出的異常被代理類(lèi)包裝為UndeclaredThrowableException,因此,對于異常測試,只能使用原始的userDao對象配合TransactionCallback實(shí)現:
@Test(expected=ResourceNotFoundException.class)
public void testQueryNonExistUser() throws Exception {
new TransactionCallback() {
protected Object doInTransaction() throws Exception {
userDao.queryUser("nonexist");
return null;
}
}.execute();
}
到此為止,對DAO組件的單元測試已經(jīng)實(shí)現完畢。下一步,我們需要使用HibernateTool自動(dòng)生成數據庫腳本,免去維護SQL語(yǔ)句的麻煩。相關(guān)的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,我們已經(jīng)成功地簡(jiǎn)化了對DAO組件進(jìn)行單元測試。我發(fā)現這種方式能夠找出許多常見(jiàn)的bug:
總之,單元測試需要根據被測試類(lèi)的實(shí)際情況,編寫(xiě)最簡(jiǎn)單最有效的測試用例。本文旨在給出一種編寫(xiě)DAO組件單元測試的有效方法。

聯(lián)系客服