基於 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。
每種情況一個 mock?
即使在我編寫本文時,我還可以聽到開發人員的嘲笑者 認為我的測試問題通 過明智地使用 mock 對象就能輕易解決。可以 用 mock 對象創建一級隔離,它 會形成更容易的測試;但是,我要說的是,把目標對象通過 mock 排除所需要的 付出級別,比起承認隔離測試困難所需要的付出,要多得多。在這種情況下,我 會采用在更高層次上的測試,這級測試有時叫做集成測試。
對於更高的復雜性,請注意 清單 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");
}
}
其他測試方式
在某些情況下,基於 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 就假定事情工作良好,測試用例也是一樣。
現在需要的是更深層次的驗證 —— 在數據庫層次。對於應當更新口令的測 試用例來說,理想情況下應當在數據庫上 執行檢查,確保那裡有一個新口令。 對於口令不應當修改的測試來說,需要進行驗證,真正檢驗沒有修改 口令。最 後,要讓這個測試套件可重復,最好是不要 對數據完整性做任何假設。
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);
}
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);
}
}
只多一個測試……
請注意這個測試用例沒有測試邊界用例,例如:如果兩個口令字段 (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 是 可能的 —— 只是需要費些力氣。