top
Loading...
對J2EE中的DAO組件編寫單元測試
單元測試作為保證軟件質量及重構的基礎,早已獲得廣大開發人員的認可。單元測試是一種細粒度的測試,越來越多的開發人員在提交功能模塊時也同時提交相應的單元測試。對于大多數開發人員來講,編寫單元測試已經成為開發過程中必須的流程和最佳實踐。

對普通的邏輯組件編寫單元測試是一件容易的事情,由于邏輯組件通常只需要內存資源,因此,設置好輸入輸出即可編寫有效的單元測試。對于稍微復雜一點的組件,例如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工程:
對DAO編寫單元測試 圖-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自動生成的,稍后我們還會討論。下圖是單元測試的執行順序:
對DAO編寫單元測試 圖-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 + "Exception: " + 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:

對DAO編寫單元測試 圖-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腳本片段如下:












export="false"
drop="true"
create="true"
delimiter=";"
outputfilename="schema.sql"
destdir="${src.dir}"
/>

完整的Ant腳本以及Hibernate配置文件請參考項目工程源代碼。

利用HSQLDB,我們已經成功地簡化了對DAO組件進行單元測試。我發現這種方式能夠找出許多常見的bug:

  • HQL語句的語法錯誤,包括SQL關鍵字和實體類屬性的錯誤拼寫,反復運行單元測試就可以不斷地修復許多這類錯誤,而不需要等到通過Web頁面請求而調用DAO時才發現問題;
  • 傳入了不一致或者順序錯誤的HQL參數數組,導致Hibernate在運行期報錯;
  • 一些邏輯錯誤,包括不允許的null屬性(常常由于忘記設置實體類的屬性),更新實體時引發的數據邏輯狀態不一致。

總之,單元測試需要根據被測試類的實際情況,編寫最簡單最有效的測試用例。本文旨在給出一種編寫DAO組件單元測試的有效方法。

查閱關于 DAO 的全部文檔

作者:http://www.zhujiangroad.com
來源:http://www.zhujiangroad.com
北斗有巢氏 有巢氏北斗