测试驱动开发是软件开发的重要部分。如果代码不进行测试,是不可靠的。所有代码都必须测试,而且理想情况下应该在编写代码之前编写测试。但是,有些东西容易测试,有些东西不容易。如果要编写一个代表货币值的简单的类,那么很容易测试把 $1.23 和 $2.8 相加是否能够得出 $4.03,而不是 $3.03 或 $4.029999998。测试是否不会出现 $7.465 这样的货币值也不太困难。但是,如何测试把 $7.50 转换为 €5.88 的方法呢(尤其是在通过连接数据库查询随时变动的汇率信息的情况下)?在每次运行程序时,amount.toEuros() 的正确结果都可能有变化。
答案是 mock 对象。测试并不通过连接真正的服务器来获取新的汇率信息,而是连接一个 mock 服务器,它总是返回相同的汇率。这样可以得到可预测的结果,可以根据它进行测试。毕竟,测试的目标是 toEuros() 方法中的逻辑,而不是服务器是否发送正确的值。(那是构建服务器的开发人员要操心的事)。这种 mock 对象有时候称为 fake。
mock 对象还有助于测试错误条件。例如,如果 toEuros() 方法试图获取新的汇率,但是网络中断了,那么会发生什么?可以把以太网线从计算机上拔出来,然后运行测试,但是编写一个模拟网络故障的 mock 对象省事得多。
mock 对象还可以测试类的行为。通过把断言放在 mock 代码中,可以检查要测试的代码是否在适当的时候把适当的参数传递给它的协作者。可以通过 mock 查看和测试类的私有部分,而不需要通过不必要的公共方法公开它们。
后,mock 对象有助于从测试中消除依赖项。它们使测试更单元化。涉及 mock 对象的测试中的失败很可能是要测试的方法中的失败,不太可能是依赖项中的问题。这有助于隔离问题和简化调试。
EasyMock 是一个针对 Java 编程语言的开放源码 mock 对象库,可以帮助您快速轻松地创建用于这些用途的 mock 对象。EasyMock 使用动态代理,让您只用一行代码能够创建任何接口的基本实现。通过添加 EasyMock 类扩展,还可以为类创建 mock。可以针对任何用途配置这些 mock,从方法签名中的简单哑参数到检验一系列方法调用的多调用测试。
EasyMock 简介
现在通过一个具体示例演示 EasyMock 的工作方式。清单 1 是虚构的 ExchangeRate 接口。与任何接口一样,接口只说明实例要做什么,而不指定应该怎么做。例如,它并没有指定从 Yahoo 金融服务、政府还是其他地方获取汇率数据。
清单 1. ExchangeRate
import java.io.IOException;
public interface ExchangeRate {
double getRate(String inputCurrency, String outputCurrency) throws IOException;
}
清单 2 是假定的 Currency 类的骨架。它实际上相当复杂,很可能包含 bug。(您不必猜了:确实有 bug,实际上有不少)。
清单 2. Currency 类
import java.io.IOException;
public class Currency {
private String units;
private long amount;
private int cents;
public Currency(double amount, String code) {
this.units = code;
setAmount(amount);
}
private void setAmount(double amount) {
this.amount = new Double(amount).longValue();
this.cents = (int) ((amount * 100.0) % 100);
}
public Currency toEuros(ExchangeRate converter) {
if ("EUR".equals(units)) return this;
else {
double input = amount + cents/100.0;
double rate;
try {
rate = converter.getRate(units, "EUR");
double output = input * rate;
return new Currency(output, "EUR");
} catch (IOException ex) {
return null;
}
}
}
public boolean equals(Object o) {
if (o instanceof Currency) {
Currency other = (Currency) o;
return this.units.equals(other.units)
&& this.amount == other.amount
&& this.cents == other.cents;
}
return false;
}
public String toString() {
return amount + "." + Math.abs(cents) + " " + units;
}
}
Currency 类设计的一些重点可能不容易一下子看出来。汇率是从这个类之外 传递进来的,并不是在类内部构造的。因此,很有必要为汇率创建 mock,这样在运行测试时不需要与真正的汇率服务器通信。这还使客户机应用程序能够使用不同的汇率数据源。
清单 3 给出一个 JUnit 测试,它检查在汇率为 1.5 的情况下 $2.50 是否会转换为 €3.75。使用 EasyMock 创建一个总是提供值 1.5 的 ExchangeRate 对象。
清单 3. CurrencyTest 类
import junit.framework.TestCase;
import org.easymock.EasyMock;
import java.io.IOException;
public class CurrencyTest extends TestCase {
public void testToEuros() throws IOException {
Currency expected = new Currency(3.75, "EUR");
ExchangeRate mock = EasyMock.createMock(ExchangeRate.class);
EasyMock.expect(mock.getRate("USD", "EUR")).andReturn(1.5);
EasyMock.replay(mock);
Currency actual = testObject.toEuros(mock);
assertEquals(expected, actual);
}
}
老实说,在我第一次运行 清单 3 时失败了,测试中经常出现这种问题。但是,我已经纠正了 bug。这是我们采用 TDD 的原因。
运行这个测试,它通过了。发生了什么?我们来逐行看看这个测试。首先,构造测试对象和预期的结果:
Currency testObject = new Currency(2.50, "USD");
Currency expected = new Currency(3.75, "EUR");
这不是新东西。
接下来,通过把 ExchangeRate 接口的 Class 对象传递给静态的 EasyMock.createMock() 方法,创建这个接口的 mock 版本:
ExchangeRate mock = EasyMock.createMock(ExchangeRate.class);
这是到目前为止不可思议的部分。注意,我可没有编写实现 ExchangeRate 接口的类。另外,EasyMock.createMock() 方法无法返回 ExchangeRate 的实例,它根本不知道这个类型,这个类型是我为本文创建的。即使它能够通过某种奇迹返回 ExchangeRate,但是如果需要模拟另一个接口的实例,又会怎么样呢?
我初看到这个时也非常困惑。我不相信这段代码能够编译,但是它确实可以。这里的 “黑魔法” 来自 Java 1.3 中引入的 Java 5 泛型和动态代理(见 参考资料)。幸运的是,您不需要了解它的工作方式(发明这些诀窍的程序员确实非常聪明)。
下一步同样令人吃惊。为了告诉 mock 期望什么结果,把方法作为参数传递给 EasyMock.expect() 方法。然后调用 andReturn() 指定调用这个方法应该得到什么结果:
EasyMock.expect(mock.getRate("USD", "EUR")).andReturn(1.5);
EasyMock 记录这个调用,因此知道以后应该重放什么。
如果在使用 mock 之前忘了调用 EasyMock.replay(),那么会出现 IllegalStateException 异常和一个没有什么帮助的错误消息:missing behavior definition for the preceding method call。
接下来,通过调用 EasyMock.replay() 方法,让 mock 准备重放记录的数据:
EasyMock.replay(mock);
这是让我比较困惑的设计之一。EasyMock.replay() 不会实际重放 mock。而是重新设置 mock,在下一次调用它的方法时,它将开始重放。
现在 mock 准备好了,我把它作为参数传递给要测试的方法:
为类创建 mock
从实现的角度来看,很难为类创建 mock。不能为类创建动态代理。标准的 EasyMock 框架不支持类的 mock。但是,EasyMock 类扩展使用字节码操作产生相同的效果。您的代码中采用的模式几乎完全一样。只需导入 org.easymock.classextension.EasyMock 而不是 org.easymock.EasyMock。为类创建 mock 允许把类中的一部分方法替换为 mock,而其他方法保持不变。
Currency actual = testObject.toEuros(mock);
后,检查结果是否符合预期:
assertEquals(expected, actual);
这完成了。如果有一个需要返回特定值的接口需要测试,可以快速地创建一个 mock。这确实很容易。ExchangeRate 接口很小很简单,很容易为它手工编写 mock 类。但是,接口越大越复杂,越难为每个单元测试编写单独的 mock。通过使用 EasyMock,只需一行代码能够创建 java.sql.ResultSet 或 org.xml.sax.ContentHandler 这样的大型接口的实现,然后向它们提供运行测试所需的行为。
测试异常
mock 常见的用途之一是测试异常条件。例如,无法简便地根据需要制造网络故障,但是可以创建模拟网络故障的 mock。
当 getRate() 抛出 IOException 时,Currency 类应该返回 null。清单 4 测试这一点:
清单 4. 测试方法是否抛出正确的异常
public void testExchangeRateServerUnavailable() throws IOException {
ExchangeRate mock = EasyMock.createMock(ExchangeRate.class);
EasyMock.expect(mock.getRate("USD", "EUR")).andThrow(new IOException());
EasyMock.replay(mock);
Currency actual = testObject.toEuros(mock);
assertNull(actual);
}
这里的新东西是 andThrow() 方法。顾名思义,它只是让 getRate() 方法在被调用时抛出指定的异常。
可以抛出您需要的任何类型的异常(已检查、运行时或错误),只要方法签名支持它即可。这对于测试极其少见的条件(例如内存耗尽错误或无法找到类定义)或表示虚拟机 bug 的条件(比如 UTF-8 字符编码不可用)尤其有帮助。