答案是 mock 對(duì)象。測試并不通過連接真正的服務(wù)器來獲取最新的匯率信息,而是連接一個(gè) mock 服務(wù)器,它總是返回相同的匯率。這樣就可以得到可預(yù)測的結(jié)果,可以根據(jù)它進(jìn)行測試。畢竟,測試的目標(biāo)是 toEuros() 方法中的邏輯,而不是服務(wù)器是否發(fā)送正確的值。(那是構(gòu)建服務(wù)器的開發(fā)人員要操心的事)。這種 mock 對(duì)象有時(shí)候稱為 fake。
mock 對(duì)象還有助于測試錯(cuò)誤條件。例如,如果 toEuros() 方法試圖獲取最新的匯率,但是網(wǎng)絡(luò)中斷了,那么會(huì)發(fā)生什么?可以把以太網(wǎng)線從計(jì)算機(jī)上拔出來,然后運(yùn)行測試,但是編寫一個(gè)模擬網(wǎng)絡(luò)故障的 mock 對(duì)象省事得多。
mock 對(duì)象還可以測試類的行為。通過把斷言放在 mock 代碼中,可以檢查要測試的代碼是否在適當(dāng)?shù)臅r(shí)候把適當(dāng)?shù)膮?shù)傳遞給它的協(xié)作者。可以通過 mock 查看和測試類的私有部分,而不需要通過不必要的公共方法公開它們。
最后,mock 對(duì)象有助于從測試中消除依賴項(xiàng)。它們使測試更單元化。涉及 mock 對(duì)象的測試中的失敗很可能是要測試的方法中的失敗,不太可能是依賴項(xiàng)中的問題。這有助于隔離問題和簡化調(diào)試。
double getRate(String inputCurrency, String outputCurrency) throws IOException;
清單 2 是假定的 Currency 類的骨架。它實(shí)際上相當(dāng)復(fù)雜,很可能包含 bug。(您不必猜了:確實(shí)有 bug,實(shí)際上有不少)。
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 類設(shè)計(jì)的一些重點(diǎn)可能不容易一下子看出來。匯率是從這個(gè)類之外 傳遞進(jìn)來的,并不是在類內(nèi)部構(gòu)造的。因此,很有必要為匯率創(chuàng)建 mock,這樣在運(yùn)行測試時(shí)就不需要與真正的匯率服務(wù)器通信。這還使客戶機(jī)應(yīng)用程序能夠使用不同的匯率數(shù)據(jù)源。
清單 3 給出一個(gè) JUnit 測試,它檢查在匯率為 1.5 的情況下 $2.50 是否會(huì)轉(zhuǎn)換為 €3.75。使用 EasyMock 創(chuàng)建一個(gè)總是提供值 1.5 的ExchangeRate 對(duì)象。
清單 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); } } |
老實(shí)說,在我第一次運(yùn)行 清單 3 時(shí)失敗了,測試中經(jīng)常出現(xiàn)這種問題。但是,我已經(jīng)糾正了 bug。這就是我們采用 TDD 的原因。
運(yùn)行這個(gè)測試,它通過了。發(fā)生了什么?我們來逐行看看這個(gè)測試。首先,構(gòu)造測試對(duì)象和預(yù)期的結(jié)果:
Currency testObject = new Currency(2.50, "USD");
Currency expected = new Currency(3.75, "EUR");
這不是新東西。
接下來,通過把 ExchangeRate 接口的 Class 對(duì)象傳遞給靜態(tài)的 EasyMock.createMock() 方法,創(chuàng)建這個(gè)接口的 mock 版本:
ExchangeRate mock = EasyMock.createMock(ExchangeRate.class);
這是到目前為止最不可思議的部分。注意,我可沒有編寫實(shí)現(xiàn) ExchangeRate 接口的類。另外,EasyMock.createMock() 方法絕對(duì)無法返回ExchangeRate 的實(shí)例,它根本不知道這個(gè)類型,這個(gè)類型是我為本文創(chuàng)建的。即使它能夠通過某種奇跡返回 ExchangeRate,但是如果需要模擬另一個(gè)接口的實(shí)例,又會(huì)怎么樣呢?
我最初看到這個(gè)時(shí)也非常困惑。我不相信這段代碼能夠編譯,但是它確實(shí)可以。這里的 “黑魔法” 來自 Java 1.3 中引入的 Java 5 泛型和動(dòng)態(tài)代理(見 參考資料)。幸運(yùn)的是,您不需要了解它的工作方式(發(fā)明這些訣竅的程序員確實(shí)非常聰明)。
下一步同樣令人吃驚。為了告訴 mock 期望什么結(jié)果,把方法作為參數(shù)傳遞給 EasyMock.expect() 方法。然后調(diào)用 andReturn() 指定調(diào)用這個(gè)方法應(yīng)該得到什么結(jié)果:
EasyMock.expect(mock.getRate("USD", "EUR")).andReturn(1.5);
EasyMock 記錄這個(gè)調(diào)用,因此知道以后應(yīng)該重放什么。
如果在使用 mock 之前忘了調(diào)用 EasyMock.replay(),那么會(huì)出現(xiàn) IllegalStateException 異常和一個(gè)沒有什么幫助的錯(cuò)誤消息:missing behavior definition for the preceding method call。
接下來,通過調(diào)用 EasyMock.replay() 方法,讓 mock 準(zhǔn)備重放記錄的數(shù)據(jù):
EasyMock.replay(mock);
這是讓我比較困惑的設(shè)計(jì)之一。EasyMock.replay() 不會(huì)實(shí)際重放 mock。而是重新設(shè)置 mock,在下一次調(diào)用它的方法時(shí),它將開始重放。
現(xiàn)在 mock 準(zhǔn)備好了,我把它作為參數(shù)傳遞給要測試的方法:
為類創(chuàng)建 mock
從實(shí)現(xiàn)的角度來看,很難為類創(chuàng)建 mock。不能為類創(chuàng)建動(dòng)態(tài)代理。標(biāo)準(zhǔn)的 EasyMock 框架不支持類的 mock。但是,EasyMock 類擴(kuò)展使用字節(jié)碼操作產(chǎn)生相同的效果。您的代碼中采用的模式幾乎完全一樣。只需導(dǎo)入org.easymock.classextension.EasyMock 而不是org.easymock.EasyMock。為類創(chuàng)建 mock 允許把類中的一部分方法替換為 mock,而其他方法保持不變。
Currency actual = testObject.toEuros(mock);
最后,檢查結(jié)果是否符合預(yù)期:
assertEquals(expected, actual);
這就完成了。如果有一個(gè)需要返回特定值的接口需要測試,就可以快速地創(chuàng)建一個(gè) mock。這確實(shí)很容易。ExchangeRate 接口很小很簡單,很容易為它手工編寫 mock 類。但是,接口越大越復(fù)雜,就越難為每個(gè)單元測試編寫單獨(dú)的 mock。通過使用 EasyMock,只需一行代碼就能夠創(chuàng)建 java.sql.ResultSet 或 org.xml.sax.ContentHandler 這樣的大型接口的實(shí)現(xiàn),然后向它們提供運(yùn)行測試所需的行為。
測試異常
mock 最常見的用途之一是測試異常條件。例如,無法簡便地根據(jù)需要制造網(wǎng)絡(luò)故障,但是可以創(chuàng)建模擬網(wǎng)絡(luò)故障的 mock。
當(dāng) getRate() 拋出 IOException 時(shí),Currency 類應(yīng)該返回 null。清單 4 測試這一點(diǎn):
清單 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() 方法在被調(diào)用時(shí)拋出指定的異常。
可以拋出您需要的任何類型的異常(已檢查、運(yùn)行時(shí)或錯(cuò)誤),只要方法簽名支持它即可。這對(duì)于測試極其少見的條件(例如內(nèi)存耗盡錯(cuò)誤或無法找到類定義)或表示虛擬機(jī) bug 的條件(比如 UTF-8 字符編碼不可用)尤其有幫助。
設(shè)置預(yù)期
EasyMock 不只是能夠用固定的結(jié)果響應(yīng)固定的輸入。它還可以檢查輸入是否符合預(yù)期。例如,假設(shè) toEuros() 方法有一個(gè) bug(見清單 5),它返回以歐元為單位的結(jié)果,但是獲取的是加拿大元的匯率。這會(huì)讓客戶發(fā)一筆意外之財(cái)或遭受重大損失。
清單 5. 有 bug 的 toEuros() 方法
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, "CAD"); double output = input * rate; return new Currency(output, "EUR"); } catch (IOException e) { return null; } } } |
但是,不需要為此編寫另一個(gè)測試。清單 4 中的 testToEuros 能夠捕捉到這個(gè) bug。當(dāng)對(duì)這段代碼運(yùn)行清單 4 中的測試時(shí),測試會(huì)失敗并顯示以下錯(cuò)誤消息:
"java.lang.AssertionError:
Unexpected method call getRate("USD", "CAD"):
getRate("USD", "EUR"): expected: 1, actual: 0".
注意,這并不是我設(shè)置的斷言。EasyMock 注意到我傳遞的參數(shù)不符合測試用例。
在默認(rèn)情況下,EasyMock 只允許測試用例用指定的參數(shù)調(diào)用指定的方法。但是,有時(shí)候這有點(diǎn)兒太嚴(yán)格了,所以有辦法放寬這一限制。例如,假設(shè)希望允許把任何字符串傳遞給 getRate() 方法,而不僅限于 USD 和 EUR。那么,可以指定 EasyMock.anyObject() 而不是顯式的字符串,如下所示:
EasyMock.expect(mock.getRate(
(String) EasyMock.anyObject(),
(String) EasyMock.anyObject())).andReturn(1.5);
還可以更挑剔一點(diǎn)兒,通過指定 EasyMock.notNull() 只允許非 null 字符串:
EasyMock.expect(mock.getRate(
(String) EasyMock.notNull(),
(String) EasyMock.notNull())).andReturn(1.5);
靜態(tài)類型檢查會(huì)防止把非 String 對(duì)象傳遞給這個(gè)方法。但是,現(xiàn)在允許傳遞 USD 和 EUR 之外的其他 String。還可以通過EasyMock.matches() 使用更顯式的正則表達(dá)式。下面指定需要一個(gè)三字母的大寫 ASCII String:
EasyMock.expect(mock.getRate(
(String) EasyMock.matches("[A-Z][A-Z][A-Z]"),
(String) EasyMock.matches("[A-Z][A-Z][A-Z]"))).andReturn(1.5);
使用 EasyMock.find() 而不是 EasyMock.matches(),就可以接受任何包含三字母大寫子 String 的 String。 EasyMock 為基本數(shù)據(jù)類型提供相似的方法:
EasyMock.anyInt()
EasyMock.anyShort()
EasyMock.anyByte()
EasyMock.anyLong()
EasyMock.anyFloat()
EasyMock.anyDouble()
EasyMock.anyBoolean()
對(duì)于數(shù)字類型,還可以使用 EasyMock.lt(x) 接受小于 x 的任何值,或使用 EasyMock.gt(x) 接受大于 x 的任何值。
在檢查一系列預(yù)期時(shí),可以捕捉一個(gè)方法調(diào)用的結(jié)果或參數(shù),然后與傳遞給另一個(gè)方法調(diào)用的值進(jìn)行比較。最后,通過定義定制的匹配器,可以檢查參數(shù)的任何細(xì)節(jié),但是這個(gè)過程比較復(fù)雜。但是,對(duì)于大多數(shù)測試,EasyMock.anyInt()、EasyMock.matches() 和 EasyMock.eq() 這樣的基本匹配器已經(jīng)足夠了。
嚴(yán)格的 mock 和次序檢查
EasyMock 不僅能夠檢查是否用正確的參數(shù)調(diào)用預(yù)期的方法。它還可以檢查是否以正確的次序調(diào)用這些方法,而且只調(diào)用了這些方法。在默認(rèn)情況下,不執(zhí)行這種檢查。要想啟用它,應(yīng)該在測試方法末尾調(diào)用 EasyMock.verify(mock)。例如,如果 toEuros() 方法不只一次調(diào)用getRate(),清單 6 就會(huì)失敗。
清單 6. 檢查是否只調(diào)用 getRate() 一次
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); EasyMock.verify(mock); } |
EasyMock.verify() 究竟做哪些檢查取決于它采用的操作模式:
Normal — EasyMock.createMock():必須用指定的參數(shù)調(diào)用所有預(yù)期的方法。但是,不考慮調(diào)用這些方法的次序。調(diào)用未預(yù)期的方法會(huì)導(dǎo)致測試失敗。
Strict — EasyMock.createStrictMock():必須以指定的次序用預(yù)期的參數(shù)調(diào)用所有預(yù)期的方法。調(diào)用未預(yù)期的方法會(huì)導(dǎo)致測試失敗。
Nice — EasyMock.createNiceMock():必須以任意次序用指定的參數(shù)調(diào)用所有預(yù)期的方法。調(diào)用未預(yù)期的方法不會(huì) 導(dǎo)致測試失敗。Nice mock 為沒有顯式地提供 mock 的方法提供合理的默認(rèn)值。返回?cái)?shù)字的方法返回 0,返回布爾值的方法返回 false。返回對(duì)象的方法返回 null。
檢查調(diào)用方法的次序和次數(shù)對(duì)于大型接口和大型測試更有意義。例如,請(qǐng)考慮 org.xml.sax.ContentHandler 接口。如果要測試一個(gè) XML 解析器,希望輸入文檔并檢查解析器是否以正確的次序調(diào)用 ContentHandler 中正確的方法。例如,請(qǐng)考慮清單 7 中的簡單 XML 文檔:
清單 7. 簡單的 XML 文檔
<root>
Hello World!
</root>
根據(jù) SAX 規(guī)范,在解析器解析文檔時(shí),它應(yīng)該按以下次序調(diào)用這些方法:
setDocumentLocator()
startDocument()
startElement()
characters()
endElement()
endDocument()
但是,更有意思的是,對(duì) setDocumentLocator() 的調(diào)用是可選的;解析器可以多次調(diào)用 characters()。它們不需要在一次調(diào)用中傳遞盡可能多的連續(xù)文本,實(shí)際上大多數(shù)解析器不這么做。即使是對(duì)于清單 7 這樣的簡單文檔,也很難用傳統(tǒng)的方法測試 XML 解析器,但是 EasyMock 大大簡化了這個(gè)任務(wù),見清單 8:
清單 8. 測試 XML 解析器
import java.io.*; import org.easymock.EasyMock; import org.xml.sax.*; import org.xml.sax.helpers.XMLReaderFactory; import junit.framework.TestCase; public class XMLParserTest extends TestCase { private XMLReader parser; protected void setUp() throws Exception { parser = XMLReaderFactory.createXMLReader(); } public void testSimpleDoc() throws IOException, SAXException { String doc = "<root>\n Hello World!\n</root>"; ContentHandler mock = EasyMock.createStrictMock(ContentHandler.class); mock.setDocumentLocator((Locator) EasyMock.anyObject()); EasyMock.expectLastCall().times(0, 1); mock.startDocument(); mock.startElement(EasyMock.eq(""), EasyMock.eq("root"), EasyMock.eq("root"), (Attributes) EasyMock.anyObject()); mock.characters((char[]) EasyMock.anyObject(), EasyMock.anyInt(), EasyMock.anyInt()); EasyMock.expectLastCall().atLeastOnce(); mock.endElement(EasyMock.eq(""), EasyMock.eq("root"), EasyMock.eq("root")); mock.endDocument(); EasyMock.replay(mock); parser.setContentHandler(mock); InputStream in = new ByteArrayInputStream(doc.getBytes("UTF-8")); parser.parse(new InputSource(in)); EasyMock.verify(mock); } } |
這個(gè)測試展示了幾種新技巧。首先,它使用一個(gè) strict mock,因此要求符合指定的次序。例如,不希望解析器在調(diào)用 startDocument() 之前調(diào)用 endDocument()。
第二,要測試的所有方法都返回 void。這意味著不能把它們作為參數(shù)傳遞給 EasyMock.expect()(就像對(duì) getRate() 所做的)。(EasyMock 在許多方面能夠 “欺騙” 編譯器,但是還不足以讓編譯器相信 void 是有效的參數(shù)類型)。因此,要在 mock 上調(diào)用 void 方法,由 EasyMock 捕捉結(jié)果。如果需要修改預(yù)期的細(xì)節(jié),那么在調(diào)用 mock 方法之后立即調(diào)用 EasyMock.expectLastCall()。另外注意,不能作為預(yù)期參數(shù)傳遞任何 String、int 和數(shù)組。必須先用 EasyMock.eq() 包裝它們,這樣才能在預(yù)期中捕捉它們的值。
清單 8 使用 EasyMock.expectLastCall() 調(diào)整預(yù)期的方法調(diào)用次數(shù)。在默認(rèn)情況下,預(yù)期的方法調(diào)用次數(shù)是一次。但是,我通過調(diào)用.times(0, 1) 把 setDocumentLocator() 設(shè)置為可選的。這指定調(diào)用此方法的次數(shù)必須是零次或一次。當(dāng)然,可以根據(jù)需要把預(yù)期的方法調(diào)用次數(shù)設(shè)置為任何范圍,比如 1-10 次、3-30 次。對(duì)于 characters(),我實(shí)際上不知道將調(diào)用它多少次,但是知道必須至少調(diào)用一次,所以對(duì)它使用 .atLeastOnce()。如果這是非 void 方法,就可以對(duì)預(yù)期直接應(yīng)用 times(0, 1) 和 atLeastOnce()。但是,因?yàn)檫@些方法返回 void,所以必須通過 EasyMock.expectLastCall() 設(shè)置它們。
最后注意,這里對(duì) characters() 的參數(shù)使用了 EasyMock.anyObject() 和 EasyMock.anyInt()。這考慮到了解析器向 ContentHandler 傳遞文本的各種方式。
mock 和真實(shí)性
有必要使用 EasyMock 嗎?其實(shí),手工編寫的 mock 類也能夠?qū)崿F(xiàn) EasyMock 的功能,但是手工編寫的類只能適用于某些項(xiàng)目。例如,對(duì)于 清單 3,手工編寫一個(gè)使用匿名內(nèi)部類的 mock 也很容易,代碼很緊湊,對(duì)于不熟悉 EasyMock 的開發(fā)人員可讀性可能更好。但是,它是一個(gè)專門為本文構(gòu)造的簡單示例。在為 org.w3c.dom.Node(25 個(gè)方法)或 java.sql.ResultSet(139 個(gè)方法而且還在增加)這樣的大型接口創(chuàng)建 mock 時(shí),EasyMock 能夠大大節(jié)省時(shí)間,以最低的成本創(chuàng)建更短更可讀的代碼。
最后,提出一條警告:使用 mock 對(duì)象可能做得太過分。可能把太多的東西替換為 mock,導(dǎo)致即使在代碼質(zhì)量很差的情況下,測試仍然總是能夠通過。替換為 mock 的東西越多,接受測試的東西就越少。依賴庫以及方法與其調(diào)用的方法之間的交互中可能存在許多 bug。把依賴項(xiàng)替換為 mock 會(huì)隱藏許多實(shí)際上可能發(fā)現(xiàn)的 bug。在任何情況下,mock 都不應(yīng)該是您的第一選擇。如果能夠使用真實(shí)的依賴項(xiàng),就應(yīng)該這么做。mock 是真實(shí)類的粗糙的替代品。但是,如果由于某種原因無法用真實(shí)的類可靠且自動(dòng)地進(jìn)行測試,那么用 mock 進(jìn)行測試肯定比根本不測試強(qiáng)。