.NET Core TDD 前傳: 編寫易於測試的代碼 -- 構建對象 ...
該系列第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容器的話, 還可以使用類似下麵的用法:
先介紹到這裡.