# 追求代碼質量: 對 Ajax 應用程序進行單元測試
_使用 GWT 更輕松地測試異步應用程序_
您可能從編寫 Ajax 應用程序中獲得了極大樂趣,但是對它們執行單元測試卻著實讓人頭痛。 在本文中,Andrew Glover 著手解決 Ajax 的弱點(其中之一),即應對異步 Web 應用程序執行單元測試的固有挑戰。 幸運的是,他發現在 Google Web Toolkit 的幫助下,解決這個特殊的代碼質量問題要比預想的容易。
_Ajax_ 在近期無疑是 Web 開發界最時髦的字眼之一 —— 與 Ajax 相關的工具、框架、書籍以及 Web 站點的劇增就是該技術流行的最好證明。 此外,Ajax 應用程序也相當靈巧,不是嗎?不過,像任何一個開發過 Ajax 應用程序的人證實的一樣, 對 Ajax 執行測試真的很不方便。事實上,Ajax 的出現已經從根本上使得許多測試框架和工具_失效_,因為它們并沒有針對異步 Web 應用程序測試進行設計!
有趣的是,某個支持 Ajax 的框架的開發人員注意到了這個限制, 并為此做了一些非常新穎的設計:內置的可測試性。除此之外,由于該框架簡化了使用 Java? 代碼(而不是 JavaScript)創建 Ajax 應用程序,它的起點甚高,并且充分利用了 Java 平臺上無可置疑的標準測試框架:JUnit。
我所論及的框架當然是非常流行的 Google Web Toolkit,也就是 GWT。在本文中,我將向您展示 GWT 如何實際地利用 Java 兼容性, 使 Ajax 應用程序的每個部分都能像與之對應的同步應用程序一樣進行測試。
## 改進代碼質量
別錯過 Andrew Glover 的 [代碼質量討論論壇](http://www.ibm.com/developerworks/forums/dw_forum.jsp?S_CMP=cn-a-j&S_TACT=105AGX52&forum=812 cat=10),里面有關于代碼語法、測試框架以及如何編寫專注于質量的代碼的幫助。
## JUnit 和 GWTTestCase
因為與 GWT 有關的 Ajax 應用程序采用 Java 代碼編寫, 所以非常適合開發人員使用 JUnit 進行測試。 事實上,GWT 開發小組還為此創建了一個幫助器類 `GWTTestCase`,擴展自 JUnit 的 3.8.1 `TestCase`。 該基類添加了一些功能,可測試 GWT 代碼并處理某些基礎實現從而啟動并運行 GWT 組件。
## Google Web Toolkit
Google Web Toolkit 在 Java Web 開發社區的發布聲勢浩大, 同時也獲得了與之相稱的巨大轟動。GWT 為利用 Java 代碼進行設計、構建和部署支持 Ajax 的 Web 應用程序提供了一種新穎的方式。Java Web 開發人員不再需要學習 JavaScript 并花費數個小時解決特定于瀏覽器的問題,他們可以直接進行與 Ajax 有關的富含信息的動態 Web 應用程序設計。
需要提醒的是:`GWTTestCase` 并非用來測試 與 UI 相關的代碼 —— 它是為了便于測試那些由 UI 交互_觸發_ 的異步問題。對 `GWTTestCase` 用途的誤解使許多剛接觸 GWT 的開發人員備受挫折, 因為他們期望能夠用它方便地模擬用戶界面,但最終發現這是徒勞的。
Ajax 組件有兩個基本組成: 體驗和功能,這些都被設計成異步方式。 圖 1 演示了一個模擬 Web 表單的簡單 Ajax 組件。 由于該組件支持 Ajax,表單的提交是異步執行的(即:無需重新載入與傳統表單提交關聯的頁面)。
##### 圖 1\. 一個支持 Ajax 的簡單 Web 表單

輸入一個有效單詞,單擊組件的 **Submit** 按鈕,將向服務器發送消息請求該單詞的定義。 該定義通過回調異步返回,相應地插入到 Web 頁面,如圖 2 所示:
##### 圖 2\. 單擊 Submit 按鈕后顯示響應

### 功能性和集成測試
圖 2 所示的交互測試可用于多個不同場景, 但是其中兩種場景最為常見。從功能性觀點考慮,您或許希望編寫一個測試:填入表單值,單擊 **Submit** 按鈕,然后驗證表單是否顯示定義。 另外一個選擇是集成測試,使您能夠驗證客戶端代碼的異步功能。GWT 的 `GWTTestCase` 正是 被設計用來執行此類測試。
需要牢記的是:在 `GWTTestCase` 測試用例環境下不可以進行用戶界面測試。 在設計和構建 GWT 應用程序時,您必須清楚_不要依賴用戶界面_ 測試代碼。 這種思路需要把交互代碼從業務邏輯中分離出來, 正如您已經了解的,這是最佳的入門實踐!
舉例而言,重新查看圖 1 和圖 2 所示的 Ajax 應用程序。 該應用程序由四個邏輯部分構成:`TextBox` 用于輸入目標單詞,`Button` 用于執行單擊,還有兩個 `Label`(一個用于 `TextBox`,另一個顯示定義)。 實際 GWT 模塊的初始方法如清單 1 所示, 但是您該如何測試這段代碼呢?
##### 清單 1\. 一個有效的 GWT 應用程序,但是如何測試它?
```
public class DefaultModule implements EntryPoint {
public void onModuleLoad() {
Button button = new Button("Submit");
TextBox box = new TextBox();
Label output = new Label();
Label label = new Label("Word: ");
HorizontalPanel inputPanel = new HorizontalPanel();
inputPanel.setStyleName("input-panel");
inputPanel.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE);
inputPanel.add(label);
inputPanel.add(box);
button.addClickListener(new ClickListener() {
public void onclick(Widget sender) {
String word = box.getText();
WordServiceAsync instance = WordService.Util.getInstance();
try {
instance.getDefinition(word, new AsyncCallback() {
public void onFailure(Throwable error) {
Window.alert("Error occurred:" + error.toString());
}
public void onSuccess(Object retValue) {
output.setText(retValue.toString());
}
});
}catch(Exception e) {
e.printStackTrace();
}
}
});
inputPanel.add(button);
inputPanel.setCellVerticalAlignment(button,
HasVerticalAlignment.ALIGN_BOTTOM);
RootPanel.get("slot1").add(inputPanel);
RootPanel.get("slot2").add(output);
}
}
```
清單 1 的代碼在運行時發生了嚴重的錯誤:它無法_按照_ JUnit 和 GWT 的 `GWTTestCase` 進行測試。 事實上,如果我試著為這段代碼編寫測試,從技術方面來說它可以運行,但是無法按照邏輯工作。考慮一下:您如何對這段代碼進行驗證?惟一可用于測試的 `public` 方法返回的是 `void`, 那么,您怎么能夠驗證其功能的正確性呢?
如果我想以白盒方式驗證這段代碼,就必須分離業務邏輯和特定于用戶界面的代碼,這就需要進行重構。這本質上意味著把清單 1 中的代碼分離到一個便于測試的獨立方法中。 但是這并非聽上去那么簡單。很明顯組件掛鉤是通過 `onModuleLoad()` 方法實現, 但是如果我想強制其行為,_可能_ 必須操縱某些用戶界面(UI)組件。
* * *
## 分解業務邏輯和 UI 代碼
第一步是為每個 UI 組件創建訪問器方法, 如清單 2 所示。按照該方式,我可以在需要時獲取它們。
##### 清單 2\. 向 UI 組件添加訪問器方法使其可用
```
public class WordModule implements EntryPoint {
private Label label;
private Button button;
private TextBox textBox;
private Label outputLabel;
protected Button getButton() {
if (this.button == null) {
this.button = new Button("Submit");
}
return this.button;
}
protected Label getLabel() {
if (this.label == null) {
this.label = new Label("Word: ");
}
return this.label;
}
protected Label getOutputLabel() {
if (this.outputLabel == null) {
this.outputLabel = new Label();
}
return this.outputLabel;
}
protected TextBox getTextBox() {
if (this.textBox == null) {
this.textBox = new TextBox();
this.textBox.setVisibleLength(20);
}
return this.textBox;
}
}
```
現在我實現了對所有與 UI 相關的組件的編程式訪問(假設所有需要進行訪問的類都在同一個包內)。以后我可能需要使用其中一種訪問進行驗證。我現在希望_限制_ 使用訪問器,如我已經指出的,這是因為 GWT 并非設計用來進行交互測試。所以,我不是真的要試圖測試某個按鈕實例是否被單擊, 而是要測試 GWT 模塊是否會對給定的單詞調用服務器端代碼,并且服務器端會返回一個有效定義。方法為將 `onModuleLoad()` 方法的定義獲取邏輯推入(不是故意用雙關語!)一個可測試方法中,如清單 3 所示:
##### 清單 3\. 重構的 onModuleLoad 方法委托給更易于測試的方法
```
public void onModuleLoad() {
HorizontalPanel inputPanel = new HorizontalPanel();
inputPanel.setStyleName("disco-input-panel");
inputPanel.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE);
Label lbl = this.getLabel();
inputPanel.add(lbl);
TextBox txBox = this.getTextBox();
inputPanel.add(txBox);
Button btn = this.getButton();
btn.addClickListener(new ClickListener() {
public void onClick(Widget sender) {
submitWord();
}
});
inputPanel.add(btn);
inputPanel.setCellVerticalAlignment(btn,
HasVerticalAlignment.ALIGN_BOTTOM);
if(RootPanel.get("input-container") != null) {
RootPanel.get("input-container").add(inputPanel);
}
Label output = this.getOutputLabel();
if(RootPanel.get("output-container") != null) {
RootPanel.get("output-container").add(output);
}
}
```
如清單 3 所示,我已經把 `onModuleLoad()` 的定義獲取邏輯委托給 `submitWord` 方法, 如清單 4 定義:
##### 清單 4\. 我的 Ajax 應用程序的實質!
```
protected void submitWord() {
String word = this.getTextBox().getText().trim();
this.getDefinition(word);
}
protected void getDefinition(String word) {
WordServiceAsync instance = WordService.Util.getInstance();
try {
instance.getDefinition(word, new AsyncCallback() {
public void onFailure(Throwable error) {
Window.alert("Error occurred:" + error.toString());
}
public void onSuccess(Object retValue) {
getOutputLabel().setText(retValue.toString());
}
});
}catch(Exception e) {
e.printStackTrace();
}
}
```
`submitWord()` 方法又委托給 `getDefinition()` 方法,我可以用 JUnit 測試它。`getDefinition()` 方法從邏輯上獨立于特定于 UI 的代碼(對于絕大部分而言),并且可以在沒有單擊按鈕的情況下得到調用。另一方面, 與異步應用程序有關的狀態問題和 Java 語言的語義規則也規定了我不能在測試中_完全_ 避免與 UI 相關的交互。仔細查看清單 4 中的代碼,您能夠發現激活異步回調的 `getDefinition()` 方法操縱了某些 UI 組件 —— 一個錯誤警告窗口以及一個 `Label` 實例。
我還可以通過獲得輸出 `Label` 實例的句柄,斷言其文本是否是給定單詞的定義,從而驗證應用程序的功能。 在用 `GWTTestCase` 測試時,最好_不要_ 嘗試手工 強制改變組件狀態,而應該讓 GWT 完成這些工作。舉例而言,在清單 4 中,我想驗證對某個給定單詞返回了其 正確定義并放入一個輸出 `Label` 中。無需操作 UI 組件來設置這個單詞;我只要直接調用 `getDefinition` 方法,然后斷言 `Label` 具有對應定義。
既然我已經編寫好了計劃進行測試的 GWT 應用程序,我需要實際編寫測試,這意味著設置 GWT 的 `GWTTestCase`。
* * *
## 設置 GWTTestCase
若想從 `GWTTestCase` 的測試魔力中獲益,需要遵守一些規則。 幸運的是,規則很簡單:
* 所有用于實現測試的類和待測 GWT 模塊必須位于同一個包內。
* 運行測試時,您必須至少傳遞一個 VM 參數,指明在哪種 GWT 模式(托管或 Web)下運行測試。
* 您必須實現 `getModuleName()` 方法,它返回一個 `String`,表示您的 XML 模塊文件。
最后,因為與服務器端實體通信的 Ajax 應用程序在本質上是異步的,GWT 還提供了 `Timer` 類 , 以便延遲 JUnit,使異步行為在進行相關斷言之前全部完成。
### 實現 getModuleName 和 Timer 類
我已經指出,我的測試集中于 `getDefinition()` 方法(如 [清單 4](#listing4) 所示)。 您可以從代碼看到,測試邏輯非常簡單:傳入一個單詞(比如 _pugnacious_),然后驗證相應的 `Label` 文本是否得到正確定義。很簡單,對嗎?但是不要忘記,`getDefinition()` 方法在 `AsyncCallback` 對象中具有某種相關的異步性。
`GWTTestCase` 類是一個_抽象_ 類,因為它的 `getModuleName()` 方法就是這么聲明的;因此,當您 擴展該類時,您需要實現 `getModuleName()`(除非您是在為框架創建自己的基抽象類)。模塊名實際上就是您的 GWT XML 文件所在的包結構的名稱去掉文件擴展名。舉個例子,在本例中, 我有一個名為 WordModule.gwt.xml 的 XML 文件,它位于 一個目錄結構如:com/acme/gwt。相應的, 模塊的邏輯名稱為 `com.acme.gwt.WordModule`, 這會讓您想到 Java 平臺的普通包模式。
我已經得到一個模塊名,可以開始定義測試用例了,如清單 5 所示:
##### 清單 5\. 您必須實現 getModuleName 方法并提供一個有效的名字
```
import com.google.gwt.junit.client.GWTTestCase;
import com.google.gwt.user.client.Timer;
public class WordModuleTest extends GWTTestCase {
public String getModuleName() {
return "com.acme.gwt.WordModule";
}
}
```
到目前為止一切良好,但是我還沒有執行任何測試!由于我的 Ajax 應用程序使用 `AsyncCallback` 對象,在通過測試用例調用 `getDefinition()` 方法時, 我必須強迫 JUnit 延遲運行;否則測試將由于沒有任何響應而失敗。這就要用到 GWT 的 `Timer` 類。`Timer` 使我能夠重寫 `getDefinition()` 的 `run` 方法,在 `Timer` 內完成測試用例邏輯。(測試用例以獨立線程運行,有效地阻塞 JUnit 完成整個測試用例)。
以我的測試為例,我將首先調用 `getDefinition()` 方法,然后提供一個 `Timer` 的 `run()` 方法的實現。`run()` 方法得到輸出 `Label` 實例的文本并驗證是否是正確定義。 定義了 `Timer` 實例后,我就需要確定其何時運行,同時強制 JUnit 掛起直至 `Timer` 實例完成。也許聽起來有點復雜,不必擔心,因為實踐起來非常簡易。實際上,清單 6 展示了整個過程:
##### 清單 6\. 使用 GWT 輕松測試
```
public void testDefinitionValue() throws Exception {
WordModule module = new WordModule();
module.getDefinition("pugnacious");
Timer timer = new Timer() {
public void run() {
String value = module.getOutputLabel().getText();
String control = "inclined to quarrel or fight readily;...";
assertEquals("should be " + control, control, value);
finishTest();
}
};
timer.schedule(200);
delayTestFinish(500);
}
```
正如您所見,`Timer` 的 `run()` 方法 是我真正驗證 Ajax 應用程序功能及其應用遠程過程調用的地方。 請注意 run 方法的最后一步是調用 `finishTest()` 方法, 它意味著一切如預期運行,JUnit 可以不受阻塞正常運行。 在實踐中,您可能會發現需要根據異步行為完成所需的時間調整延遲時間。 但用 JUnit 測試 GWT 應用程序的要點在于:您能夠在_無需_ 部署完整功能的 Web 應用程序的情況下測試它。因此,您能夠_更早地_ 并且更 _頻繁地_ 測試您的 GWT 應用程序。
* * *
## 運行 GWT 測試
## 使用 GWT 進行功能測試
像本文演示的這類簡單 Ajax 應用程序_可以_ 從功能角度進行驗證, 使用包括 Selenium 在內的框架,它會驅動瀏覽器模擬實際用戶行為。不過,要想用 Selenium 運行功能測試,您必須部署完整功能的 Web 應用程序。
前面我曾提到,如果您想實際運行您的 GWT JUnit 測試,您必須執行大量瑣碎的工作來配置運行環境。比如說,要想通過 Ant 的 `junit` 任務運行我的測試,我就必須確保某些文件 位于類路徑中并向低層 JVM 提供一個參數。特別是,在調用 `junit` 任務時, 我還要確保托管源文件(以及測試)的目錄(或多個目錄)位于類路徑中,還要告訴 GWT 以何種模式運行。 我傾向于使用 _hosted_ 模式,這意味著要使用 _www-test_ 標志 ,如清單 7 所示:
##### 清單 7\. 用 Ant 運行 GWT 測試
```
<junit dir="./" failureproperty="test.failure" printSummary="yes"
fork="true" haltonerror="true">
<jvmarg value="-Dgwt.args=-out www-test" />
<sysproperty key="basedir" value="." />
<formatter type="xml" />
<formatter usefile="false" type="plain" />
<classpath>
<path refid="gwt-classpath" />
<pathelement path="build/classes" />
<pathelement path="src" />
<pathelement path="test" />
</classpath>
<batchtest todir="${testreportdir}">
<fileset dir="test">
<include name="**/**Test.java" />
</fileset>
</batchtest>
</junit>
```
運行 GWT 測試現在轉變成調用問題了。還需注意的是 GWT 測試屬于輕量級測試, 所以我可以頻繁運行測試,甚至是連續運行,就像我在一個持續集成環境(Continuous Integration)中一樣。
* * *
## 結束語
在本文所示的 GWT 測試用例中,您已經看到用于驗證 Ajax 應用程序所需的基本步驟。 您可以繼續測試我的示例 GWT 應用程序,比如測試一些邊界用例,但是我認為重點在于:如果使用包含測試特性的框架編寫 Ajax 應用程序,測試要比想象中容易。
要對 GWT 應用程序進行良好測試(對絕大多數應用程序也適用),關鍵在于設計應用程序時要把測試一并考慮。還要注意 `GWTTestCase` 不是被用來進行交互測試的。 您不能使用 `GWTTestCase` 直接模擬用戶。不過您能夠以一種間接的方式用它來驗證用戶交互,正如本文中演示的那樣。
- 追求代碼質量
- 追求代碼質量: 對 Ajax 應用程序進行單元測試
- 追求代碼質量: 使用 TestNG-Abbot 實現自動化 GUI 測試
- 追求代碼質量: 用 AOP 進行防御性編程
- 追求代碼質量: 探究 XMLUnit
- 追求代碼質量: 用 JUnitPerf 進行性能測試
- 追求代碼質量: 通過測試分類實現敏捷構建
- 追求代碼質量: 可重復的系統測試
- 追求代碼質量: JUnit 4 與 TestNG 的對比
- 追求代碼質量: 馴服復雜的冗長代碼
- 追求代碼質量: 用代碼度量進行重構
- 追求代碼質量: 軟件架構的代碼質量
- 讓開發自動化: 除掉構建腳本中的氣味
- 追逐代碼質量: 決心采用 FIT
- 追求代碼質量: 不要被覆蓋報告所迷惑