技术开发 频道

对Ajax 应用程序进行单元测试

  【IT168 技术文章】您可能从编写 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 应用程序的每个部分都能像与之对应的同步应用程序一样进行测试。

  JUnit 和 GWTTestCase

  因为与 GWT 有关的 Ajax 应用程序采用 Java 代码编写,所以非常适合开发人员使用 JUnit 进行测试。事实上,GWT 开发小组还为此创建了一个帮助器类 GWTTestCase,扩展自 JUnit 的 3.8.1 TestCase。该基类添加了一些功能,可测试 GWT 代码并处理某些基础实现从而启动并运行 GWT 组件。

  需要提醒的是: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 应用程序,但是如何测试它?

1 public class DefaultModule implements EntryPoint {
2
3 public void onModuleLoad() {
4    Button button = new Button("Submit");
5    TextBox box = new TextBox();
6    Label output = new Label();
7    Label label = new Label("Word: ");
8
9    HorizontalPanel inputPanel = new HorizontalPanel();
10    inputPanel.setStyleName("input-panel");
11    inputPanel.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE);
12    inputPanel.add(label);
13    inputPanel.add(box);
14
15    button.addClickListener(new ClickListener() {
16     public void onclick(Widget sender) {
17       String word = box.getText();
18       WordServiceAsync instance = WordService.Util.getInstance();
19        try {
20          instance.getDefinition(word, new AsyncCallback() {
21           
22            public void onFailure(Throwable error) {
23              Window.alert("Error occurred:" + error.toString());
24            }
25
26            public void onSuccess(Object retValue) {
27              output.setText(retValue.toString());
28            }
29           });
30         }catch(Exception e) {
31           e.printStackTrace();
32         }
33     }
34    });
35
36    inputPanel.add(button);
37    inputPanel.setCellVerticalAlignment(button,
38      HasVerticalAlignment.ALIGN_BOTTOM);
39
40    RootPanel.get("slot1").add(inputPanel);
41    RootPanel.get("slot2").add(output);
42    }
43 }
44

  清单 1 的代码在运行时发生了严重的错误:它无法按照 JUnit 和 GWT 的 GWTTestCase 进行测试。事实上,如果我试着为这段代码编写测试,从技术方面来说它可以运行,但是无法按照逻辑工作。考虑一下:您如何对这段代码进行验证?惟一可用于测试的 public 方法返回的是 void, 那么,您怎么能够验证其功能的正确性呢?

  如果我想以白盒方式验证这段代码,就必须分离业务逻辑和特定于用户界面的代码,这就需要进行重构。这本质上意味着把清单 1 中的代码分离到一个便于测试的独立方法中。但是这并非听上去那么简单。很明显组件挂钩是通过 onModuleLoad() 方法实现,但是如果我想强制其行为,可能 必须操纵某些用户界面(UI)组件。

  分解业务逻辑和 UI 代码

  第一步是为每个 UI 组件创建访问器方法,如清单 2 所示。按照该方式,我可以在需要时获取它们。

  清单 2. 向 UI 组件添加访问器方法使其可用

1 public class WordModule implements EntryPoint {
2
3   private Label label;
4   private Button button;
5   private TextBox textBox;
6   private Label outputLabel;
7   
8   protected Button getButton() {
9    if (this.button == null) {
10     this.button = new Button("Submit");
11    }
12    return this.button;
13   }
14
15   protected Label getLabel() {
16    if (this.label == null) {
17     this.label = new Label("Word: ");
18    }
19    return this.label;
20   }
21
22   protected Label getOutputLabel() {
23    if (this.outputLabel == null) {
24     this.outputLabel = new Label();
25    }
26    return this.outputLabel;
27   }
28
29   protected TextBox getTextBox() {
30    if (this.textBox == null) {
31     this.textBox = new TextBox();
32     this.textBox.setVisibleLength(20);
33    }
34    return this.textBox;
35   }
36 }
37

  现在我实现了对所有与 UI 相关的组件的编程式访问(假设所有需要进行访问的类都在同一个包内)。以后我可能需要使用其中一种访问进行验证。我现在希望限制 使用访问器,如我已经指出的,这是因为 GWT 并非设计用来进行交互测试。所以,我不是真的要试图测试某个按钮实例是否被单击,而是要测试 GWT 模块是否会对给定的单词调用服务器端代码,并且服务器端会返回一个有效定义。方法为将 onModuleLoad() 方法的定义获取逻辑推入(不是故意用双关语!)一个可测试方法中,如清单 3 所示:

  清单 3. 重构的 onModuleLoad 方法委托给更易于测试的方法

1 public void onModuleLoad() {
2   HorizontalPanel inputPanel = new HorizontalPanel();
3   inputPanel.setStyleName("disco-input-panel");
4   inputPanel.setVerticalAlignment(HasVerticalAlignment.ALIGN_MIDDLE);
5
6   Label lbl = this.getLabel();
7   inputPanel.add(lbl);
8
9   TextBox txBox = this.getTextBox();
10   inputPanel.add(txBox);
11
12   Button btn = this.getButton();
13
14   btn.addClickListener(new ClickListener() {
15    public void onClick(Widget sender) {
16      submitWord();
17    }
18   });
19
20   inputPanel.add(btn);
21   inputPanel.setCellVerticalAlignment(btn,
22       HasVerticalAlignment.ALIGN_BOTTOM);
23
24   if(RootPanel.get("input-container") != null) {
25    RootPanel.get("input-container").add(inputPanel);
26   }
27
28   Label output = this.getOutputLabel();
29   if(RootPanel.get("output-container") != null) {
30    RootPanel.get("output-container").add(output);
31   }
32 }
33

  如清单 3 所示,我已经把 onModuleLoad() 的定义获取逻辑委托给 submitWord 方法,如清单 4 定义:

  清单 4. 我的 Ajax 应用程序的实质!

1 protected void submitWord() {
2   String word = this.getTextBox().getText().trim();
3   this.getDefinition(word);
4 }
5
6 protected void getDefinition(String word) {
7 WordServiceAsync instance = WordService.Util.getInstance();
8 try {
9    instance.getDefinition(word, new AsyncCallback() {
10
11    public void onFailure(Throwable error) {
12     Window.alert("Error occurred:" + error.toString());
13    }
14
15    public void onSuccess(Object retValue) {
16     getOutputLabel().setText(retValue.toString());
17    }
18   });
19 }catch(Exception e) {
20    e.printStackTrace();
21 }
22 }
23

  submitWord() 方法又委托给 getDefinition() 方法,我可以用 JUnit 测试它。getDefinition() 方法从逻辑上独立于特定于 UI 的代码(对于绝大部分而言),并且可以在没有单击按钮的情况下得到调用。另一方面,与异步应用程序有关的状态问题和 Java 语言的语义规则也规定了我不能在测试中完全 避免与 UI 相关的交互。仔细查看清单 4 中的代码,您能够发现激活异步回调的 getDefinition() 方法操纵了某些 UI 组件 —— 一个错误警告窗口以及一个 Label 实例。

  我还可以通过获得输出 Label 实例的句柄,断言其文本是否是给定单词的定义,从而验证应用程序的功能。在用 GWTTestCase 测试时,最好不要 尝试手工强制改变组件状态,而应该让 GWT 完成这些工作。举例而言,在清单 4 中,我想验证对某个给定单词返回了其正确定义并放入一个输出 Label 中。无需操作 UI 组件来设置这个单词;我只要直接调用 getDefinition 方法,然后断言 Label 具有对应定义。

  既然我已经编写好了计划进行测试的 GWT 应用程序,我需要实际编写测试,这意味着设置 GWT 的 GWTTestCase。

0