分享
 
 
 

对J2EE中的DAO组件编写单元测试

王朝java/jsp·作者佚名  2008-05-31
窄屏简体版  字體: |||超大  

单元测试作为保证软件质量及重构的基础,早已获得广大开发人员的认可。单元测试是一种细粒度的测试,越来越多的开发人员在提交功能模块时也同时提交相应的单元测试。对于大多数开发人员来讲,编写单元测试已经成为开发过程中必须的流程和最佳实践。

对普通的逻辑组件编写单元测试是一件容易的事情,由于逻辑组件通常只需要内存资源,因此,设置好输入输出即可编写有效的单元测试。对于稍微复杂一点的组件,例如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工程:

由于将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自动生成的,稍后我们还会讨论。下图是单元测试的执行顺序:

在编写测试类之前,我们首先准备了一个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<String> initSqls = new ArrayList<String>();

@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:

这是因为通过反射调用抛出的异常被代理类包装为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组件单元测试的有效方法。

(出处:http://www.knowsky.com/)

 
 
 
免责声明:本文为网络用户发布,其观点仅代表作者个人观点,与本站无关,本站仅提供信息存储服务。文中陈述内容未经本站证实,其真实性、完整性、及时性本站不作任何保证或承诺,请读者仅作参考,并请自行核实相关内容。
2023年上半年GDP全球前十五强
 百态   2023-10-24
美众议院议长启动对拜登的弹劾调查
 百态   2023-09-13
上海、济南、武汉等多地出现不明坠落物
 探索   2023-09-06
印度或要将国名改为“巴拉特”
 百态   2023-09-06
男子为女友送行,买票不登机被捕
 百态   2023-08-20
手机地震预警功能怎么开?
 干货   2023-08-06
女子4年卖2套房花700多万做美容:不但没变美脸,面部还出现变形
 百态   2023-08-04
住户一楼被水淹 还冲来8头猪
 百态   2023-07-31
女子体内爬出大量瓜子状活虫
 百态   2023-07-25
地球连续35年收到神秘规律性信号,网友:不要回答!
 探索   2023-07-21
全球镓价格本周大涨27%
 探索   2023-07-09
钱都流向了那些不缺钱的人,苦都留给了能吃苦的人
 探索   2023-07-02
倩女手游刀客魅者强控制(强混乱强眩晕强睡眠)和对应控制抗性的关系
 百态   2020-08-20
美国5月9日最新疫情:美国确诊人数突破131万
 百态   2020-05-09
荷兰政府宣布将集体辞职
 干货   2020-04-30
倩女幽魂手游师徒任务情义春秋猜成语答案逍遥观:鹏程万里
 干货   2019-11-12
倩女幽魂手游师徒任务情义春秋猜成语答案神机营:射石饮羽
 干货   2019-11-12
倩女幽魂手游师徒任务情义春秋猜成语答案昆仑山:拔刀相助
 干货   2019-11-12
倩女幽魂手游师徒任务情义春秋猜成语答案天工阁:鬼斧神工
 干货   2019-11-12
倩女幽魂手游师徒任务情义春秋猜成语答案丝路古道:单枪匹马
 干货   2019-11-12
倩女幽魂手游师徒任务情义春秋猜成语答案镇郊荒野:与虎谋皮
 干货   2019-11-12
倩女幽魂手游师徒任务情义春秋猜成语答案镇郊荒野:李代桃僵
 干货   2019-11-12
倩女幽魂手游师徒任务情义春秋猜成语答案镇郊荒野:指鹿为马
 干货   2019-11-12
倩女幽魂手游师徒任务情义春秋猜成语答案金陵:小鸟依人
 干货   2019-11-12
倩女幽魂手游师徒任务情义春秋猜成语答案金陵:千金买邻
 干货   2019-11-12
 
推荐阅读
 
 
 
>>返回首頁<<
 
靜靜地坐在廢墟上,四周的荒凉一望無際,忽然覺得,淒涼也很美
© 2005- 王朝網路 版權所有