测试
Sumit Chandel,Google 开发者关系
2009 年 3 月
本文直接改编自 Daniel Wellman 的优秀文章 “GWT:以测试驱动的方式编写 Ajax 应用程序”,该文章发表于 Better Software 杂志(2008 年 11 月)。
GWT 的核心功能之一是可测试性,这意味着我们可以使用一组久经考验的测试工具轻松测试我们的应用程序。GWT 应用程序的可测试性分为以下三种测试组件类型
- 标准 JUnit TestCase
- GWTTestCase(JUnit TestCase 的子类)
- Selenium 测试
乍一看,测试 GWT 应用程序似乎有些让人望而生畏,因为 GWT 应用程序代码以 Java 而不是 JavaScript 运行。但是,使用这些测试组件来彻底测试我们的应用程序实际上非常简单,更重要的是,我们可以将强大的设计模式应用于我们的代码,这将有助于使我们的测试用例简洁、有效且易于维护。在深入研究测试方法和设计模式之前,我们可以使用这些方法和模式来测试我们的 GWT 应用程序,首先让我们解释一下 GWT 的测试基础架构。
GWT 的测试基础架构
由于 GWT 应用程序几乎完全是用 Java 编程语言编写的,因此您可以使用标准 JUnit TestCase 测试其中很大一部分。但是,GWT 还包含一个特殊的 TestCase
子类,即 GWTTestCase
类,它可以测试在运行时需要 JavaScript 的代码。虽然最终您的所有客户端 Java 代码都将交叉编译为 JavaScript,但其中只有一部分使用直接实现为 JavaScript 的代码。例如,以下代码来自 GWT HTMLTable
类
public void setStylePrimaryName(int row, int column, String styleName) {
UIObject.setStylePrimaryName(getCellElement(bodyElem, row, column), styleName);
}
private native Element getCellElement(Element table, int row, int col) /*-{
var out = table.rows[row].cells[col];
return (out == null ? null : out);
}-*/;
此代码示例演示了用 Java 编写的(setStylePrimaryName
)方法,该方法依赖于用 JavaScript 直接实现的代码,用 native
关键字(getCellElement
)表示。许多 GWT 库都包含一些本机代码,如上所示;特别是,所有小部件都操作 DOM。这意味着当您对执行本机 JavaScript 代码的组件运行单元测试时,它们必须在支持 JavaScript 运行时的环境中运行,例如托管模式浏览器提供的环境。
为了测试依赖于本机 JavaScript 代码的组件,GWT 提供了一个 JUnit TestCase
的子类,称为 GWTTestCase
。这个基类允许您像往常一样实现 JUnit 测试用例;实际上,GWTTestCase 看起来与标准 JUnit TestCase 几乎相同
public class MeetingSummaryLabelTest extends GWTTestCase {
public String getModuleName() {
return "com.danielwellman.booking.Booking";
}
// Add tests here
}
唯一的明显区别是所有 GWTTestCase 都必须重写一个名为 getModuleName
的抽象方法,该方法返回一个字符串,其中包含您的 GWT 代码模块的名称,如您的应用程序的模块 XML 文件中定义的那样。
当您运行测试时,GWT 框架会启动一个不可见的(或“无头”)托管模式浏览器,然后评估您的测试用例。这意味着托管浏览器的所有功能都可用于您的测试用例;您可以运行本机 JavaScript 函数、渲染小部件或调用异步远程过程调用。此外,您可以将测试作为 Java 和 JavaScript 代码的混合运行(在托管模式下),或者将所有 GWT 代码编译并运行为 JavaScript(在 Web 模式下)。您只需在运行测试时,向 TestRunner 进程声明并传递 -Dgwt.args="-web"
Java 运行时参数即可。强烈建议您在托管模式和 Web 模式下运行测试,因为 Java 和 JavaScript 之间存在一些细微的 差异,可能会导致意外的失败。
设置运行这些测试的类路径需要将测试代码的源代码和中间编译的 Java 类都传递给测试运行器。GWT 提供了一个名为 “junitCreator” 的工具,它将为您生成一个空的 GWTTestCase,以及在托管模式和 Web 模式下运行测试所需的脚本。
能够在 JUnit 测试中测试本机 JavaScript 代码很棒,但有一些注意事项和限制。首先,正常的浏览器事件机制在测试模式下按预期工作,但您需要添加一些非典型代码来执行诸如以编程方式单击按钮并期望相应的事件处理程序被触发之类的事情。(例如 onClick
)。处理您想通过事件监听器进行测试的情况的最佳方法是编写 Selenium 测试,这些测试针对具有所有事件机制的浏览器运行。还有一些性能方面的考虑;运行 TestCase 会强制编译模块中的源代码,这会导致初始启动延迟。此外,每个单独的测试用例都需要启动和关闭无头浏览器,这可能需要几秒钟。一种有用的技术是将测试用例分组到 TestSuite 中,以便测试可以在单个套件中运行,并且每个套件只产生一次编译/托管模式启动成本。
那么什么时候应该扩展标准 JUnit TestCase 或 GWTTestCase 呢?一般来说,您应该优先使用标准 JUnit TestCase,因为它们的运行速度比 GWTTestCase 快几个数量级。如果您的代码执行本机 JavaScript,那么您的测试必须扩展 GWTTestCase——这通常包括使用提供的 GWT 库的任何代码。关键是,如果您只是在被测试的代码中实例化了一个小部件,那么您将不得不使用 GWTTestCase 来测试它。但是,您应该考虑是否存在其他设计方法可以避免这种本机代码需求,例如将逻辑移动到另一个类。
GUI 设计模式
您可以使用多种设计模式和技术来构建可测试的 GUI 应用程序。它们都关注一个核心原则:将尽可能多的逻辑从视图中移到其他更易于测试的层。一种有助于实现此目标的常见模式称为模型-视图-演示器,其中演示器对象充当视图(GUI)和模型对象之间的中介,并指示视图层响应用户输入或模型更改来更改状态。这种模式与更广为人知的 MVC 模式非常相似,但是与在 MVC 中,控制器和视图共享表示逻辑不同,在 MVP 中,所有表示逻辑都推送到演示器。下图说明了这两种模式,以帮助可视化它们之间的差异。
MVC…
与 MVP
示例
为了说明这些概念中的一些,让我们看一下如何构建应用程序的一小部分。在这个例子中,我们正在构建一个用于在会议中心预订会议室的在线应用程序。用户需要指定有关会议的一些详细信息,包括预计容量和日期。该应用程序将与调度后端服务进行检查,以确定会议室是否可用。如果不可用,保存按钮将变暗,并会显示一条消息。有关此对话框的示例布局,请参见图 1。
图 1 — 预订应用程序 UI 的第一个迭代
在白板上快速画了几下后,我们对涉及的对象进行了粗略的草图,如图 2 所示。
图 2 — 预订应用程序的对象职责和交互
构建演示器
测试演示器的关键是它们将是普通的 Java 代码,并且可以使用 JUnit 像任何其他 Java 代码一样进行测试。可以像 EasyMock 这样的模拟对象库来测试演示器与视图组件之间的交互。对于那些更熟悉其他模拟对象库的人来说,Daniel Wellman 在引言中提到的文章中使用 jMock 而不是 EasyMock 对此示例进行了很好的处理。
让我们尝试解决此功能的一小部分:用户输入了无法安排的会议容量。首先,视图将通知演示器用户已更改容量文本字段的值。然后,演示器将询问 RoomScheduler 服务它是否可以接受具有指定容量的新会议。最后,演示器将告诉视图禁用保存按钮。让我们为此场景编写一个测试
import static org.easymock.EasyMock.*;
public class PresenterTest extends TestCase {
@Test
public void test_an_unavailable_room_disables_the_save_button() {
final MeetingView view = createMock(MeetingView.class);
final RoomScheduler scheduler = createMock(RoomScheduler.class);
final Meeting meeting = new Meeting();
final Presenter presenter = new Presenter(meeting, view, scheduler);
// The schedule service will reply with no available capacity
expect(scheduler.canAcceptCapacityFor(meeting)).andReturn(false);
view.disableSaveButton();
replay(scheduler);
replay(view);
presenter.requiredCapacityChanged(new FakeTextContainer("225"));
verify(scheduler);
verify(view);
assertEquals("Should have updated the model's capacity", 225, meeting.getCapacity());
}
}
此测试是一个基于交互的测试,它使用 EasyMock 为 View 和 RoomScheduler 提供测试替身。我们模拟调度程序以回复它无法接受会议的容量,并期望我们的视图被告知禁用保存按钮。请注意,这里视图最终变得相当愚蠢;它除了在需要容量发生更改时通知演示器之外什么也不做。
此代码需要我们为视图指定一个接口
public interface MeetingView {
void disableSaveButton();
}
…以及我们的服务
public interface RoomScheduler {
boolean canAcceptCapacityFor(Meeting meeting);
}
通过此测试的代码非常简单
public class Presenter {
private Meeting meeting;
private MeetingView meetingView;
private RoomScheduler roomScheduler;
public Presenter(Meeting meeting, MeetingView meetingView, RoomScheduler roomScheduler) {
this.meeting = meeting;
this.meetingView = meetingView;
this.roomScheduler = roomScheduler;
}
/**
* Callback when the view's capacity text box changes
*
* @param textField the capacity TextBox widget
*/
public void requiredCapacityChanged(HasText textField) {
meeting.setCapacity(Integer.parseInt(textField.getText()));
if (!roomScheduler.canAcceptCapacityFor(meeting)) {
meetingView.disableSaveButton();
}
}
protected Meeting getMeeting() {
return meeting;
}
}
演示者负责协调对远程服务的调用并指示视图禁用保存按钮。另请注意,我们选择让演示者维护会议对象的狀態,以便所有 UI 事件最终都修改此对象。
这是一个非常简单的实现,但它远未完成设计。我们的下一个测试可能会检查设置可接受的容量是否启用了保存按钮,并促使我们为视图创建一个新的方法“enableSaveButton
”或一个通用的“setSaveButtonAvailable
”方法。我们仍在测试不需要任何 JavaScript 的纯 Java 对象,因此这些测试运行速度很快。
请注意,requiredCapacityChanged
的参数类型为 HasText
。事实证明,这是一个 GWT 库的一部分接口。
package com.google.gwt.user.client.ui;
public interface HasText {
/**
* Gets this object's text.
*/
String getText();
/**
* Sets this object's text.
*
* @param text the object's new text
*/
void setText(String text);
}
此简单接口被许多 GWT 组件使用,并允许操作小部件的文本内容,包括我们示例中的 TextBox。此接口对于测试非常有用,因为我们不需要传入真实的 TextBox。因此,我们避免在 DOM 中实例化文本输入,要求我们的测试扩展 GWTTestCase 以在真实浏览器中运行。在此示例中,我创建了一个非常简单的伪造实现,它包装了一个字符串。
public class FakeTextContainer implements HasText {
private String text;
public FakeTextContainer(String text) {
this.text = text;
}
public String getText() {
return text;
}
public void setText(String text) {
this.text = text;
}
}
最后,让我们看一下我们的视图实现。
public class MeetingViewWidget extends Composite implements MeetingView {
private Button saveButton = new Button("Save");
private TextBox capacityText = new TextBox();
public MeetingViewWidget() {
VerticalPanel mainPanel = new VerticalPanel();
HorizontalPanel row = new HorizontalPanel();
row.add(new Label("Capacity:"));
row.add(capacityText);
mainPanel.add(row);
mainPanel.add(saveButton);
// Start with the save button disabled
saveButton.setEnabled(false);
// Here the view is responsible for creating the model and presenter
final Presenter presenter = new Presenter(new Meeting(), this, new RemoteRoomScheduler());
capacityText.addChangeListener(new ChangeListener() {
public void onChange(Widget sender) {
presenter.requiredCapacityChanged((HasText) sender);
}
});
initWidget(mainPanel);
}
public void disableSaveButton() {
saveButton.setEnabled(false);
}
}
最后,为了完整起见,请查看会议类代码。
public class Meeting {
private Integer capacity;
public int getCapacity() {
return capacity;
}
public void setCapacity(int capacity) {
this.capacity = capacity;
}
}
如您所见,这里没有太多逻辑。大部分代码都用于设置事件侦听器和配置显示小部件。那么如何在 GWTTestCase 中对其进行测试呢?
我们不会。事实上,这里没有多少内容可以在自动化测试中进行测试;如前所述,事件传播在 GWTTestCase 中默认情况下不起作用。这就是 Selenium 测试非常有用的地方。我们为小部件编写的测试在已部署的浏览器环境中运行,这意味着测试上下文将拥有它期望的所有事件侦听器以供测试。在 GWT 1.5 中,我们引入了可跟踪的调试 ID,通过新的 UIObject.ensureDebugId()
方法设置,这使我们能够在给定的小部件上设置调试 ID。稍后,在编写 Selenium 测试时,我们可以使用它们的调试 ID 来跟踪这些小部件。
如果您正在构建小部件库,那么您可能希望编写 GWTTestCases 来通过其 API 测试小部件,这就是 GWT 团队对 GWT 中包含的小部件(如 Button
、TextBox
和 Tree
)所做的事情。但是,这些测试速度很慢,任何复杂的逻辑都可以移动到一个简单演示者对象中,可以在一个普通的快速 JUnit TestCase 中对其进行测试。
测试对远程服务的异步访问
GWT 提供了一个 远程过程调用 机制 (RPC),它允许使用服务器端序列化库在服务器和客户端之间传递 Java 对象。GWTTestCase 支持测试这些功能,它提供了一些实用程序方法,这些方法有助于编写异步测试。有关 GWTTestCase 的大多数可用信息都集中在这些 RPC 案例上,我建议您阅读它们以获取完整的故事。有关简要介绍,请参阅 GWT 文档中的 异步测试 部分。