現代化模擬工具與黑魔法
權力腐敗的範例
現代化模擬工具對我們處理舊有程式碼的能力產生的正面影響,以及使用這些工具可能產生的負面影響。
2012 年 9 月 10 日
在 有效處理舊有程式碼 中,Michael Feathers 將舊有程式碼定義為沒有自動化測試的程式碼。2010 年某時,我被介紹給 JMockIt,並了解到它如何讓我們撰寫看似違反 Java 語意的自動化測試。例如,可以在測試執行期間替換靜態方法。如果我要使用 Michael Feathers 建議的「舊」風格,我會執行一些動作,例如引入執行個體委派器,以編寫我想要撰寫的測試。現在,使用現代化模擬工具,我可以跳過這個步驟。我最初的反應是驚訝,因為我認為這是開啟難以測試的現有程式碼並更快地撰寫程式碼的方法,而且在現有程式碼中修改的次數更少。
快轉到 2011 年底,當時我在柏林教授一堂課,並被要求在使用者小組中進行簡報(可以在這裡觀看該演講)。在該影片中,你可以看到我試圖使用 JMockIt 讓一些舊有程式碼接受測試,最後我成功做到這一點,而沒有實際變更底層程式碼。影片中沒有顯示的是,隔天在課堂上,我們採用結果程式碼,並對其套用更傳統的舊有程式碼重構技術,然後重新撰寫基於 JMockIt 的測試。結果令人驚嘆,我相信結果不言而喻。以下是完整重現的故事,我希望刪除了大部分的混淆。
常見嫌疑犯
要開始,以下是供您考慮的一些程式碼
public static BigDecimal convertFromTo(String fromCurrency, String toCurrency) { Map<String, String> symbolToName = currencySymbols(); if (!symbolToName.containsKey(fromCurrency)) throw new IllegalArgumentException(String.format( "Invalid from currency: %s", fromCurrency)); if (!symbolToName.containsKey(toCurrency)) throw new IllegalArgumentException(String.format( "Invalid to currency: %s", toCurrency)); String url = String.format("http://www.gocurrency.com/v2/dorate.php?inV=1&from=%s&to=%s&Calculate=Convert", toCurrency, fromCurrency); try { HttpClient httpclient = new DefaultHttpClient(); HttpGet httpget = new HttpGet(url); HttpResponse response = httpclient.execute(httpget); HttpEntity entity = response.getEntity(); StringBuffer result = new StringBuffer(); if (entity != null) { InputStream instream = entity.getContent(); InputStreamReader irs = new InputStreamReader(instream); BufferedReader br = new BufferedReader(irs); String l; while ((l = br.readLine()) != null) { result.append(l); } } String theWholeThing = result.toString(); int start = theWholeThing.lastIndexOf("<div id=\"converter_results\"><ul><li>"); String substring = result.substring(start); int startOfInterestingStuff = substring.indexOf("<b>") + 3; int endOfIntererestingStuff = substring.indexOf("</b>", startOfInterestingStuff); String interestingStuff = substring.substring( startOfInterestingStuff, endOfIntererestingStuff); String[] parts = interestingStuff.split("="); String value = parts[1].trim().split(" ")[0]; BigDecimal bottom = new BigDecimal(value); return bottom; } catch (Exception e) { throw new RuntimeException(e); } }
表面上,這段程式碼以相當迂迴的方式篩選刮取貨幣轉換資訊,然而這段程式碼的真正目的是討論糟糕的編碼選擇及其對我們理解、測試和維護這段程式碼的能力的影響。
測試挑戰
在深入探討方法本身之前,首先觀察這是一個靜態方法。在 Java、C#、C++(以及許多其他語言)中,靜態方法總是在編譯(或連結)時繫結。這表示呼叫靜態方法的程式碼直接與這些方法耦合。方法的選擇和呼叫機制太早選定,無法輕易讓測試設定一個環境,讓它們轉移到測試控制的某個項目。可以使用 Michael Feathers 稱之為「連結接縫」的東西在執行階段呼叫不同的靜態方法。在 Java 的情況下,您可以確保類別的不同版本(包含靜態方法)會較早出現在類別路徑中。下方有更多相關資訊。
驗證
這個方法執行的第一件事是執行一些基本驗證。它確認提供為方法引數的符號實際上存在。為此,它呼叫同一個類別中的另一個方法。
Map<String, String> symbolToName = currencySymbols(); if (!symbolToName.containsKey(fromCurrency)) throw new IllegalArgumentException(String.format( "Invalid from currency: %s", fromCurrency)); if (!symbolToName.containsKey(toCurrency)) throw new IllegalArgumentException(String.format( "Invalid to currency: %s", toCurrency));
必要
取得已知符號的方法是靜態的,而且在同一個類別中。這使得在語言中無法模擬它或不執行驗證。雖然總是執行驗證似乎是個好主意,但它確實會增加測試負擔,這使得測試變得有點脆弱,因為它們依賴著隨著時間推移可能會變化的更多事物。這只是表面而已。在測試其他事情時總是測試驗證似乎會讓驗證的檢查更徹底,但事實上,這樣的測試通常表示重複的測試,而不是更深入的測試;它增加了體積,但沒有增加價值。
我只想驗證剖析是否正確。根據撰寫方式,驗證和剖析必須一起以特定順序執行。那個順序似乎很合乎邏輯;要輸入,我需要有效的貨幣。然而,雖然這看起來像必要的限制,但剖析的任何部分都不依賴於有效的貨幣,也不應該依賴。它只關心要剖析的文字是否遵循一個格式,而不是那個格式的一部分碰巧還包括有效的貨幣符號。因此,驗證的業務不必要地滲透到下一個步驟。我會說這是不必要的時間耦合的一個例子。雖然剖析確實在時間上遵循驗證,但因此不應遵循驗證必須是必要的才能讓我們驗證剖析。給定的測試知道它在測試什麼。例如,它知道它想要剖析的輸入是否有效。因此,在剖析之前強制驗證與程式碼的撰寫方式有關。它對剖析來說並非必要。
最後一點值得強調,因為這是一個常見的誤解。根據我的經驗,大多數人會建議在解析之前進行驗證至關重要。從某種意義上來說,這是正確的;為了獲得一些有效的輸入,我需要在實際系統中使用有效的貨幣符號。但是,對於測試的目的,我不需要實際的系統,因此通過驗證的要求實際上是偶然的。如果我們檢查驗證是否有效,並且我們檢查解析是否有效,那麼我們編寫的內容不起作用的可能性有多大?我認為不會,所以我認為不需要一個完全集成的功能測試。有人可能會不同意我的觀點。在一個真實的項目中,我可能會寫一個,因為爭論會比僅編寫自動化檢查花費更多時間。但是,將事情分解成越來越小的部分既是一項必不可少的技能,而且需要多年的時間才能學會。但是,即使你不同意我對這一點的看法,我希望我們可以同意,如果我可以獨立於驗證驗證解析,這將使檢查事情變得更容易。在系統思考概論中,溫伯格描述了計算的平方定律,你可能更熟悉它,即「分而治之」。此程式碼顯然不遵循任何此類規則,而無謂的偶然耦合將使事情變得相對困難。
呼叫 currencySymbols()
如前所述,呼叫靜態方法是一個問題,但更大的問題是,有問題的方法使用HttpClient呼叫系統,因此呼叫它需要網路連線。此呼叫是必要的,或至少由驗證使用。此靜態方法的存在是否與此方法的其他部分本質相關或偶然相關?不要將「如所寫」與「必要」混淆。
傳統選項
為了解決這些問題,我們可以做很多事情
- 使用Sprout 類別或Extract Class將驗證提取到其自己的類別中
- 使此類別中的所有方法為非靜態且非私有,並使用測試子類別以簡化測試
- 使用實例委派器 - 靜態方法保留,但在內部它們會呼叫物件上的實例方法
這些解決方案解決了底層語言設計問題(不可覆寫的靜態元素是它們的設計方式,但並非根本必要,請考慮 Smalltalk、JavaScript、Self 等)。無論如何,選擇哪一種取決於許多因素,包括起點、目前有多少現有程式碼依賴於類別等。
HttpClient
接著,程式碼使用 HttpClient 讀取網頁
HttpClient httpclient = new DefaultHttpClient(); HttpGet httpget = new HttpGet(url); HttpResponse response = httpclient.execute(httpget); HttpEntity entity = response.getEntity();
直接使用
此程式碼直接使用 HttpClient,並使用 new 啟動。繼承是 Java 中最高形式的耦合,其次是呼叫 new。由於此程式碼已撰寫,因此沒有語言定義的方式可以避免使用此類別。您可以透過使用不同的 jar 檔案(或類別路徑)進行測試,來使用連結接縫。在緊急情況下,我會考慮這麼做,但前提是我有權存取程式碼並可以變更它,或者,正如我們將看到的,可以存取我所謂的第四代模擬工具,例如 JMockIt (Java 開放原始碼)、powermock(Java 開放原始碼)或 Isolator.Net(商業版,.Net)
違反依賴反轉
在此範例中,商業領域是貨幣轉換,但商業邏輯直接使用 HttpClient。這是違反依賴反轉原則。在此情況下,如果有人想要取得貨幣轉換,由於程式碼已撰寫,因此嘗試這麼做會引進直接的編譯時間耦合,到需要連線到網際網路的類別。高階元素依賴於低階細節。此程式碼的時效性不如傾向反轉此類依賴關係的替代方案。
修正此問題
解決此問題的選項與驗證相同:引入執行個體方法、建立類別等。然而,有一個更深層的問題。此程式碼不僅直接依賴於網際網路連線,正如我們即將看到的,它會傳回 HTML,而 HTML 必須進行剖析。我們關心的是轉換率,而不是剖析 HTML,但為了取得我們想要的,我們必須經過許多技術層面,而且在經過所有這些之後,我們必須處理 HTML。
檔案 I/O
HttpClient 提供一個 InputStream,然後將其讀取至完成
StringBuffer result = new StringBuffer(); if (entity != null) { InputStream instream = entity.getContent(); InputStreamReader irs = new InputStreamReader(instream); BufferedReader br = new BufferedReader(irs); String l; while ((l = br.readLine()) != null) { result.append(l); } } String theWholeThing = result.toString();
嵌入
與前幾個區段一樣,此程式碼直接嵌入在方法中,讓它更難被忽略。
複雜度和重複
此程式碼並不十分複雜,但要了解它在做什麼,您必須閱讀它。改善溝通的一種方法是減少對它的需求,而程式碼也是如此。由於此程式碼已撰寫,因此您必須閱讀它才能理解它。如果這是它自己的方法或類別,並有一個好的名稱,那麼它可能會更容易傳遞。由於我們傾向於閱讀程式碼的次數多於撰寫程式碼,因此我們可以採取任何措施來減少閱讀程式碼的需求,都是專案生命週期中時間的良好投資。
必須透過方法取得
此程式碼是嵌入式且照寫,每次都必須執行才能執行程式碼。這是時間耦合的另一個範例。正如前一節所暗示的,如果以一種方式組織,讓它更容易理解,那麼當我們嘗試檢查的事物與讀取串流內容無直接關聯時,也可能更容易擺脫它。
剖析
現在串流已轉換成字串,是時候剖析結果了
int start = theWholeThing.lastIndexOf("<div id=\"converter_results\"><ul><li>"); String substring = result.substring(start); int startOfInterestingStuff = substring.indexOf("<b>") + 3; int endOfIntererestingStuff = substring.indexOf("</b>", startOfInterestingStuff); String interestingStuff = substring.substring( startOfInterestingStuff, endOfIntererestingStuff); String[] parts = interestingStuff.split("="); String value = parts[1].trim().split(" ")[0];
第三節與第一節相同...
在這個時候,你可能會注意到我聽起來像跳針唱片。此程式碼具有上述所有問題:必須照寫執行、違反依賴反轉、必須閱讀才能理解。
SRP 違反
此方法是違反單一職責原則的經典示範。它執行許多不同的動作,每個動作都有不同的理由在不同的時間變更。事實上,在此方法的原始形式中,我正在連線一個網站,但它變得不可用,所以我必須將它變更到另一個網站才能取得我想要的資訊。這在幾個地方中斷了事物,說明了 SRP 和 DIP 違反的問題。我需要的,不只是連線到不同的位置 (HttpStuff),我還取得不同的 HTML (剖析),而且我必須取得不同的 URL (再次為 HttpStuff)。
深入探討
現代的模擬工具讓我在上面提到的許多事情變得無關緊要;至少在表面上是如此。與其先嘗試在生產程式碼中修正這些事情,讓我們透過嘗試透過自動化單元測試來執行此方法來深入探討。
設定 - 練習程式碼
開始的一個地方,就是單純嘗試使用空引數或「合理」值來執行有問題的程式碼。由於網域是貨幣轉換,而且此方法採用兩種貨幣,這似乎是一個合理的起點。
以下是自動化單元測試的開頭,用於單純執行程式碼。我的目標是透過此方法。一個注意事項,雖然此程式碼會在網路連線時執行,但在撰寫此測試時,我已關閉我的連線,以確保我的測試不需要網路連線才能執行
@Test public void returnExpectedConversion_v1() { CurrencyConversion.convertFromTo("USD", "EUR"); }
如果網路已啟用,這會執行,但如果沒有,程式碼會產生 java.net.UnknownHostException 。然而,實際發生此情況的地方是在方法 CurrencyConversion.currencySymbols 中,而不是我們關心的方法中。使用較舊的工具,這需要一點工作,但對於我們在本文中選擇的工具而言,則不然:JMockIt
通過驗證
以下是測試的第二個版本,它通過了第一個例外
@Test public void returnExpectedConversion_v2() { new NonStrictExpectations(CurrencyConversion.class) { { CurrencyConversion.currencySymbols(); result = mapFrom("USD", "EUR"); } }; CurrencyConversion.convertFromTo("USD", "EUR"); } private Map<String, String> mapFrom(String... keyValuePairs) { Map<String, String> result = new ConcurrentHashMap<String, String>(); for (int i = 0; i < keyValuePairs.length; ++i) result.put(keyValuePairs[i], keyValuePairs[i]); return result; }
執行此方法時,會擲出相同的例外狀況 UnknownHostException,但例外狀況現在出現在受測方法中,而不是呼叫的方法中。這是一個進步。這允許程式碼通過驗證,但如何做到?
new NonStrictExpectations(CurrencyConversion.class) { { CurrencyConversion.currencySymbols(); result = mapFrom("USD", "EUR"); } };
注意到使用 NonStrictExpectations 建立匿名內部類別嗎? 此表單指示 JMockIt 對 CurrencyConversion 類別 執行某些動作(在本例中替換靜態方法)。內部 {} 中的程式碼是標準的 Java 實例初始化程式。在該實例初始化程式中執行的程式碼指示 JMockIt 替換執行的 currencySymbols 方法。這是類別部分模擬的一個範例;我們替換類別中的其中一個方法,以便在任何時候呼叫該方法時,它會傳回指定給繼承欄位「result」的任何內容。
這涉及一些黑魔法。JMockIt 正在執行一些 Java 位元組碼魔法,而此程式碼使用 JMockIt DSL 來實現此目的。要讓此方法運作,必須將 JMockIt jar 檔案新增到類別路徑中。如果您確定它在 JUnit 的 jar 檔案之前列出,這樣就足夠了。JMockIt 使用在 MANIFEST.MF 檔案中註冊的 JavaAgent 使這一切自動化。
處理客戶端
現在,程式碼在嘗試讀取網頁的部分失敗
HttpClient httpclient = new DefaultHttpClient(); HttpGet httpget = new HttpGet(url); HttpResponse response = httpclient.execute(httpget); HttpEntity entity = response.getEntity();
這段程式碼稍微複雜一些,因為它嵌入在方法中,但我們可以解決這個問題。但是,要做到這一點需要多做一些工作
- 前兩行呼叫 new 。我們需要讓這些使用 new 算子的地方傳回我們控制下的類別。
- 下一行在 HttpClient 上呼叫 execute 方法,所以我們需要控制它。
- 最後一行在 HttpResponse 上呼叫 getEntity 方法,這是前一行的傳回值,所以整體而言這涉及更多內容。
以下是一種一次處理這三個問題的方法
new NonStrictExpectations() { DefaultHttpClient httpclient; HttpResponse response; HttpEntity entity; { httpclient.execute((HttpUriRequest) any); result = response; response.getEntity(); result = entity; entity.getContent(); result = bais; } };
在此使用 NonStrictExpectations 時,第一行沒有傳入任何參數,表示我們正在以某種方式處理實例,而不是靜態內容。這個匿名內部類別有三個欄位:DefaultHttpClient、HttpResponse、HttpEntity。這些類別僅在此測試中完全替換 Java 類別載入器。這表示,例如,呼叫 new DefaultHttpClient 會傳回 JMockIt 建立的類別實例,而不是在 HttpClient jar 中找到的版本。
這是動態連結接縫的一個範例。這是一個連結接縫,就像 Michael Feathers 在他的書中討論的,通常會使用建置腳本/檔案魔法來完成。不過,這與使用建置腳本不同,這是使用一個函式庫,讓它可以在語言「內部」的 Java 程式碼中使用,而不是在語言外部。
這段程式碼取代了那三個類別,但用什麼取代?
- HttpClient.execute 方法會永遠傳回回應,這是 JMockIt 建立的 HttpResponse 子類別的一個實例。
- HttpResponse.getEntity 方法會永遠傳回實體,這是 JMockIt 建立的 HttpEntity 子類別的一個實例。
- HttpEntity.getContent 方法會永遠傳回「bais」,我們會在下一節看到。
別搞錯了,這很強大。事實上,我有一個個人的經驗法則:如果我覺得某件事很酷,那麼它可能不適合實際開發。JMockIt 讓我的「酷蜘蛛感應」過度活躍。
處理檔案 I/O
底層程式碼需要串流的內容。為了建立這個,測試使用一些標準的 Java 魔法來建立一個記憶體中的串流
final ByteArrayInputStream bais = new ByteArrayInputStream( "<div id=\"converter_results\"><ul><li><b>1 USD = 0.98 EUR</b>" .getBytes());
這會從一個字串建立一個記憶體中的 ByteArrayInputStream。我是怎麼知道要放入什麼字串?我必須對底層程式碼進行逆向工程。即便如此,這會讓程式碼執行。
整合
以下是測試作為一個單一方法,而不是像到目前為止那樣分開
@Test public void returnExpectedConversion_v3() throws Exception { final ByteArrayInputStream bais = new ByteArrayInputStream( "<div id=\"converter_results\"><ul><li><b>1 USD = 0.98 EUR</b>" .getBytes()); new NonStrictExpectations() { DefaultHttpClient httpclient; HttpResponse response; HttpEntity entity; { httpclient.execute((HttpUriRequest) any); result = response; response.getEntity(); result = entity; entity.getContent(); result = bais; } }; new NonStrictExpectations(CurrencyConversion.class) { { CurrencyConversion.currencySymbols(); result = mapFrom("USD", "EUR"); } }; BigDecimal result = CurrencyConversion.convertFromTo("USD", "EUR"); assertThat(result.subtract(new BigDecimal(2)), is(lessThanOrEqualTo(new BigDecimal(0.001)))); }
一方面,這相當令人印象深刻。我們設法從頭到尾執行程式碼,而不用觸碰它。如果我們想要寫一些使用這個類別的東西,我們現在有辦法這樣做。通常,在嘗試觸碰一些程式碼之前,我們需要對它進行測試,這樣我們才知道在做完變更後是否破壞了任何東西。這稱為特徵測試。像這樣的寫得很差的程式碼通常會讓這樣做變得非常困難。雖然它仍然很困難,但至少我們可以在不觸碰生產程式碼的情況下這樣做。在程式碼周圍進行特徵測試會讓重構更安全。
所以我們完成了,對吧?
錯。
回到傳統
我們解決了症狀,而不是原因
請注意,JMockIt 讓執行近乎黑魔法的事情成為可能,但我們能做得更好嗎?如果我們嘗試,投資的時間是否值得?在本節中,我們從同一個方法開始,並對程式碼進行一些所謂的傳統重構,以使用更傳統的工具(本例中為手動測試替身)來進行測試。然後,我們將比較、觀察,然後看看會變成什麼樣子。
引入執行個體委派器
靜態方法的一個典型問題是它們無法被覆寫。為了修復這個問題,我們可能只需讓類別改用所有實例方法。不過,讓我們假設在這個範例中,我們需要維持向後相容性,所以我們需要保留靜態方法(根據我的經驗,這並非牽強附會)。
雖然我可能會嘗試先進行此測試,但事實上,我有一個現有的 JMockIt 測試,所以我將僅進行必要的變更。我將透過將 CurrencyConversion 複製到一個新的套件中,並在套件名稱中新增 v2 來執行此操作(此部落格的原始碼是從原始碼產生,所以我需要保留原始版本)。
加入執行個體委派
- 加入類別的靜態執行個體。
- 將靜態方法複製到執行個體方法(您需要建立新的方法名稱,因為靜態方法和執行個體方法不能有相同的名稱和簽章)。
- 變更靜態方法以呼叫內部靜態執行個體上的執行個體方法。
以下是一種執行方式(內部執行個體的延遲初始化是故意的,如果您擔心執行緒問題,我們可以使用雙重檢查鎖定或僅讓方法同步)
private static CurrencyConversion instance; private static CurrencyConversion getInstance() { if (instance == null) { instance = new CurrencyConversion(); } return instance; } public static BigDecimal convertFromTo(String fromCurrency, String toCurrency) { return getInstance().convert(fromCurrency, toCurrency); } public static Map<String, String> currencySymbols() { return getInstance().getAllCurrencySymbols(); }
萃取一些方法
有許多機會可以萃取一些方法,以下是執行一些方法萃取後的 v2/CurrencyConversion 版本
public BigDecimal convert(String fromCurrency, String toCurrency) { validateCurrencies(fromCurrency, toCurrency); try { String result = getPage(fromCurrency, toCurrency); String value = extractToValue(result); return new BigDecimal(value); } catch (Exception e) { throw new RuntimeException(e); } } protected void validateCurrencies(String fromCurrency, String toCurrency) { Map<String, String> symbolToName = currencySymbols(); if (!symbolToName.containsKey(fromCurrency)) throw new IllegalArgumentException(String.format( "Invalid from currency: %s", fromCurrency)); if (!symbolToName.containsKey(toCurrency)) throw new IllegalArgumentException(String.format( "Invalid to currency: %s", toCurrency)); } protected String extractToValue(String result) { String theWholeThing = result; int start = theWholeThing.lastIndexOf("<div id=\"converter_results\"><ul><li>"); String substring = result.substring(start); int startOfInterestingStuff = substring.indexOf("<b>") + 3; int endOfIntererestingStuff = substring.indexOf("</b>", startOfInterestingStuff); String interestingStuff = substring.substring( startOfInterestingStuff, endOfIntererestingStuff); String[] parts = interestingStuff.split("="); return parts[1].trim().split(" ")[0]; } protected String getPage(String fromCurrency, String toCurrency) throws URISyntaxException, IOException, HttpException { String url = String.format("http://www.gocurrency.com/v2/dorate.php?inV=1&from=%s&to=%s&Calculate=Convert", toCurrency, fromCurrency); HttpClient httpclient = new DefaultHttpClient(); HttpGet httpget = new HttpGet(url); HttpResponse response = httpclient.execute(httpget); HttpEntity entity = response.getEntity(); StringBuffer result = new StringBuffer(); if (entity != null) { InputStream instream = entity.getContent(); InputStreamReader irs = new InputStreamReader(instream); BufferedReader br = new BufferedReader(irs); String l; while ((l = br.readLine()) != null) { result.append(l); } } return result.toString(); }
以下是針對此類別(v2 套件中的類別)的最後一個測試版本。此測試通過
@Test public void returnExpectedConversion_v4() throws Exception { final ByteArrayInputStream bais = new ByteArrayInputStream( "<div id=\"converter_results\"><ul><li><b>1 USD = 0.98 EUR</b>" .getBytes()); new NonStrictExpectations() { DefaultHttpClient httpclient; HttpResponse response; HttpEntity entity; { httpclient.execute((HttpUriRequest) any); result = response; response.getEntity(); result = entity; entity.getContent(); result = bais; } }; new NonStrictExpectations(CurrencyConversion.class) { { CurrencyConversion.currencySymbols(); result = mapFrom("USD", "EUR"); } }; BigDecimal result = CurrencyConversion.convertFromTo("USD", "EUR"); assertThat(result.subtract(new BigDecimal(2)), is(lessThanOrEqualTo(new BigDecimal(0.001)))); }
測試子類別
現在,我將使用手寫測試雙重而非 JMockIt 重寫此測試。我不會顯示所有中間步驟,而只會顯示最終結果
class CurrencyConversion2_testingSubclass extends CurrencyConversion { @Override public void validateCurrencies(String fromCurrency, String toCurrency) { } @Override public Map<String, String> getAllCurrencySymbols() { return mapFrom("USD", "EUR"); } @Override public String getPage(String fromCurrency, String toCurrency) { return "<div id=\"converter_results\"><ul><li><b>1 USD = 0.98 EUR</b>"; } } @Test public void returnExpectedConversion_v5() throws Exception { CurrencyConversion original = CurrencyConversion.reset(new CurrencyConversion2_testingSubclass()); BigDecimal result = CurrencyConversion.convertFromTo("USD", "EUR"); assertThat(result.subtract(new BigDecimal(2)), is(lessThanOrEqualTo(new BigDecimal(0.001)))); CurrencyConversion.reset(original); }
此測試產生與 JMockIt 測試相同的结果,而後者透過手動執行許多工作。
請注意,為了做到這一點,我們需要允許設定和重設新建立的 Singleton 類別
public static CurrencyConversion reset(CurrencyConversion other) { CurrencyConversion original = instance; instance = other; return original; }
觀察
實際執行時間
這種加入執行個體委派、可覆寫靜態 Singleton 和許多萃取方法的技術可能看起來相當多。在實務上,這種變更很快。有多快?對於這個範例,我使用 IntelliJ 花了 3 分鐘。Eclipse 也會花費相同時間。在 vi 中,可能 1 分鐘(好吧,可能不會,但我確實在 Eclipse、IntelliJ 甚至 Visual Studio 中使用 vi 外掛程式)。無論如何,一旦您練習過,就會很快。如果類別一開始就沒有使用所有靜態方法,我們可以避免許多這種情況,但這是一個常見問題,因此知道如何處理它是一種很好的通用技術。
是的,但是...
常見的疑慮是,如何讓所有這些提取的方法受到保護?這並不困擾我,因為我相信測試能力比設計更重要。這實際上是一個錯誤的二分法,但聽起來有爭議,所以我還是喜歡這麼說。事實上,許多這些「受保護的方法」都足夠複雜,足以保證個別類別。接下來,引入依賴反轉,突然間我正在連接受我控制的依賴物件,並使用可覆寫的方法,而且檢查此整體流程的個別部分變得輕而易舉。分而治之。
第一個測試與第二個測試
以下是兩個測試,用於比較
使用 JMockIt
@Test public void returnExpectedConversion_v4() throws Exception { final ByteArrayInputStream bais = new ByteArrayInputStream( "<div id=\"converter_results\"><ul><li><b>1 USD = 0.98 EUR</b>" .getBytes()); new NonStrictExpectations() { DefaultHttpClient httpclient; HttpResponse response; HttpEntity entity; { httpclient.execute((HttpUriRequest) any); result = response; response.getEntity(); result = entity; entity.getContent(); result = bais; } }; new NonStrictExpectations(CurrencyConversion.class) { { CurrencyConversion.currencySymbols(); result = mapFrom("USD", "EUR"); } }; BigDecimal result = CurrencyConversion.convertFromTo("USD", "EUR"); assertThat(result.subtract(new BigDecimal(2)), is(lessThanOrEqualTo(new BigDecimal(0.001)))); }
使用手動編寫的模擬和重構
class CurrencyConversion2_testingSubclass extends CurrencyConversion { @Override public void validateCurrencies(String fromCurrency, String toCurrency) { } @Override public Map<String, String> getAllCurrencySymbols() { return mapFrom("USD", "EUR"); } @Override public String getPage(String fromCurrency, String toCurrency) { return "<div id=\"converter_results\"><ul><li><b>1 USD = 0.98 EUR</b>"; } } @Test public void returnExpectedConversion_v5() throws Exception { CurrencyConversion original = CurrencyConversion.reset(new CurrencyConversion2_testingSubclass()); BigDecimal result = CurrencyConversion.convertFromTo("USD", "EUR"); assertThat(result.subtract(new BigDecimal(2)), is(lessThanOrEqualTo(new BigDecimal(0.001)))); CurrencyConversion.reset(original); }
如果您必須支援測試程式碼,您比較喜歡哪個版本?等等,先別回答。
第一個測試仍然通過
我想重申,儘管我對生產程式碼進行了多項變更,但 JMockIt 測試仍然通過。這對我來說很有趣。事實上,如果您從 JMockIt 如何操作類別載入器的角度思考,這是有道理的。即便如此,這仍然很酷。
如果我們再次嘗試 JMockIt
為什麼要停在這裡?如果我們花時間重寫 JMockIt 測試,並考慮對生產程式碼所做的變更,會發生什麼事?
@Test public void returnExpectedConversion_final() throws Exception { new NonStrictExpectations(CurrencyConversion.class) { CurrencyConversion c; { c.validateCurrencies(anyString, anyString); c.getPage(anyString, anyString); result = "<div id=\"converter_results\"><ul><li><b>1 USD = 0.98 EUR</b>"; } }; BigDecimal result = CurrencyConversion.convertFromTo("USD", "EUR"); assertThat(result.subtract(new BigDecimal(2)), is(lessThanOrEqualTo(new BigDecimal(0.001)))); }
現在您想維護哪一個?
請注意,一點點重構讓最終的 JMockIt 測試變得更好。事實上,撰寫此版本測試所需的重構量小於我所做的重構。僅僅提取方法就足以大幅改善 JMockIt 測試。但是,請注意,JMockIt 沒有強制執行這一點。不使用模擬工具或使用 Mockito 等工具會迫使我進行一定程度的重構,才能讓這個類別接受測試。
封閉回饋迴路
當人們使用開迴路工作時會發生什麼事?也就是說,你寫了一些程式碼,然後像 JMockIt 這樣的工具讓你可以事後回去撰寫測試,而不用因為糟糕的決策而受苦?我這麼問是因為這個範例展示了這樣的狀況。原始程式碼一團糟,JMockIt 讓我可以撰寫一個特徵測試來執行程式碼。這很棒。但它並沒有強迫我處理原始問題。從某種意義上來說,它也讓我可以避免修復一團糟的痛苦。如果我們拿走強大的工具,那麼我們必須清理這團糟才能撰寫測試。請注意,當我們清理程式碼時,它仍然一團糟,但比之前好,而且重構的程式碼也建議其他改進。此外,也許下次有人撰寫程式碼時,他們可能會學到一些東西,而且也許,也許他們不會再犯同樣的錯誤,或至少不會那麼常犯。
雖然沒有直接相關,但這裡有一篇很棒的文章可以閱讀,與這個回饋的想法有關:Joshua Foer 的心智遊戲祕密。這篇文章討論的其中一件事是精進技能需要什麼:失敗。也就是說,我們需要犯錯,然後從這些錯誤中學習。當工具讓我們無法從錯誤中學習時會發生什麼事?我擔心強大的工具可能會減輕或消除與失敗相關的痛苦,這將導致停滯。如果我們沒有持續努力犯新的愚蠢錯誤,那麼我們並沒有真正學習,對吧?
結論
我認為我們不應該使用像 JMockIt 這樣的工具嗎?不。我認為 JMockIt 是一個很棒的工具,而且我曾經處於需要它的強大功能的狀況,甚至是有必要的。但是,在新的開發過程中使用它呢?我猶豫不決。一方面,我在工作時想要最强大的工具。儘管我最喜歡的模擬函式庫是 Mockito,但它不如 JMockIt 強大。另一方面,JMockIt 實際上需要更多的紀律才能有效使用,因為它允許我留下更多一團糟,仍然可以完成我想完成的事情。
也許這是一個紅鯡魚。我通常實踐測試驅動開發。雖然我沒有在使用 JMockIt 的實際專案中實踐 TDD 的經驗,但我使用 Mockito 有,而且我不認為我對過度使用它有任何問題。我有時仍然有太過精明/聰明,但我不能怪罪工具。我可以說我看到其他團隊使用 Mockito 和 Moq(Mockito 在 C# 中的等效項),而且過度使用它們。但是,當我看到那樣的情況時,那些團隊是在經過大量開發後才撰寫測試的,那不是工具的錯。如果有人有紀律可以少量工作,同時撰寫測試(希望是先撰寫),那麼 JMockIt 的强大功能就不會造成任何問題。另一方面,如果 JMockIt 的强大功能會造成問題,那麼可能還有更深層的問題需要擔心。
無論如何,我鼓勵你嘗試這些類型的工具,並親自了解擁有這樣的强大功能是否有助於或阻礙你的學習。
重大修訂
2012 年 9 月 10 日:首次發布
2012 年 5 月 25 日:初始草稿