時(shí)間:2007-08-20 作者:廖雪峰 瀏覽次數(shù): 620 本文關(guān)鍵字:單元測(cè)試,DAO,JUnit |
|
單元測(cè)試作為保證軟件質(zhì)量及重構(gòu)的基礎(chǔ),早已獲得廣大開發(fā)人員的認(rèn)可。單元測(cè)試是一種細(xì)粒度的測(cè)試,越來越多的開發(fā)人員在提交功能模塊時(shí)也同時(shí)提交相應(yīng)的單元測(cè)試。對(duì)于大多數(shù)開發(fā)人員來講,編寫單元測(cè)試已經(jīng)成為開發(fā)過程中必須的流程和最佳實(shí)踐。
對(duì)普通的邏輯組件編寫單元測(cè)試是一件容易的事情,由于邏輯組件通常只需要內(nèi)存資源,因此,設(shè)置好輸入輸出即可編寫有效的單元測(cè)試。對(duì)于稍微復(fù)雜一點(diǎn)的組件,例如Servlet,我們可以自行編寫模擬對(duì)象,以便模擬HttpRequest和HttpResponse等對(duì)象,或者,使用EasyMock之類的動(dòng)態(tài)模擬庫,可以對(duì)任意接口實(shí)現(xiàn)相應(yīng)的模擬對(duì)象,從而對(duì)依賴接口的組件進(jìn)行有效的單元測(cè)試。
在J2EE開發(fā)中,對(duì)DAO組件編寫單元測(cè)試往往是一件非常復(fù)雜的任務(wù)。和其他組件不通,DAO組件通常依賴于底層數(shù)據(jù)庫,以及JDBC接口或者某個(gè)ORM框架(如Hibernate),對(duì)DAO組件的測(cè)試往往還需引入事務(wù),這更增加了編寫單元測(cè)試的復(fù)雜性。雖然使用EasyMock也可以模擬出任意的JDBC接口對(duì)象,或者ORM框架的主要接口,但其復(fù)雜性往往非常高,需要編寫大量的模擬代碼,且代碼復(fù)用度很低,甚至不如直接在真實(shí)的數(shù)據(jù)庫環(huán)境下測(cè)試。不過,使用真實(shí)數(shù)據(jù)庫環(huán)境也有一個(gè)明顯的弊端,我們需要準(zhǔn)備數(shù)據(jù)庫環(huán)境,準(zhǔn)備初始數(shù)據(jù),并且每次運(yùn)行單元測(cè)試后,其數(shù)據(jù)庫現(xiàn)有的數(shù)據(jù)將直接影響到下一次測(cè)試,難以實(shí)現(xiàn)“即時(shí)運(yùn)行,反復(fù)運(yùn)行”單元測(cè)試的良好實(shí)踐。
本文針對(duì)DAO組件給出一種較為合適的單元測(cè)試的編寫策略。在JavaEE開發(fā)網(wǎng)(http://www.javaeedev.com)的開發(fā)過程中,為了對(duì)DAO組件進(jìn)行有效的單元測(cè)試,我們采用HSQLDB這一小巧的純Java數(shù)據(jù)庫作為測(cè)試時(shí)期的數(shù)據(jù)庫環(huán)境,配合Ant,實(shí)現(xiàn)了自動(dòng)生成數(shù)據(jù)庫腳本,測(cè)試前自動(dòng)初始化數(shù)據(jù)庫,極大地簡(jiǎn)化了DAO組件的單元測(cè)試的編寫。
在Java領(lǐng)域,JUnit作為第一個(gè)單元測(cè)試框架已經(jīng)獲得了最廣泛的應(yīng)用,無可爭(zhēng)議地成為Java領(lǐng)域單元測(cè)試的標(biāo)準(zhǔn)框架。本文以最新的JUnit 4版本為例,演示如何創(chuàng)建對(duì)DAO組件的單元測(cè)試用例。
JavaEEdev的持久層使用Hibernate 3.2,底層數(shù)據(jù)庫為MySQL。為了演示如何對(duì)DAO進(jìn)行單元測(cè)試,我們將其簡(jiǎn)化為一個(gè)DAOTest工程:
由于將Hibernate的Transaction綁定在Thread上,因此,HibernateUtil類負(fù)責(zé)初始化SessionFactory以及獲取當(dāng)前的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í)體類User使用JPA注解,代表一個(gè)用戶:
@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; // 電子郵件驗(yàn)證碼
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í)體類PasswordTicket代表一個(gè)重置口令的請(qǐng)求:
@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接口定義了對(duì)用戶的相關(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í)現(xiàn)類:
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í)際的客戶端調(diào)用DAO組件時(shí),還必須加入事務(wù)代碼:
Transaction tx = HibernateUtil.getCurrentSession().beginTransaction();
try {
dao.xxx();
tx.commit();
}
catch(Exception e) {
tx.rollback();
throw e;
}
下面,我們開始對(duì)DAO組件編寫單元測(cè)試。前面提到了HSQLDB這一小巧的純Java數(shù)據(jù)庫。HSQLDB除了提供完整的JDBC驅(qū)動(dòng)以及事務(wù)支持外,HSQLDB還提供了進(jìn)程外模式(與普通數(shù)據(jù)庫類似)和進(jìn)程內(nèi)模式(In-Process),以及文件和內(nèi)存兩種存儲(chǔ)模式。我們將HSQLDB設(shè)定為進(jìn)程內(nèi)模式及僅使用內(nèi)存存儲(chǔ),這樣,在運(yùn)行JUnit測(cè)試時(shí),可以直接在測(cè)試代碼中啟動(dòng)HSQLDB。測(cè)試完畢后,由于測(cè)試數(shù)據(jù)并沒有保存在文件上,因此,不必清理數(shù)據(jù)庫。
此外,為了執(zhí)行批量測(cè)試,在每個(gè)獨(dú)立的DAO單元測(cè)試運(yùn)行前,我們都執(zhí)行一個(gè)初始化腳本,重新建立所有的表。該初始化腳本是通過HibernateTool自動(dòng)生成的,稍后我們還會(huì)討論。下圖是單元測(cè)試的執(zhí)行順序:
在編寫測(cè)試類之前,我們首先準(zhǔn)備了一個(gè)TransactionCallback抽象類,該類通過Template模式將DAO調(diào)用代碼通過事務(wù)包裝起來:
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)代理只能對(duì)接口代理,因此,要求DAO組件必須實(shí)現(xiàn)接口。如果只有具體的實(shí)現(xiàn)類,則只能考慮CGLIB之類的第三方庫,在此我們不作更多討論。
下面我們需要編寫DatabaseFixture,負(fù)責(zé)啟動(dòng)HSQLDB數(shù)據(jù)庫,并在@Before方法中初始化數(shù)據(jù)庫表。該DatabaseFixture可以在所有的DAO組件的單元測(cè)試類中復(fù)用:
public class DatabaseFixture {
private static Server server = null; // 持有HSQLDB的實(shí)例
private static final String DATABASE_NAME = "javaeedev"; // 數(shù)據(jù)庫名稱
private static final String SCHEMA_FILE = "schema.sql"; // 數(shù)據(jù)庫初始化腳本
private static final List<String> initSqls = new ArrayList<String>();@BeforeClass // 啟動(dòng)HSQLDB數(shù)據(jù)庫
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),因?yàn)镠SQLDB不支持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 // 執(zhí)行初始化腳本
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對(duì)象包裝為在事務(wù)范圍內(nèi)執(zhí)行的代理對(duì)象,即對(duì)于一個(gè)普通的DAO對(duì)象的方法調(diào)用前后,自動(dòng)地開啟事務(wù)并根據(jù)異常情況提交或回滾事務(wù)。
下面是UserDaoImpl的單元測(cè)試類:
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對(duì)象,而proxy則是將userDao進(jìn)行了事務(wù)封裝的對(duì)象。
由于UserDaoImplTest從DatabaseFixture繼承,因此,@Before方法在每個(gè)@Test方法調(diào)用前自動(dòng)調(diào)用,這樣,每個(gè)@Test方法執(zhí)行前,數(shù)據(jù)庫都是一個(gè)經(jīng)過初始化的“干凈”的表。
對(duì)于普通的測(cè)試,如UserDao.queryUser()方法,直接調(diào)用proxy.queryUser()即可在事務(wù)內(nèi)執(zhí)行查詢,獲得返回結(jié)果。
對(duì)于異常測(cè)試,例如期待一個(gè)ResourceNotFoundException,就不能直接調(diào)用proxy.queryUser()方法,否則,將得到一個(gè)UndeclaredThrowableException:
這是因?yàn)橥ㄟ^反射調(diào)用拋出的異常被代理類包裝為UndeclaredThrowableException,因此,對(duì)于異常測(cè)試,只能使用原始的userDao對(duì)象配合TransactionCallback實(shí)現(xiàn):
@Test(expected=ResourceNotFoundException.class)
public void testQueryNonExistUser() throws Exception {
new TransactionCallback() {
protected Object doInTransaction() throws Exception {
userDao.queryUser("nonexist");
return null;
}
}.execute();
}
到此為止,對(duì)DAO組件的單元測(cè)試已經(jīng)實(shí)現(xiàn)完畢。下一步,我們需要使用HibernateTool自動(dòng)生成數(shù)據(jù)庫腳本,免去維護(hù)SQL語句的麻煩。相關(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配置文件請(qǐng)參考項(xiàng)目工程源代碼。
利用HSQLDB,我們已經(jīng)成功地簡(jiǎn)化了對(duì)DAO組件進(jìn)行單元測(cè)試。我發(fā)現(xiàn)這種方式能夠找出許多常見的bug:
總之,單元測(cè)試需要根據(jù)被測(cè)試類的實(shí)際情況,編寫最簡(jiǎn)單最有效的測(cè)試用例。本文旨在給出一種編寫DAO組件單元測(cè)試的有效方法。
聯(lián)系客服