.NET Core TDD 前傳: 編寫易於測試的程式碼 -- 構建物件

solenovex發表於2018-07-28

該系列第1篇: 講述了如何創造"縫".  "縫"(seam)是需要知道的概念.

本文是第2篇, 介紹的是如何避免在構建物件時寫出不易測試的程式碼. 本文的概念性內容大部分都來自Misko Hevery的這篇部落格文章.

構建

還是用上文裡汽車的例子.

通常情況下, 我們是先去建造汽車, 組裝好汽車後, 我們再去駕駛它.

軟體開發也類似, 我們應該把物件構造完畢之後, 再去用它. 但是有時候, 開發者會在構造過程中新增一些程式邏輯. 這就相當於車還沒造完, 我們就駕駛它去兜風了. 這樣做是不太好的.

建構函式是類用來建立其例項物件的方法, 這裡的程式碼是用來準備該物件的. 但有時開發者會在建構函式裡做一些其它的工作, 例如構建依賴項, 執行初始化邏輯等等.

在建構函式(或者更大一點, 指構建的過程)裡, 做這些額外的工作會讓測試變得異常困難. 這是因為像初始化依賴項, 呼叫服務, 設定狀態的邏輯等這些工作會把用於測試的"縫"弄丟. 導致無法進行mock.

總之在構造的過程中做太多的工作會妨礙測試.

 

危險訊號

  • 在建構函式/欄位宣告裡出現new關鍵字
    • 如果建構函式裡需要建立依賴, 那麼這就會為該類與依賴項之間創造了緊耦合. 這個之前提過, 所以需要注入依賴. 但是簡單的值型別, 例如字串, List, Dictionary等還是可以的.
  • 在建構函式/欄位宣告裡呼叫靜態方法
    • 靜態方法不可以被mock, 也不能被注入.
  • 建構函式出現流程控制邏輯程式碼
    • 這樣就很難對邏輯直接進行測試了. 我們只能分別使用不同的方式構造該物件, 測試並確認物件的狀態. 而這個狀態通常對直接測試是隱藏的. 實際上只要不是賦值程式碼, 就有可能是問題程式碼.
  • 建構函式裡出現非賦值程式碼
  • 存在另外一個初始化函式 (也就是說建構函式走了完, 但是物件並沒有被完全初始化)

 

如何解決問題?

  • 不要在建構函式裡建立依賴項, 應該注入它們. 然後在建構函式裡把它們賦值給類的私有變數.
  • 當需要構建物件圖(一組有引用關係的物件), 也包括物件需要一些構建的引數等情況, 應該使用工廠, 建造者模式, 或者IoC容器的依賴注入等, 目的是把這些物件的構建工作分離出去.
  • 避免在建構函式裡寫邏輯程式碼, 例如條件, 迴圈, 計算等等. 也不能把邏輯程式碼放在別的方法, 然後呼叫該方法...

總之就是要避免物件的構建和物件的行為混合到一起, 因為它們在一起就會很難進行測試.

 

最後還有一點, 首先你需要知道, 根據angular的創始人Misko Hevery所說:

物件的構造分兩類, 一種是可注入的, 一種是可new的.

可注入的物件可以由其它的一堆可注入物件組成. 它們可以為 可new的 物件工作. 可注入的物件通常是實現了介面的service, 像什麼IUnitOfWork, IRepository, IxxxService等等.

可new的物件就是物件圖裡的終點, 例如實體或者值物件(Value Object)等.

為了易於測試, 針對這兩類構造, 有下列規則:

可注入的物件可以在建構函式請求(注入)其它的可以注入物件, 但是不能在建構函式請求可new的物件.

反過來, 可new的物件可以在建構函式請求其它的可new物件, 但是不能在建構函式請求可注入的物件.

 

例子

第一個例子

這是不對的, 構建的過程中直接new的話, 就會造成緊耦合, 也無法在測試中使用Test Double來代替它們了. 如果測試中不代替它們的話, 有些服務的開銷可能會很大.

 

正確的寫法是使用依賴注入:

第二個例子

該例中, UserController只需要UserService和LoggingService兩個依賴項. 但是UserService又依賴於UserRepository. 

但是這樣寫就不對了, 這會造成UserController和UserRepository間的緊耦合, 而且配置UserService也並不是UserController的責任.

 

正確的寫法是:

而UserService也最好是注入依賴.

 

而如果UserService並不是在建構函式注入UserRepository的話:

那麼Controller裡就應該這樣寫:

不過最好還是使用建構函式注入的寫法.

 

第三個例子

仔細的說, 該例有不止一處錯誤.

首先它有條件判斷邏輯程式碼; 此外它還使用了ApplicationState.IsRunning這個靜態變數(就是全域性狀態); 而且在建構函式裡還做了UserService的配置工作, 這不是UserController的責任.

儘量要避免全域性變數, 它無法進行隔離, 測試會遇到麻煩, 例如並行測試時其中一個測試改變了靜態變數的值就可能導致另一個測試失敗.

但是粗略的說, 該例可以說就是一個錯誤, 如何配置UserService並不是UserController的責任, 所以, 正確的做法是把UserService配置相關的程式碼移出去, 讓它自己去管理吧:

 

第四個例子

該例子中, LoggingService的Log方法需要一個Area型別的物件, 它是一個值物件.

所以它的錯誤就是, 不應該把可new的物件注入到可注入的物件裡. 這麼做的話, 測試就不好做隔離了.

 

正確的做法應該是, 作為方法的引數傳遞進來:

第五個例子

如果出現類類似initalize()或類似意思的方法, 很有可能說明該物件的責任太多了.

 

修改它很簡單, 讓各自的類負責自己的內容即可. 去掉initialize()方法即可.

 

例子就舉這些, 並不全, 詳細請看Angular作者的博文.

 

測試/執行時如何建立物件

上面例子裡的UserController就是我們需要使用的物件, 在執行時, 程式碼可能是這樣的:

構建這個物件還是有點麻煩的, 它的類關係圖如下:

 

所以測試的設定過程也會比較麻煩:

當然也可以不直接new, 而是使用mock. 總之都很麻煩.

 

使用工廠

所以我們可以使用Factory等模式, 把構建UserController的工作放到工廠裡:

 

可以這樣呼叫:

 

使用IoC容器

如果專案使用了IoC容器的話, 還可以使用類似下面的用法:

 

先介紹到這裡.

 

相關文章