.NET Core TDD 前傳: 編寫易於測試的代碼 -- 縫 為什麼要編寫易於測試的代碼? 如何創造縫隙? ...
有時候不是我們不想做單元測試, 而是這代碼寫的實在是沒法測試....
舉個例子, 如果一輛汽車在產出後沒完成測試, 那麼沒人敢去駕駛它. 代碼也是一樣的, 如果項目未能進行該做的測試, 那麼客戶就不敢去使用它, 即使使用了也會遇到“車禍”.
為什麼要測試/測試的好處
- 它可以儘早發現bug, 解決bug
- 它會節省開發和維護一個軟體的總成本. 實際上我們在維護軟體上付出的成本要遠大於在開發時付出的成本. 開發的時候編寫單元測試確實會增加一些成本, 但是從長遠來看這些測試還是會從維護上降低軟體的總成本.
- 它會促使開發者改進設計. 如果開發時先寫測試或者同時寫測試代碼, 那麼開發者會不得不仔細考慮要解決的問題, 所以會寫出更好的設計, 而且無需考慮如何測試代碼.
- 相當於自成文檔. 因為所有的測試就是被開發軟體所有期待的行為.
- 增強自信, 去除恐懼. 有時修改代碼後我們就會擔心這是否對現有的功能造成了破壞, 而如果單元測試覆蓋了軟體的重要功能的話, 那麼只要測試都能通過, 那麼就基本可以確信功能沒被破壞.
測試從不同的角度看可以分成很多類. 我們首先應該保證好單元測試能夠很好的進行, 只要單元測試能夠很好的進行, 那麼其它測試應該都可以很好的進行.
為什麼要寫易於測試的代碼
再詳細說一下:
在談到軟體測試的時候, 網上的文章經常舉這個建造汽車的例子, 那我也拿汽車這個例子說明問題吧.
假設我們需要設計並生產一輛汽車, 可能會有兩種方式:
第一種是把車設計成一個複雜的整體, 把所有需要的零件都焊到了一起, 也可以說它只有一個大零件, 就是汽車本身. 這樣做的好處就是我們不必花那麼多時間和精力去製作發動機, 輪胎, 車窗等等這些可替換的零件了. 這麼去做是有可能把汽車的設計和生產成本降低的. 但是如果汽車被長期使用, 考慮到售後及維護, 那麼成本肯定會非常高了.
如果汽車壞了, 我們無法檢測是哪裡出錯, 因為它是一個整體, 無法對某部分進行隔離測試; 即使我們知道哪裡有問題, 我們還是無法替換損壞的部分, 因為它還是一個整體...
第二種方式就是正確的方式, 我們使用可替換的零件進行設計生產, 這樣就會方便測試和售後維護. 因為車裡的每個零件都可以被替換, 也可以取出來單獨進行測試. 如果汽車不能啟動, 那麼就對每個零件進行檢查, 最後替換出問題的零件即可, 而無需像第一種方式那樣把整個車扒開進行大修.
很明顯, 正常的汽車廠商都是使用的第二種方式, 因為其具有可測試性和可維護性.
軟體開發這個領域和設計汽車是很相似的, 可以像第一種方式一樣開發軟體, 也可以像第二種方式一樣開發軟體.
在現實中, 有太多的開發者使用了第一種方式, 把一大堆代碼和功能都放到了一起. 而實際上開發者們應該採用第二種方式來進行代碼的設計和編寫, 即使在開發初期這可能會花掉更多的時間和精力.
有的時候不是開發者不想採取第二種方式, 而是花了很大力氣卻發現寫出來的代碼仍然不能很好的進行單元測試, 所以實際問題是不知道該如何寫出易於測試的代碼.
什麼樣的代碼易於測試
還是汽車的例子, 如果我們懷疑汽車的電瓶壞了, 那麼採用第一種方式創造的汽車就無法進行對它的“電瓶”進行單獨檢測, 因為是焊到一起的, 也沒有可以用檢測的插頭等; 而採用第二種方式建造的汽車則可以把電瓶拿出來, 然後我們使用電壓表等專用的儀器在隔離的情況下對其進行檢測.
第二種方式之所以可以進行隔離測試是因為它採用的是可替換零件, 也就是零件可以拿下來.
用專業的術語說就是第二種方式里有縫(seam). 在軟體里, 什麼是縫(seam)? 縫就是你可以在程式里替換行為的地方, 而不需要在這個地方進行修改. 或者說就是可以讓你的代碼移除依賴項並創建出可用於隔離測試對象的地方.....我可能解釋的不明白, 看圖吧:
虛線就是縫.
由於有縫的存在, 所以我們可以進行隔離測試:
分別使用Test Fixture和Test double來替換調用類和依賴項.
而採用第一種方式的軟體就無法把代碼拆出來進行測試了, 因為無法替換依賴項, 無法接入到測試環境, 也就是說無法進行隔離測試了.
為什麼代碼會無法進行隔離測試呢
無法測試的代碼有一些特點:
- new 關鍵字. 如果這部分代碼里出現了new關鍵字, 也就是說在構造函數或方法內創造了外部資源或較複雜類型的實例, 那麼測試就會很困難了. 而應該採用的做法是依賴註入.
- 靜態方法/屬性調用. 靜態方法會為它的調用者和它被調用時所在的類創建很緊的耦合. 使用像Math.Min(), String.Join()這些方法時是沒有題的, 但是如果使用DateTime.Now, Console.Write() 那就可能會出問題了. 這時候你可能就需要使用一個包裝類了.
- 單立體 Singleton. Singleton的本質是共用狀態. 但是為了隔離測試, 最好還是避免使用singleton. 如果確實需要使用它的話, 那麼在測試的時候可以使用一個非Singleton的替身來進行測試, 當然, 通過依賴註入.
- 全局共用狀態, 這個應該明白
- 引用第三方框架或外部資源. 一旦有這樣的引用的話, 就無法進行隔離測試了. 我們需要做的就是對這些東西抽象化, 把細節忽略只關心特定條件下的特定結果.
如何產生縫隙
- 解藕依賴項. 在C#里, 我們通過對介面編程而不是對實現來編程來實現這個任務.
- 依賴註入. 主要是採用構造函數註入.
做到這兩點, 那麼我們就可以使用test double(測試替身)來代替依賴項並註入到被測試類使用, 從而進行隔離測試.
例子
下麵就是一個難以測試的例子, 這個代碼並不完美, 無法展示出不可測試代碼所有的特點, 但是也包含了至少兩個特點:
首先它的依賴項都是new出來的, 這些依賴項就有依賴於資料庫的, 所以測試的話, 我們還需要知道資料庫裡面特定的數據內容..這樣的結果就是測試很難完成.
其次這裡用到了第三方的Mapper.Map()靜態方法, 這個方法也許是經過測試的並且沒有副作用的, 但是也有可能不是. 而且它造成了ProductControllerHard和Mapper類之間的緊耦合.
針對第一個問題, 我想都知道怎麼去處理了, 就是使用介面. 我就不多介紹了.
針對第二個問題, 使用靜態方法造成了緊耦合. 如果這個靜態方法是我們自己寫的方法, 我們可以對其重構, 變成實例方法. 但是如果它來自第三方庫, 並且第三方庫沒有提供可以依賴註入使用的版本, 那麼我們自己可以寫一個包裝類(wrapper)來包裝該方法:
但是由於這個Mapper來自AutoMapper庫, 這個庫提供了IMapper介面, 所以使用IMapper進行依賴註入即可.
可測試的代碼應該如下: