測試驅動開發是軟件開發的重要部分。如果代碼不進行測試,是不可靠的。所有代碼都必須測試,而且理想情況下應該在編寫代碼之前編寫測試。但是,有些東西容易測試,有些東西不容易。如果要編寫一個代表貨幣值的簡單的類,那么很容易測試把 $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 字符編碼不可用)尤其有幫助。