单元测试已被广泛接受为软件开发的“最佳实践”。 编写对象时,还必须提供一个自动化测试类,其中包含使对象步调一致的方法,使用各种参数调用其各种公共方法并确保返回的值合适。
当您处理简单的数据或服务对象时,编写单元测试非常简单。 但是,许多对象依赖于其他对象或基础结构层。 当测试这些对象时,实例化这些协作者通常很昂贵,不切实际或效率低下。
例如,要对使用数据库的对象进行单元测试,安装,配置和播种数据库的本地副本,运行测试然后再次关闭本地数据库可能会很麻烦。 模拟对象提供了摆脱这种困境的方法。 模拟对象符合真实对象的接口,但是仅具有足以欺骗测试对象并跟踪其行为的代码。 例如,用于特定单元测试的数据库连接可能会记录查询,同时始终返回相同的硬连线结果。 只要要测试的类的行为符合预期,它就不会注意到差异,并且单元测试可以检查是否发出了正确的查询。
使用模拟对象进行测试的常见编码样式是:
尽管此模式在许多情况下非常有效,但有时无法将模拟对象传递到要测试的对象中。 相反,该对象旨在创建,查找或以其他方式获取其协作者。
例如,被测试的对象可能需要获得对Enterprise JavaBean(EJB)组件或远程对象的引用。 或者被测对象可能会使用副作用,这些副作用在单元测试中可能是不希望的,例如删除文件的File
对象。
常识表明,这种情况提供了重构对象的机会,使其更易于测试。 例如,您可以更改方法签名,以便传递协作者对象。
Nicholas Lesiecki指出,重构并非总是可取的,它也不总是使代码更清晰或更易于理解。 在许多情况下,更改方法签名以使协作者成为参数将导致该方法的原始调用者内部的代码混乱,未经测试。
问题的核心是对象正在“内部”获得这些对象。 任何解决方案都必须适用于此创建代码的所有出现。 为了解决此问题,Lesiecki使用查找方面或创建方面。 在此解决方案中,执行查找的代码将自动替换为返回模拟对象的代码。
由于AspectJ对于某些用户而言不是一种选择,因此在本文中我们提供了另一种方法。 因为从根本上讲,这是一种重构,所以我们将遵循Martin Fowler在他的开创性著作《 重构:改进现有代码的设计》中建立的表示惯例(请参阅参考资料 )。 (我们的代码基于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中所示的单元测试代码:
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);
}
}
}
此设计引入了系统中的对象扮演的以下角色:
重构包括许多小的技术步骤。 这些统称为力学 。 如果您像菜谱食谱一样密切关注机械原理,那么您应该能够轻松地学习重构。
protected
关键字)。 假设您正在为银行的自动柜员机编写测试。 这些测试之一可能类似于清单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:
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所示(比较粗体源代码以查看我们要更改的内容):
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所示:
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所示。
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
块来保护自己。)
最后,我们将模拟对象本身定义为测试类的私有内部类,这通常是一种便捷的方法,因为将模拟对象放置在使用该模拟对象的测试代码旁边更加容易,而且内部类可以访问实例他们周围阶级的变量。
因为我们覆盖了工厂方法来编写此测试,所以事实证明,我们不再对原始创建代码(现在位于基类的工厂方法内部)进行任何测试。 添加明确涵盖此代码的测试可能会有所帮助。 这就像调用基类的factory方法并断言返回的对象是正确类型一样简单。 例如:
AtmGui atm = new AtmGui();
Transaction t = atm.createTransaction();
assertTrue(!(t instanceof MockTransaction));
注意,相反, assertTrue(t instanceof Transaction)
不能满足要求,因为MockTransaction
也是一个Transaction
。
在这一点上,您可能会更进一步,将工厂方法替换为成熟的抽象工厂对象,这在Erich Gamma等人的《 设计模式》中有详细介绍。 (请参阅参考资料 )。 实际上,许多方法本来是从工厂对象而不是工厂方法开始的,但是我们很快就放弃了。
将第三种对象类型(角色)引入系统具有一些潜在的缺点:
请记住,此练习的全部目的是使对象更易于测试 。 通常,针对可测试性进行设计可以将对象的API推向更清洁,更模块化的状态。 但这可能太过分了。 测试驱动的设计更改不应污染原始对象的公共接口。
在ATM示例中,就生产代码而言, AtmGui
对象仅生成一种类型的Transaction
对象(真实类型)。 测试代码希望它产生不同的类型(模拟)。 但是,仅由于测试代码希望它强制公共API容纳工厂对象或抽象工厂是错误的设计。 如果生产代码不需要实例化此协作者的许多类型,则添加该功能将使所产生的设计不必要地难以理解。
翻译自: https://www.ibm.com/developerworks/java/library/j-mocktest/index.html