2022年3月3日 星期四

使用IoC與DI有何意義? (四) 使用IoC與DI提高可測試性

enter image description here這一系列的文章,持續維持著一年更新一篇的進度,😛。

今天適逢良辰吉日,我們再次來談談,如何透過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專頁。
若這篇文章對您有所幫助,請幫我們分享出去,謝謝您的支持。

沒有留言:

熱門文章