内容:
Liar, liar!
Liar View 错误模式
症状
起因
治疗及预防措施
总结
参考资料
GUI 一般是基于模型-视图-控制器体系结构设计的,其中,视图是从模型中分离出来的。这种分离对自动测试是一个挑战,因为我们很难检验模型中的状态改变是否在视图中得到了适当的反映,这样就产生了臭名昭著的 Liar View。在诊断 Java 代码的这部分中,讨论的就是 Liar View 错误模式。
Liar, liar!
设想一下:您已经为一个分布式系统精心设计了一个极好的 GUI 程序,它包含了客户机请求的所有东西及其它一些东西。您已经让它运行通过了一个自动化测试套件的测试 ? 由于不变量的数量是个天文数字,因此,自动化测试是必须的。测试的结果是程序获得了一张“无错误的健康证实书”。
发布这个 GUI 的期限到了,但是,作为一个象您这样严格的程序员,只是为了发现错误的行为 ? 本该在自动化测试中就被捕捉到的行为,您启动了程序,对它做最后一次手工测试。但愿您能够避免这种情形。真的,您能够。
Liar View 错误模式
好的调试从好的测试开始。由于 GUI 程序中有大量的不变量需要检查,因此,自动化测试是必需的。但有时尽管已经通过了一套测试,在手工检查时,程序仍会出现本该由这些测试之一发现的错误行为。
快速跟踪代码
清单 1. JTable 及表模型 一个说明 Liar View 如何产生的简单 GUI
清单 2. 检查视图、模型及行的内容使断言说明真相
在分布式和多线程系统中,这种行为是常见的。在这些情况下,程序的“不确定性”本质经常就是原因所在。但在 GUI 中,却有另一种常见的原因 ? Liar View 错误模式。
症状
多数 GUI 程序测试,跟通常的程序测试一样,遵循下列步骤:
启动程序
检查程序状态的一些特征
尝试修改状态
检查状态是否已经按意愿被修改
但就象我已提到过的,有时对运行时程序行为的手工检查的结果会与测试得到的成功结果相矛盾:屏幕上可能显示一个队列,包含被测试(按设想进行)确认删除了的元素;而对象中可能包含报告显示已被更新了的陈旧数据。
类似这样的错误会使我们对自己的心智是否健全产生怀疑,或者更糟地陷入到康德的怀疑论哲学,怀疑起原因本身的有效性。
不要让这些发生在您身上。当正确对待原因时,它确实是有用的。尽管有反面的报告,但很少有程序员会在写代码时永久地丧失心智( 永久地是一个要害词)。
起因
找到这些错误的一个要害是要熟悉到,至少有一部分错误可以在测试套件中找到。
在测试 GUI 程序的过程中,错误最常发生的地方是在最后一步:检查状态是否已经按意愿被修改。原因是 GUI 一般是基于模型-视图-控制器(MVC)体系结构设计的。Swing 类库甚至把这种体系结构建到了 GUI 类自身的结构中。
在 MVC 体系结构中,程序的内部状态保存在模型中。视图响应改变模型状态的事件并相应更新屏幕图像。控制器把这两个组件连接在一起。
这种体系结构的优点是把视图从模型中分离出来,使得各自的实现可以独立修改。但是它对自动化测试方法却是一个挑战:我们很难检验模型中的状态改变是否在视图中得到了适当的反映。当这两者之间存在矛盾时,我们就会碰到一个 Liar View 错误模式的实例。
例如,考虑下面的简单 GUI。它在一列元素的内容被更新时显示其内容。Controller 类的 main 方法被用作一个简单的测试。在实际的应用程序中,我把这个方法移到单独的测试类中,并将其挂到 JUnit 中(请参阅参考资料)。
为了让我们能够使测试以慢动作方式进行,并在每个事件发生时对它进行手工检查,我添加了 pause() 方法和 PAUSE 字段。
清单 1. JTable 及表模型
import java.awt.*;
import java.awt.event.*;
import java.util.Vector;
import javax.swing.*;
public class Controller {
private static final int PAUSE = 1;
private static void assert(boolean assertion) {
if (! assertion) {
throw new RuntimeException("Assertion Failed");
}
}
private void pause() {
try {
synchronized (this) {
wait(PAUSE);
}
}
catch (InterruptedException e) {
}
}
public static void main(String[] args) {
Controller controller = new Controller();
JFrame frame = new JFrame("Test");
Model model = new Model();
JList view = new JList(model);
view.setPreferredSize(new Dimension(200,100));
frame.getContentPane().add(view);
frame.pack();
frame.setVisible(true);
assert(model.getSize() == 0);
controller.pause();
model.add("test0");
controller.pause();
model.add("test1");
controller.pause();
assert(model.getSize() == 2);
controller.pause();
model.remove(0);
controller.pause();
assert(model.getSize() == 1);
controller.pause();
System.exit(0);
}
}
class Model extends AbstractListModel {
private Vector elements = new Vector();
public synchronized Object getElementAt(int index) {
return elements.get(index);
}
public synchronized int getSize() {
return elements.size();
}
public synchronized void add(Object o) {
int index = this.getSize();
this.elements.add(o);
this.fireIntervalAdded(this, index, index);
}
public synchronized void remove(int index) {
this.elements.remove(index);
}
}
您可能已经注重到这段代码有一个严重错误。假如我们运行这段测试代码,所有的断言都会是成功的,它表明已在列表中正确地添加或除去了项目。但假如我们通过某种方法,例如把 PAUSE 设成 1000,使运行速度降下来,那么我们就能以手工检查的方式运行测试。猜猜结果是什么?我们注重到,视图中不曾有项目被除去。
视图没被更新的原因是,Model 类中的 remove() 方法从未调用 fireIntervalRemoved() 来通知侦听器:模型的状态已经改变了。
但我们的测试方法中的所有断言都取得了成功。为什么呢?因为这些断言只是在模型中,而不是在视图中检查发生的更改。因为模型被适当地更新了,断言没能检测到遗漏的事件触发。
治疗及预防措施
防止这种错误模式的一种方法是:只在修改模型的状态之后才检查视图的显式属性。虽然这种技术把我们能检查的属性限制在视图提供的范围内,但至少能使断言反映屏幕上实际正在发生的事情。
例如,我们可重写 Controller.main,如下所示:
清单 2. 检查视图、模型及行的内容
import java.awt.*;
import java.awt.event*;
import java.util.Vector;
import java.swing.*;
public class Controller {
. . .
public static void main(String[] args) {
Controller controller = new Controller();
JFrame frame = new JFrame("Test");
Model model = new Model();
JList view = new JList(model);
view.setPreferredSize(new Dimension(200,100));
frame.getContentPane().add(view);
frame.pack();
frame.setVisible(true);
assert (model.getSize() == 0);
controller.pause();
boolean toggle = model.toggle;
model.add("test0");
assert ( toggle == ! model.toggle);
controller.pause();
toggle = model.toggle;
model.add("test1");
assert ( toggle == ! model.toggle);
controller.pause();
assert(model.getSize() == 2);
view.setSelectedIndex(0);
assert(view.getSelectedValue().equals("test0"));
controller.pause();
toggle = model.toggle;
model.remove(0);
assert(toggle == ! model.toggle);
controller.pause();
assert(model.getSize() == 1);
view.setSelectedIndex(0);
assert(view.getSelectedValue().equals("test1"));
controller.pause();
System.exit(0);
}
}
class Model extends AbstractListModel {
boolean switch = false;
private Vector elements = new Vector();
...
public void fireIntervalAdded(AbstractListModel m, int start, int end) {
super.fireIntervalAdded(m,start,end);
this.switch = ! this.switch;
}
public void fireIntervalRemoved(AbstractListModel m, int start, int end) {
super.fireIntervalAdded(m,start,end);
this.switch = ! this.switch;
}
}
通过使用 setSelectedIndex() 和 getSelectedIndex(),我们对程序进行测试,测试虽只是稍有不同,但却有极大改善。修改后的测试不单检查模型,还检查视图,而且不只简单地检查行的数量,还检查所选行的内容。
直接核查视图的另一种方法是使用 Java Robot 类(在 Java 1.3 API 中有介绍 ? 请参阅参考资料),使 GUI 的鼠标和键盘的物理操作真正实现自动化。
Robot 类还答应您对屏幕的局部进行快照,答应建立基于 GUI 视图实际物理布局的测试。当然,假如视图的物理布局不如其逻辑结构稳定,这种能力会成为一种缺点。每次物理布局改变时,都必须重写几个测试是很痛苦的。因此,对于视图不会频繁改变的成熟的 GUI,我推荐您使用 Robot 类作为测试工具。要测试逻辑方面的问题,您可以象我们上面所做的那样,调用视图的方法。
最后一条忠告:对视图对象中简单地把调用弹回到模型的方法要小心。这样做很快就会引入 Liar View。非凡是 JTables,其中包含有很多这样的方法。
总结
以下是本周讨论的错误模式的小结:
模式:Liar View
症状:GUI 程序通过了测试套件的测试,但后来却显示出本该在那些测试中被消除了的行为。
起因:测试只在模型方面做检查,而没有在视图方面做检查。
治疗及预防措施:在视图方面做检查。
一点一点地,我们终于努力学完了关于最常见(也最让人沮丧)的错误模式的解决方案,但我们仍然有很多事情要做。下次,我们将对付一种更具破坏性的错误:破坏者数据,数据一开始是完美的……直到存取它们时。且待下回分解!
参考资料
Dr. Dobbs Journal 在 1997 年就 Java 平台上的 GUI 测试写了一篇虽显陈旧但却很好的论文: “Automated testing is as problematic as it is essential”。
CssT Technologies, Inc 的 Web 站点主办有一个很好的关于 GUI 测试中的一些问题的论坛。
请查阅 JUnit 并通过使您的代码“布满测试”以捕捉更多错误。
请访问模式主页,以获得有关设计模式及其使用方法的具体介绍。
假如您还没这样做,请务必到 Java 1.3 API 查阅 Robot 类。
Malcolm Davis 的文章“Incremental development with Ant and JUnit”(developerWorks,2000 年 11 月)和 Jeff Canna 的文章“Testing, fun? Really?”(developerWorks,2001 年 3 月)为日常开发工作的测试提供了一些指导。
Malcolm Davis 写的“Struts,一个开放源代码的 MVC 实现”从另一个角度考察了模型-视图-控制器体系结构。
阅读 Eric 关于错误模式的完整系列。