追求代码质量 – 测试Struts遗留的应用措施
副标题#e#
基于 Java™ 的Web开拓规模最近呈现了富厚的竞争性技能。启动新 项目标开拓人员可以在很多差异的框架之间举办选择,包罗 JavaServer Faces 、Tapestry、Shale、Grails 和 Seam (只罗列浩瀚机智的名称中的几个)。很 快,我们就可以通过 JRuby 框架在 Java 编程中利用 Ruby on Rails 了!
但就在不远的已往,只有一个 Java Web 开拓框架卓然而立。Struts 是第一 个在 Java 世界掀刮风暴的框架,并且多年以来,仿佛是假如一个项目不消 Struts 构建就没有前途一样。没有 Struts 履历的 Java 开拓人员很稀少,也 很不幸,就像本日的开拓人员没有传闻过 Ruby on Rails 一样。
纵然 Struts 正逐步地从舞台中央退去(本来的根基框架,此刻叫做 Struts 1,好像 正在退出 Web 框架的汗青舞台),但它的遗产仍然存在,既以 Shale 的形式存 在,又以运行活着界各地的成千上万的遗留应用措施的形式存在。因为很多企业 甘愿测试和维护这些应用措施而不肯意费钱从头编写它们,所以领略 Struts 应 用措施的一些缺陷,以及如何环绕它们举办重构,是个好主意。
这个月 ,我要把以质量为焦点的要领用于 Struts 应用措施的测试场景。团结现实,这 个场景环绕着最普遍的 Struts 结构:深受喜爱的 Action 类。
1、2、3 ,动作!
Struts 的革新之一就是把 Web 开拓从 Servlet 移进了 Action 类。这些类包括业务逻辑,以 JavaBean 的形式(凡是叫做 ActionForm )把数据传送到 JSP。然后 JSP 处理惩罚应用措施视图。Struts 到 MVC 的要领非 常容易把握,以至于很多开拓团队莽撞地闯进去,而很少思量与 Action 相关的 恒久设计和维护问题。
测试和巨大性
我已经发明,在开拓人员的测试和代码的巨大性之间存在强烈的相关性:没 有个中一个的处所,凡是也没有另一个。高度巨大的编码难于测试,功效是很少 有人会真正为它编写测试。反过来,编写测试可以低落代码的巨大性。因为给复 杂代码编写测试更坚苦,并且因为会边走边测试,所以会发明本身朝着更简朴的 代码结构前进。假如代码太巨大,并且知道不得不测试它,您大概就会在测试之 前对巨大性举办重构。岂论如何对待,为不那么简朴的代码编写测试是没落代码 巨大性的好实践。
固然在谁人时候(已往的自由年华啊)大概没人想过,但 Struts Action 类 凡是成为巨大性的掩护所。像在老的 EJB 架构中声名狼籍的会话 Facade 一样 ,Action 类会成为特定业务进程的严格伪装,可能通过直接挪用 EJB,通过打 开数据库毗连,可能通过挪用其他高度依赖的工具。Action 类尚有输出耦合( 通过 java.servlet API 包中的工具,譬喻 HttpServletRequest 和 HttpServletResponse),从而极难把它们断绝出来测试。
断绝出来测试 Action 类的坚苦意味着它们可以很容易变得相当巨大 —— 出格是当它们酿成越来越深入地与遗留框架耦合的时候。此刻我们来看这个坚苦 在真实的遗留应用措施场景中浸染的环境。
测试挑战
纵然最简朴的 Struts Action 类也会是个测试挑战。譬喻,以清单 1 中的 execute() 要领为例;它看起来足够简朴,可以测试,可是真的么?
清单 1. 这个要领看起来容易测试……
public ActionForward execute(ActionMapping mapping, ActionForm aForm,
HttpServletRequest req, HttpServletResponse res) throws Exception {
try{
String newPassword = ((ChangePasswordForm) aForm).getNewPassword1();
String username = ((ChangePasswordForm)aForm).getUsername ();
IUser user = DataAccessUtils.getDaos().getUserDao ().findUserByUsername(username);
user.digestAndSetPassword(newPassword);
DataAccessUtils.getDaos().getUserDao().saveUser(user);
}catch(Throwable thr){
return findFailure(mapping, aForm, req, res);
}
return findSuccess(mapping, aForm, req, res);
}
图 1. Action 类的输出耦合
可是,就像在图 1 中可以看到的,在试图断绝 ChangePasswordAction 类并 检讨 execute() 要领时,该类给出了一些有代表性的挑战。为了有效地测试 execute() 要领,必需处理惩罚三层耦合。首先,到 Struts 自身的耦合;其次, Servlet API 代表一个障碍;最后,到业务工具包的耦合,进一步查抄业务工具 包,还会有数据会见层利用 Hibernate 和 Spring。
#p#副标题#e#
每种环境一个 mock?
纵然在我编写本文时,我还可以听到开拓人员的讥笑者 认为我的测试问题通 过明智地利用 mock 工具就能等闲办理。可以 用 mock 工具建设一级断绝,它 会形成更容易的测试;可是,我要说的是,把方针工具通过 mock 解除所需要的 支付级别,比起认可断绝测试坚苦所需要的支付,要多得多。在这种环境下,我 会回收在更高条理上的测试,这级测试有时叫做集成测试。
#p#分页标题#e#
对付更高的巨大性,请留意 清单 1 中的代码如何把 aForm 参数转换成 ChangePasswordForm 工具,它是 Struts ActionForm 范例。这些 JavaBeans 有一个 validate 要领,这个要领由 Struts 在挪用 Action 类的 execute() 要领之前挪用。
出错误太容易了
在清单 2 中,可以看到所有这个巨大性会在那边产生。ChangePasswordForm 的 validate() 要领的代码片断演示了担保两个属性(newPassword1 和 newPassword2)不为空并相相互等的简朴逻辑。可是,假如 Struts 发明 errors 荟萃(范例为 ActionErrors)包括一些 ActionError 工具,就会沿着 错误路径走,譬喻带着堕落动静从头显示 Web 页面。
清单 2. ChangePasswordForm 的验证逻辑
if((newPassword1 == null) || (newPassword1.length() < 1)) {
errors.add("newPassword1",
new ActionError ("error.changePassword.newPassword1Required"));
}
if((newPassword2 == null) || (newPassword2.length() < 1)) {
errors.add("newPassword2",
new ActionError ("error.changePassword.newPassword2Required"));
}
if((newPassword1 != null) && (newPassword2 != null)) {
if(!newPassword1.equals(newPassword2)) {
errors.add(ActionErrors.GLOBAL_ERROR,
new ActionError("error.changePassword.passwordsDontMatch"));
}
}
清单 1 和 清单 2 的代码不非凡也不特定于某个规模。它是无数应用措施中 都包括的简朴口令修改逻辑。假如正在测试 Struts 遗留应用措施,将不得不花 些时间处理惩罚口令逻辑,可是如何用可反复的方法测试它呢?
两个测试用例
在诡计为 清单 1(间接的是 清单 2)的代码编写测试之前,大概想确定实 际需要测试什么。在这个详细示例中,逻辑清楚地是为了利便用户口令的修改; 所以,该当编写至少两个条理的测试用例:
口令修改在数据正确时是否事情?
假如数据不正确,口令是不是不 修改?
这些测试不会太容易只是个假设。不只需要搪塞 Struts,还必需处理惩罚数据层 以及数据层与数据库暗含的耦合!在面临巨大性时,我的第一本能是寻求辅佐, 在这个示例中,是以 JUnit 的 StrutsTestCase 的形式。
来自 StrutsTestCase 的辅佐
StrutsTestCase 是一个 JUnit 扩展,专门针对 Struts 应用措施。这个框 架实际上模仿了一个 servlet 容器,这样就能虚拟地运行和测试 Struts 应用 措施,而不必在 Tomcat(举例)中运行它了。框架尚有一个利便的 MockStrutsTestCase 类,它扩展了 TestCase 并处理惩罚很多 Struts 设置方面( 譬喻装入 struts-config.xml 设置文件)。
可是,在您认为本身完全离开了 Struts 设置的疾苦之前,该当相识一些正 确设置 MockStrutsTestCase 的工作。也就是说,需要把它指向代表 Web 应用 措施的目次,然后指向须要的 web.xml 和 struts-config.xml 文件。默认环境 下,MockStrutsTestCase 扫描这些项目标类路径;可是,要把 MockStrutsTestCase 设置成在特定情况中事情,操纵很简朴,只需包围一些设 置并编写一些特定的设置代码即可。
返回口令验证示例,包括 ChangePasswordAction 类的项目有图 3 所示的目 录布局:
清单 3. 示例目次布局
root/
src/
conf/
java/
webapp/
images/
jsp/
WEB-INF/
test/
WEB-INF 目次包括 web.xml 和 struts-config.xml 文件,webapp 目次代表 Web 上下文情况。知道了这些,我就如清单 4 所示设置 MockStrutsTestCase:
清单 4. MockStrutsTestCase 的定制 fixture 代码
public void setUp() throws Exception {
try {
super.setUp();
this.setContextDirectory(new File("src/webapp/"));
this.setServletConfigFile("src/webapp/WEB-INF/web.xml");
this.setConfigFile(
this.getSession().getServletContext()
.getRealPath("WEB-INF/struts-config.xml"));
}catch (Exception e) {
fail("Unable to setup test");
}
}
其他测试方法
#p#分页标题#e#
在某些环境下,基于 Action 类中发明的对应逻辑,大概可以或许用基于 Web 的 测试框架(像 JWebUnit 或 Selenium)间接地 测试代码。利用这些框架从测试 配置的角度来说,确实增加了巨大性。譬喻,要利用 JWebUnit,必需把应用程 序陈设到一个运行着设置好的数据库的 servlet 容器。把 StrutsTestCase 和 DbUnit 协同利用,可以利便测试,不必 把 war 文件陈设到运行着的 servlet 容器。它还答允在不 思量应用措施的视图方面的环境下举办测试。
关于逻辑映射
正确地设置了 MockStrutsTestCase 的实例后,测试 Action 类就只包括一 点点逻辑映射。要挪用 Action 类,需要强制 StrutsTestCase 框架通过一个路 径间接地 挪用它,这是在 struts-config.xml 文件中界说的。
譬喻,要强制挪用 ChangePasswordAction 类,必需汇报框架利用 /changePasswordSubmit 路径。在清单 5 中可以看到这点,清单 5 中的代码片 段来自 struts-config.xml 文件,它把 ChangePasswordAction 类映射到 /changePasswordSubmit 路径:
清单 5. struts-config.xml 代码片断显示了行动类路径映射
<action path="/changePasswordSubmit"
type="com.acme.ccb.action.ChangePasswordAction"
name="changePasswordForm" scope="request"
input="/jsp/admin/changepassword.jsp">
<forward name="success" path="/viewUsers.do"
redirect="true" contextRelative="false" />
</action>
一旦某个用户点击了提交按钮(举例),Struts 就把来自 HTTP 请求的参数 值映射到 ActionForm,在这个示例中,是上面的 struts-config.xml 代码片断 中(在清单 5 中)界说的 ChangePasswordForm。要模仿这个行为,在测试用例 中必需有另一个逻辑映射 —— JSP 表单名称必需映射到值。在口令修改场景中 ,提交了四个参数:username、currentPassword、newPassword1 和 newPassword2( newPassword2 参数是大都 Web 页面为了校验新口令正确简直 认信息)。
乐成的测试用例!
请求路径和参数映射好之后,编写测试用例就成了操作 MockStrutsTestCase API 配置相关隘令值的问题,如清单 6 所示。在这个测试用例中,用户 Jane 的口令从 “admin” 改成了 “meme”。
清单 6. 一个验证口令修改乐成的简朴测试用例
public void testExecute() throws Exception{
setRequestPathInfo("/changePasswordSubmit");
addRequestParameter("username","jane");
addRequestParameter("currentPassword","admin");
addRequestParameter("newPassword1","meme");
addRequestParameter("newPassword2","meme");
actionPerform();
verifyForward("success");
}
setRequestPathInfo() 要领设置路径以映射到 Action 类, addRequestParameter() 要领把来自 JSP 文件的参数名称映射到值。譬喻,在 清单 6 中,username 参数映射到 “jane”。
还请留意清单 6 中的最后两行。actionPerform() 要领实际上让 Struts 去 挪用对应的 Action 类。假如这个要领没被挪用,什么也不会产生。最后挪用的 要领 verifyForward() 是在 MockStrutsTestCase 类中找到的一个雷同于断言 的要领,它验证正确的转发。在 Struts 中,这是一个 String,凡是映射到成 功或失败状态。(请留意,清单 5 中的 XML 界说了 “success” 转发。)
用 DbUnit 举办的可反复的乐成
这时,您大概但愿事情完成 —— 究竟已经编写了一个诡计验证口令修改的 测试。可是还缺乏更深的验证。确实,这个利便的框架挪用了 Struts,可是代 码依赖于数据库。假如但愿可以或许不止一次地运行这个测试,好比在构建进程中, 就需要让它可反复。
由于一些特定的假设,所以 清单 6 中的测试用例不是可反复的。首先,测 试用例假设在系统中已经 有一个名为 “jane” 的用户,它的口令是 “admin ”。其次,测试用例假设在某些永久存储 中口令 “admin” 被更新成 “meme ”。正如所写的那样,只要代码没有生成异常,ActionForm 乐成验证,Struts 就假定工作事情精采,测试用例也是一样。
此刻需要的是更深条理的验证 —— 在数据库条理。对付该当更新口令的测 试用例来说,抱负环境下该当在数据库上 执行查抄,确保哪里有一个新口令。 对付口令不应当修改的测试来说,需要举办验证,真正检讨没有修改 口令。最 后,要让这个测试套件可反复,最好是不要 对数据完整性做任何假设。
#p#分页标题#e#
DbUnit 是个专门利便把数据库放进测试状态中已知状态的 JUnit 扩展。使 用 XML 种子文件,可以把特定命据插入到测试用例可以依靠的数据库中。并且 ,利用 DbUnit API,可以容易地较量数据库的内容和 XML 文件的内容,从而提 供一个在应用措施代码之外 校验预期数据库功效的机制。
用 DbUnit 举办测试
要利用 DbUnit,需要两样对象:
通过普通 JDBC 的数据库毗连
一个文件,包括需要放到数据库中的数据
清单 7 是一个 DbUnit 种子文件,只界说了几样对象:首先,有一个叫做 user 的表和另一个叫做 user_role 的表。在 user 表中界说了一个新行,并映 射一些值到列(譬喻列 username 拥有值 “jane”)。在 user_role 中还界说 了一行。请留意这个数据库中的口令是通过 SHA 加密的。
清单 7. 用于测试表 user 和 user_role 的 DbUnit 种子文件
<?xml version='1.0' encoding='WINDOWS-1252'?>
<dataset>
<!-- user with password admin -->
<user username="jane"
password="d033e22ae348aeb5660fc2140aec35850c4da997"
name="Jane Admin"
date_created="2003-8-14 10:10:10"
email="[email protected]"/>
<user_role username="jane" rolename="ADMIN"/>
</dataset>
有了这个文件,就可以操作 DbUnit 插入数据、更新数据库来反应数据,甚 至删除数据。数据库修改逻辑包括在 DbUnit 的 DatabaseOperation 类中。在 这个示例中,只是通过 清单 4 中界说的 MockStrutsTestCase 范例的 setUp() 要领中的一些加强的 fixture 逻辑中的 CLEAN_INSERT 符号来担保清洁的数据 集。譬喻,在清单 8 中,界说了三个要领,别离操作 DbUnit API 把 dbunit- user-seed.xml 文件的内容插入数据库。
清单 8. 定制的 DbUnit fixture 逻辑
private void executeSetUpOperation() throws Exception{
final IDatabaseConnection connection = this.getConnection ();
try{
DatabaseOperation.CLEAN_INSERT.execute(connection, this.getDataSet());
}finally{
connection.close();
}
}
private IDataSet getDataSet() throws IOException, DataSetException {
return new FlatXmlDataSet(new File("test/conf/dbunit-user- seed.xml"));
}
private IDatabaseConnection getConnection() throws ClassNotFoundException, SQLException {
final Class driverClass = Class.forName ("org.gjt.mm.mysql.Driver");
final Connection jdbcConnection = DriverManager.
getConnection("jdbc:mysql://localhost/ccb01",
"9043", "43xli");
return new DatabaseConnection(jdbcConnection);
}
清单 8 中界说的 executeSetUpOperation() 要领将在前面的 清单 4 中定 义的 setUp() 要领中挪用。这个要领再挪用清单 8 中的另两个要领: getDataSet() 把 XML 转换成 DbUnit 的 IDataSet 范例,getConnection() 则 返回包装成 DbUnit 的 IDatabaseConnection 范例的数据库毗连。
更好的测试用例
设置好 DbUnit 后,剩下的就只有改造 清单 6 的测试用例,验证数据库中 的一切 OK。然后,添加验证其他问题场景的其余测试用例。
要确认数据库中的口令更新,可以利用 DbUnit 的查询 API,它辅佐较量数 据库的功效与静态界说的 XML 文件,譬喻清单 9 中界说的谁人。请留意这个文 件没有列出 user 表中的所有列 —— 实际上,它只列出了两个:username 和 password。
清单 9. 较量测试 XML 文件
<?xml version='1.0' encoding='WINDOWS-1252'?>
<dataset>
<user username="jane"
password="58117e24e4d0b8a958146c9eaa28336184f4d491"/>
</dataset>
DbUnit 的查询 API 足够机动,可以辅佐过滤掉没有意义的值,在这个示例 中就是 username 和 password 之外的值。同样,在清单 10 中, verifyPassword() 要领用 DbUnit 的 createQueryTable() 要领构建 ITable 范例,以与清单 9 中界说的 XML 举办较量:
清单 10. 利用 DbUnit 查询 API 的 verifyPassword 要领
private void verifyPassword(String fileName) throws Exception{
final IDataSet expectedDataSet = new FlatXmlDataSet(
new File(fileName));
final ITable defJoinData = this.getConnection().
createQueryTable("TestResult",
"select user.username, user.password " +
"from user where user.username=\"jane\"");
final ITable defTable = expectedDataSet.getTable("user");
Assertion.assertEquals(defJoinData, defTable);
}
#p#分页标题#e#
Assertion 范例是 DbUnit 界说的定制类,可以举办特定于数据库功效集的 特别断言。还请留意 verifyPassword() 接管一个文件路径,这意味着我可以定 义多个期望的数据集(一个用于修改的口令,一个用于沟通的口令)。
重复测试 Struts
综合起来,此刻有了一个可以完成以下事情的测试用例:
通过 DbUnit 填凑数据库
设置 Struts
间接地挪用 ChangePasswordAction 和 ChangePasswordForm 类
关联参数值
验证乐成转发
验证数据库内容
从清单 11 可以看出,ChangePasswordAction 测试用例只通过 testExecute 测试处理惩罚一个正常场景:
清单 11. ChangePasswordAction 测试用例
package test.com.acme.ccb.action;
import java.io.File;
import java.io.IOException;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import org.dbunit.Assertion;
import org.dbunit.database.DatabaseConnection;
import org.dbunit.database.IDatabaseConnection;
import org.dbunit.dataset.DataSetException;
import org.dbunit.dataset.IDataSet;
import org.dbunit.dataset.ITable;
import org.dbunit.dataset.xml.FlatXmlDataSet;
import org.dbunit.operation.DatabaseOperation;
import servletunit.struts.MockStrutsTestCase;
public class ChangePasswordActionTest extends MockStrutsTestCase {
public ChangePasswordActionTest(String arg0) {
super(arg0);
}
public void setUp() throws Exception {
try {
super.setUp();
this.executeSetUpOperation();
this.setContextDirectory(new File("src/webapp/"));
this.setServletConfigFile("src/webapp/WEB-INF/web.xml");
this.setConfigFile(
this.getSession().getServletContext()
.getRealPath("WEB-INF/struts-config.xml"));
} catch (Exception e) {
fail("Unable to setup test");
}
}
public void testExecute() throws Exception{
setRequestPathInfo("/changePasswordSubmit");
addRequestParameter("username","jane");
addRequestParameter("currentPassword","admin");
addRequestParameter("newPassword1","meme");
addRequestParameter("newPassword2","meme");
actionPerform();
verifyForward("success");
verifyPassword("test/conf/dbunit-expect-user.xml");
}
private void executeSetUpOperation() throws Exception{
final IDatabaseConnection connection = this.getConnection ();
try{
DatabaseOperation.CLEAN_INSERT.execute(connection, this.getDataSet());
}finally{
connection.close();
}
}
private IDataSet getDataSet() throws IOException, DataSetException {
return new FlatXmlDataSet(new File("test/conf/dbunit-user- seed.xml"));
}
private IDatabaseConnection getConnection() throws ClassNotFoundException, SQLException {
final Class driverClass = Class.forName ("org.gjt.mm.mysql.Driver");
final Connection jdbcConnection = DriverManager.
getConnection("jdbc:mysql://localhost/ccb01",
"9043", "43xli");
return new DatabaseConnection(jdbcConnection);
}
private void verifyPassword(String fileName) throws Exception{
final IDataSet expectedDataSet = new FlatXmlDataSet(
new File(fileName));
final ITable defJoinData = this.getConnection().
createQueryTable("TestResult",
"select user.username, user.password " +
"from user where user.username=\"jane\"");
final ITable defTable = expectedDataSet.getTable("user");
Assertion.assertEquals(defJoinData, defTable);
}
}
只多一个测试……
#p#分页标题#e#
请留意这个测试用例没有测试界线用例,譬喻:假如两个口令字段 (newPassword1 和 newPassword2())不匹配。谢天谢地,一旦配置好了,添加 另一个测试用例并不难。在清单 12 中,验证了假如两个值不匹配,就生成 ActionError,用户 “jane” 口令在数据库中的值保持稳定。
清单 12. 添加新测试
public void testExecuteWithErrors() throws Exception{
setRequestPathInfo("/changePasswordSubmit");
addRequestParameter("username","jane");
addRequestParameter("currentPassword","admin");
addRequestParameter("newPassword1","meme");
addRequestParameter("newPassword2","emem");
actionPerform();
verifyActionErrors(
new String[] {"error.changePassword.passwordsDontMatch"});
verifyPassword("test/conf/dbunit-expect-user-same.xml");
}
在清单 12 中,我验证了 清单 2 中的逻辑正确地捕获到了口令值不匹配的 环境。MockStrutsTestCase 类包括一个利便要领可以断言错误条件,这个要领 是 verifyActionErrors(),在这个要领中,错误 String 被通报进来举办验证 。还请留意,数据库被查抄,这次是按照另一个包括沟通值的文件(在这个示例 中,username “jane” 有一个未加密的 password “admin”)。
Struts 的集成测试
大都 Struts 应用措施不会 很快消失,所以重要的是知道如安在重写之前用 开拓人员测试构建必然条理的担保。这个月,我先容了在测试 Struts 遗留应用 措施时的一些挑战,并先容了如何用 StrutsTestCase 和 DbUnit 处理惩罚它们。
StrutsTestCase 只要设置正确就会处理惩罚 Struts 的事情,而 DbUnit 处理惩罚与 数据库有关的代码的事情。一起利用这两个框架,可以在 Struts 应用措施长进 行集成级此外测试,而不消通过更高条理的框架(譬喻 JWebUnit 或 Selenium )模仿欣赏器(也是一个值得回收的要领,可是生成的功效很是差异。)
Struts 应用措施对测试来说是 具有挑战性的,而且没有办理的要领。这个 坚苦是 Struts 框架被越发创新的框架所掩盖的原因之一,出格是那些办理了测 试容易问题的框架。另一方面,就像我在这里先容的,测试 Struts 是 大概的 —— 只是需要费些力气。