使用IoC與DI有何意義? (四) 使用IoC與DI提高可測試性
這一系列的文章,持續維持著一年更新一篇的進度,😛。
今天適逢良辰吉日,我們再次來談談,如何透過IoC與DI增加系統的可測試性。
關於單元測試(Unit Test)
我們知道,有單元測試的存在,我們得以放膽的修改程式碼,無須擔心程式碼的頻繁異動將造成意料之外的副作用。
也因此,有一些團隊追求著程式碼單元測試的覆蓋率。
然而,高覆蓋率的單元測試並不意味著就會有更高品質的程式碼,反而真正重要的是,如何為我們系統中的核心邏輯、重要的核心函式,適當的添加單元測試。
所以,我們接下來要來談一談,如何透過IoC與DI增加程式碼的可測試性。
關於可測試性
你大概也已經知道,我們可以透過Visual Studio為特定的method來建立單元測試。特別是重要的商業邏輯運算函式、或是API,都是單元測試非常能發揮功能的好對象。
然而,你可能會發現有些程式碼似乎難以測試,例如底下這個例子:
Console.Write("請輸入金額(USD):");
var amount = int.Parse(Console.ReadLine()); //100
Console.Write("請輸入人數:");
var people = int.Parse(Console.ReadLine()); //5
Financy f = new Financy();
var CostByPeople = f.SplitMoney(amount, people);
Console.Write(CostByPeople);
上面這段程式碼看起來簡單,實際上,也真的很簡單😎。
SplitMoney()是一個計算旅遊消費金額分攤的函式。
假設,你和一夥人出國旅遊,在路邊店家吃了一餐,總共100美元,你想計算每個人要負擔多少台幣,就可以使用這個函式。呼叫SplitMoney()時,傳入『美金總金額』和『人數』兩個參數,它就幫你算出一個人要付的台幣金額。
假設我們要針對這個函式進行單元測試,乍看之下似乎並不難,但我們看SplitMoney()的具體內容:
public class Financy
{
public double SplitMoney(double USDAmount, int People)
{
var currencyConverter = new CurrencyConverter();
//使用到外部函式(抓取匯率)
double rate = currencyConverter.Convert("USD", "TWD");
//計算台幣總金額
double Total = USDAmount * rate;
//回傳一個人需要付多少錢(台幣)
return Total / People;
}
}
這下問題來了。
我們SplitMoney()這個方法本身不難,也不過就是把總金額除以旅遊人數,就算出平均金額了。倘若我們需要為SplitMoney()這個方法撰寫單元測試,似乎也沒啥問題。
但這邊出現了個障礙,你知道的,單元測試是以程式來驗證程式,也就是說,我要從外部來驗證一個函式是否正確,就撰寫單元測試函式,來呼叫我想驗證的函式,傳入固定的參數,我想驗證的函式理當也會有著同樣固定的回傳結果。藉由判斷回傳結果是否與預期的一致,我就可以驗證該函式是否正確。
但剛才說,這邊出了點問題,因為SplitMoney()這個方法在計算的過程中呼叫到了Convert這個函式:
//使用到外部函式(抓取匯率)
double rate = currencyConverter.Convert("USD", "TWD");
上面這個方法,抓取的是『即時』的匯率,而因為匯率是浮動的,這表示,當我們每一次呼叫這個函式時,即便傳入的,是同樣的參數,也可能得到不同的結果!
如此一來,該函式SplitMoney()就沒有可測試性(testability),因為我們無法去驗證它,是否在被修改後,還維持著一致的運算邏輯。
該如何解決前面提到的問題?
使用fake類別提高可測試性
我們先釐清問題在哪,顯然,問題的來源不是來自於SplitMoney()這個方法本身,而是來自於SplitMoney()這個方法執行的過程中,所呼叫的即時匯率抓取函式。
倘若,我們能夠在執行單元測試方法時,也就是運行掛載著[TestMethod()] 這個attribute的單元測試方法時,將SplitMoney()中抓取即時匯率的函式給換掉,改成永遠回傳固定值(例如台幣與美金的兌換永遠維持在 27:1),如此一來,我們的單元測試函式,就可以撰寫了,因為我們每次重複呼叫時,都可以得到一個穩定的預期結果值。
不過,我們在執行單元測試的時候,雖然抓取的匯率必須是固定的值,但一般呼叫的時候,則必須要保持是抓取到動態的匯率才行呀。
為了實現這個功能,我們可以針對SplitMoney()中所用到的抓取匯率的這個類別,設計一個相同的偽裝類別(Fake Class)。為了這麼做,我們先把抓取匯率的這個類別CurrencyConverter抽提出介面ICurrencyConverter,同時調整一下原本的CurrencyConverter類別,讓它繼承自介面ICurrencyConverter:
public interface ICurrencyConverter
{
float Convert(string From, string To);
}
public class CurrencyConverter : ICurrencyConverter
{
public float Convert(string From, string To)
{
//…具體程式碼請參考 github
// https://github.com/isdaviddong/HOL-UnitTestWithIoC_After
return data;
}
}
然後,再繼承ICurrencyConverter介面設計出一個fake類別:
//建立fake類別
public class FakeCurrencyConverter : ICurrencyConverter
{
public float Convert(string From, string To)
{
return 27.67222F;
}
}
你會發現,我們在假類別中,把匯率固定在27.6,如此一來,倘若我們在單元測試方法中,可以讓SplitMoney()採用這個假類別FakeCurrencyConverter,而非使用真的CurrencyConverter類別去抓取即時匯率,那就可以在單元測試方法中,維持SplitMoney()回傳值的穩定性(冪等性)。如此一來,就可以正確的撰寫單元測試了。
那我們要怎麼讓單元測試中的程式碼在呼叫SplitMoney()方法的時候,採用假類別,而一般程式碼呼叫SplitMoney()方法的時候,卻又使用真的類別呢?
請注意,這時候,建構子注入就要出現了。
透過IoC與DI提高可測試性
就物件導向程式設計的概念來說,如果A方法對於B類別有依賴,我們可以幫B類別設計(重構出)一個介面C,將A方法程式碼中對於B類別的依賴,改寫成對介面C的依賴。
這其實,就是剛才我們做的事情。
我們對SplitMoney()所倚賴的類別CurrencyConverter進行重構,為它建立介面ICurrencyConverter,並且繼承該介面建立出一個類似CurrencyConverter類別的假類別FakeCurrencyConverter。
這樣有何好處?
如此一來,我們可以調整原本SplitMoney()方法的程式碼,把寫死依賴CurrencyConverter類別的這個狀況,改為依賴介面ICurrencyConverter:
ICurrencyConverter _CurrencyConverter;
public double SplitMoney(double USDAmount, int People)
{
//var currencyConverter = new CurrencyConverter();
//使用到外部函式(抓取匯率)
double rate = _CurrencyConverter.Convert("USD", "TWD");
//計算台幣總金額
double Total = USDAmount * rate;
//回傳一個人需要付多少錢(台幣)
return Total / People;
}
然後,在建立該類別的時候,把_CurrencyConverter先預設設定為CurrencyConverter類別即可。
這樣做的好處是,有需要時,我們可以動態替換抓取匯率的類別,將其改成fake類別,以便於固定抓取到的匯率回傳值。進而讓SplitMoney()方法的計算結果冪等,好讓我們可以撰寫單元測試。
因此,我們依照上面的邏輯,把程式碼改成底下這樣,實現所謂的建構子注入:
public class Financy
{
//加入建構子注入
ICurrencyConverter _CurrencyConverter;
public Financy(ICurrencyConverter currencyConverter)
{
_CurrencyConverter = currencyConverter;
}
public Financy()
{
//預設狀況下,用標準類別
_CurrencyConverter = new CurrencyConverter();
}
public double SplitMoney(double USDAmount, int People)
{
//var currencyConverter = new CurrencyConverter();
//使用到外部函式(抓取匯率)
double rate = _CurrencyConverter.Convert("USD", "TWD");
//計算台幣總金額
double Total = USDAmount * rate;
//回傳一個人需要付多少錢(台幣)
return Total / People;
}
}
也就是說,我們在呼叫這個SplitMoney()方法前,在建立其所屬的類別Financy時,可以把將來要具體使用的抓取匯率的類別,以建構子參數的方式傳入,而不是寫死在SplitMoney()方法中。如此一來,我們就可以在運行單元測試的時候,採用fake類別:
[TestClass()]
public class FinancyTests
{
[TestMethod()]
public void SplitMoneyTest()
{
//注入測試用的fake類別實作
Financy f = new Financy(new FakeCurrencyConverter());
var CostByPeople = f.SplitMoney(100, 5);
Assert.IsTrue(CostByPeople.ToString().StartsWith("553.444"));
}
}
而運行一般程式的時候,採用正常的類別(當沒有傳入建構子參數,則預設使用一般類別):
Console.Write("請輸入金額(USD):");
var amount = int.Parse(Console.ReadLine()); //100
Console.Write("請輸入人數:");
var people = int.Parse(Console.ReadLine()); //5
Console.ReadLine();
//採用正常的類別
Financy f = new Financy();
var CostByPeople = f.SplitMoney(amount, people);
這樣一來,是不是讓原本不易撰寫測試程式的程式碼,變成可以輕易測試了呢? 這就是可測試性(testability)的提升。
如果你需要程式碼,請參考:
https://github.com/isdaviddong/HOL-UnitTestWithIoC_After
本文內容來自於『團隊開發與架構設計實務』課程,我們最近要開課囉,依照過去經驗這門課幾乎都是秒殺,如果你需要預先保留席次,請點選這裡登記唷。
若您要詢問問題,或是需要即時取得更多相關訊息,可按這裡加入FB專頁。
若這篇文章對您有所幫助,請幫我們分享出去,謝謝您的支持。
留言