级别: 初级 Alexander Day Chaffee (alex@jguru.com), 顾问, Purple Technology William Pietri (william@scissor.com), 顾问, Scissor
2003 年 3 月 26 日 模仿对象(Mock object)是为起中介者作用的对象编写单元测试的有用方法。测试对象调用模仿域对象(它只断言以正确的次序用期望的参数调用了正确的方法),而不是调用
实际域对象。然而,当测试对象必须创建域对象时,我们面临一个问题。测试对象如何知道创建
模仿域对象,而不是创建
实际域对象呢?在本文中,软件顾问 Alexander Day Chaffee 和 William Pietri 将演示一种重构技术,该技术根据工厂方法设计模式来创建模仿对象。
单元测试已作为软件开发的“最佳实践”被普遍接受。当编写对象时,还必须提供一个自动化测试类,
该类包含测试该对象性能的方法、用各种参数调用其各种公用(public)方法并确保返回值是正确的。
当您正在处理简单数据或服务对象时,编写单元测试很简单。
然而,许多对象依赖基础结构的其它对象或层。当开始测试这些对象时,实例化这些合作者(collaborator)通常是昂贵的、不切实际的或效率低的。
例如,要单元测试一个使用数据库的对象,安装、配置和发送本地数据库副本、运行测试然后再卸装本地数据库可能很麻烦。
模仿对象提供了解决这一困难的方法。模仿对象符合实际对象的接口,但只要有足够的代码来“欺骗”测试对象并跟踪其行为。
例如,虽然某一特定单元测试的数据库连接始终返回相同的硬连接结果,但可能会记录查询。
只要正在被测试的类的行为如所期望的那样,它将不会注意到差异,
而单元测试会检查是否发出了正确的查询。
夹在中间的模仿
使用模仿对象进行测试的常用编码样式是:
- 创建模仿对象的实例
- 设置模仿对象中的状态和期望值
- 将模仿对象作为参数来调用域代码
- 验证模仿对象中的一致性
虽然这种模式对于许多情况都非常有效,但模仿对象有时不能被传递到正在测试的对象。
而设计该对象是为了创建、查找或获得其合作者。
例如,测试对象可能需要获得对 Enterprise JavaBean(EJB)组件或远程对象的引用。或者,测试对象会使用具有副作用的对象,如删除文件的
File 对象,而在单元测试中不希望有这些副作用。
根据常识,我们知道这种情形下可以尝试重构对象,使之更便于测试。
例如,可以更改方法签名,以便传入合作者对象。
在 Nicholas Lesiecki 的文章“
Test flexibly with AspectJ and mock objects”中,
他指出重构不一定总是合意的,也不一定总是产生更清晰或更容易理解的代码。
在许多情况下,更改方法签名以使合作者成为参数将会在方法的原始调用者内部产生混淆的、未经试验的代码混乱。
问题的关键是该对象“在里面”获得这些对象。任何解决方案都必须应用于这个创建代码的所有出现。
为了解决这个问题,Lesiecki 使用了查找方式或创建方式。在这个解决方案中,执行查找的代码被返回模仿对象的代码自动替换。
因为 AspectJ 对于某些情况不是选项,所以我们在本文中提供了一个替代方法。
因为在根本上这是重构,所以我们将遵循 Martin Fowler 在他创新的书籍“
Refactoring: Improving the Design of Existing Code”(请参阅
参考资料)中建立的表达约定。
(我们的代码基于 JUnit ― Java 编程的最流行的单元测试框架,尽管它决不是 JUnit 特定的。)
重构:抽取和覆盖工厂方法
重构是一种代码更改,它使原始功能保持不变,但更改代码设计,使它变得更清晰、更有效且更易于测试。
本节将循序渐进地描述“抽取”和“覆盖”工厂方法重构。
问题:正在测试的对象创建了合作者对象。必须用模仿对象替换这个合作者。
重构之前的代码
class Application {
...
public void run() {
View v = new View();
v.display();
...
|
解决方案:将创建代码抽取到工厂方法,在测试子类中覆盖该工厂方法,然后使被覆盖的方法返回模仿对象。
最后,如果可以的话,添加需要原始对象的工厂方法的单元测试,以返回正确类型的对象:
重构之后的代码
class Application {
...
public void run() {
View v = createView();
v.display();
...
protected View createView() {
return new View();
}
...
}
|
该重构启用清单 1 中所示的单元测试代码:
清单 1. 单元测试代码
class ApplicationTest extends TestCase {
MockView mockView = new MockView();
public void testApplication {
Application a = new Application() {
protected View createView() {
return mockView;
}
};
a.run();
mockView.validate();
}
private class MockView extends View
{
boolean isDisplayed = false;
public void display() {
isDisplayed = true;
}
public void validate() {
assertTrue(isDisplayed);
}
}
}
|
角色
该设计引入了由系统中的对象扮演的下列角色:
-
目标对象:正在测试的对象
-
合作者对象:由目标对象创建或获取的对象
-
模仿对象:遵循模仿对象模式的合作者的子类(或实现)
-
特殊化对象:覆盖创建方法以返回模仿对象而不是合作者的目标的子类
技巧
重构由许多小的技术性步骤组成。这些步骤统称为
技巧。如果您象按照食谱那样严格遵循这些技术,
那么您在学习重构时应该没有太大的麻烦。
- 标识创建或获取合作者的代码的所有出现。
- 将
抽取方法重构应用于这个创建代码,创建工厂方法(在 Fowler 书籍的第 110 页中讨论;有关更多信息,请参阅
参考资料一节)。
- 确保目标对象及其子类可以访问工厂方法。(在 Java 语言中,使用
protected 关键字)。
- 在测试代码中,创建模仿对象且实现与合作者相同的接口。
- 在测试代码中,创建扩展(专用于)目标对象的特殊化对象。
- 在特殊化对象中,覆盖创建方法以返回为测试提供的模仿对象。
- 可选的:创建单元测试以确保原始目标对象的工厂方法仍返回正确的非模仿对象。
示例:ATM
设想您正在编写用于银行自动柜员机(Automatic Teller Machine)的测试。其中一个测试可能类似于清单 2:
清单 2. 初始单元测试,在模仿对象引入之前
public void testCheckingWithdrawal() {
float startingBalance = balanceForTestCheckingAccount();
AtmGui atm = new AtmGui();
insertCardAndInputPin(atm);
atm.pressButton("Withdraw");
atm.pressButton("Checking");
atm.pressButtons("1", "0", "0", "0", "0");
assertContains("$100.00", atm.getDisplayContents());
atm.pressButton("Continue");
assertEquals(startingBalance - 100,
balanceForTestCheckingAccount());
}
|
另外,
AtmGui 类内部的匹配代码可能类似于清单 3:
清单 3. 产品代码,在重构之前
private Status doWithdrawal(Account account, float amount) {
Transaction transaction = new Transaction();
transaction.setSourceAccount(account);
transaction.setDestAccount(myCashAccount());
transaction.setAmount(amount);
transaction.process();
if (transaction.successful()) {
dispense(amount);
}
return transaction.getStatus();
}
|
该方法将起作用,遗憾的是,它有一个副作用:支票帐户余额比测试开始时少,这使得其它测试变得更困难。
有一些解决这种困难的方法,但它们都会增加测试的复杂性。
更糟的是,该方法还需要对管理货币的系统进行三次往返。
要修正这个问题,第一步是重构
AtmGui 以允许我们用模仿事务替换实际事务,
如清单 4 中所示(比较
粗体的源代码以查看我们正在更改什么):
清单 4. 重构 AtmGui
private Status doWithdrawal(Account account, float amount) {
Transaction transaction = createTransaction();
transaction.setSourceAccount(account);
transaction.setDestAccount(myCashAccount());
transaction.setAmount(amount);
transaction.process();
if (transaction.successful()) {
dispense(amount);
}
return transaction.getStatus();
}
protected Transaction createTransaction() {
return new Transaction();
} |
后退到测试类内部,我们将
MockTransaction 类定义为成员类,如清单 5 中所示:
清单 5. 将 MockTransaction 定义为成员类
private MockTransaction extends Transaction {
private boolean processCalled = false;
// override process method so that no real work is done
public void process() {
processCalled = true;
setStatus(Status.SUCCESS);
}
public void validate() {
assertTrue(processCalled);
}
}
|
最后,我们可以重写测试,以便被测试的对象使用
MockTransaction 类,而不是使用实际类,如清单 6 中所示:
清单 6. 使用 MockTransaction 类
MockTransaction mockTransaction;
public void testCheckingWithdrawal() {
mockTransaction = new MockTransaction();
AtmGui atm = new AtmGui() {
protected Transaction createTransaction() {
return mockTransaction;
}
};
insertCardAndInputPin(atm);
atm.pressButton("Withdraw");
atm.pressButton("Checking");
atm.pressButtons("1", "0", "0", "0", "0");
assertContains("$100.00", atm.getDisplayContents());
atm.pressButton("Continue");
assertEquals(100.00, mockTransaction.getAmount());
assertEquals(TEST_CHECKING_ACCOUNT,
mockTransaction.getSourceAccount());
assertEquals(TEST_CASH_ACCOUNT,
mockTransaction.getDestAccount());
mockTransaction.validate();
}
|
该解决方案产生了一个稍长的测试,但该测试只关注正在测试的类的直接行为,
而不是 ATM 接口之外整个系统的行为。也就是说,我们不再检查测试帐户的最终余额是否正确;
我们将在对
Transaction 对象的单元测试中检查该函数,而不是在对
AtmGui 对象的单元测试中。
注:根据模仿对象的创造者所说,它应该在其
validate() 方法内部执行自己的所有验证。
在本示例中,为了清晰起见,我们将验证的某些部分放在了测试方法内部。
随着您更加熟练地使用模仿对象,对于将多少验证职责代理给模仿对象,您将会深有体会。
内部类魔法
在清单 6 中,我们使用了
AtmGui 的匿名内部子类来覆盖
createTransaction 方法。
因为我们只需要覆盖一个简单的方法,所以这是实现我们目标的简明方法。
如果我们覆盖多个方法或在许多测试之间共享
AtmGui 子类,那么创建一个完整的(非匿名)成员类是值得的。
我们还使用了实例变量来存储对模仿对象的引用。这是在测试方法和特殊化类之间共享数据的最简单方法。这是可以接受的,因为我们的测试框架不是多线程的或可重入的。(如果它是多线程的或可重入的,则必须用
synchronized 块保护我们自己。)
最后,我们将模仿对象本身定义为测试类的专用内部类 ― 这通常是一种便利的方法,
因为将模仿对象就放在使用它的测试代码旁边会更加清楚,又因为内部类有权访问包含它们的类的实例变量。
小心不出大错
因为我们覆盖了工厂方法来编写这个测试,所以其结果是:我们的测试不再包括任何
原始创建代码(现在它在基类的工厂方法内部)。
添加确实包括该代码的测试也许是有益的。这与调用基类的工厂方法并断言返回对象具有正确类型一样简单。例如:
AtmGui atm = new AtmGui();
Transaction t = atm.createTransaction();
assertTrue(!(t instanceof MockTransaction));
|
注:相反,
assertTrue(t instanceof Transaction) 不能满足,因为
MockTransaction 也是
Transaction 。
从工厂方法到抽象工厂
此时,您可能很想更进一步并用成熟的抽象工厂对象替换工厂方法,
如 Erich Gamma 等人在
设计模式中详细描述的那样。(请参阅
参考资料)。
实际上,许多人已经用工厂对象来着手这种方法,而不是用工厂方法 ― 我们以前是这样做的,但很快就放弃了。
将第三种对象类型(角色)引入系统会有一些潜在的缺点:
- 它增加了复杂性,而没有相应地增加功能。
- 它会迫使您更改目标对象的公用接口。如果必须传入抽象工厂对象,那么您必须添加一个新的公用构造函数或赋值(mutator)方法。
- 许多语言对于“工厂”这一概念都附有一些约定,它们会使您误入歧途。例如,在 Java 语言中,工厂通常作为静态方法实现;在这种情况下,这是不合适的。
请记住,本练习的宗旨是使对象更易于
测试。通常,
用于可测性的设计可以将对象的 API 推向一种更清晰更模块化的状态。但它会走得太远。测试驱动的设计更改不应该污染原始对象的公用接口。
在 ATM 示例中,对于产品代码,
AtmGui 对象始终只产生一种类型的
Transaction 对象(实际类型)。测试代码希望它产生另一种类型的对象(模仿对象)。但强迫公用 API 适应工厂对象或抽象工厂(只因为测试代码要求它这样)是错误的设计。
如果产品代码无需实例化该合作者的多个类型,那么添加该功能将使最终的设计不必要地变得难于理解。
参考资料
作者简介
 | |  | William Pietri 的父亲是一位系统分析师和企业家,William 在十三岁时就开始利用计算机赚取午餐费。
从那以后,他几乎从事过技术领域的各个方面,从技术支持到系统管理到软件工程到用户界面设计。
他是
Scissor(技术咨询公司)的创办人。可以通过
william@scissor.com 与 William 联系。
|
对本文的评价
|