讀書筆記: 單元測試的藝術

書籍連接: book.douban.com/subject/259…數據庫

這本書教你爲何要關注可測試性, 如何編寫可測試的代碼, 以及如何推進測試落地.bash

書中的代碼是.NET的, 不太習慣, 後文中我改爲用Golang進行舉例.網絡

第一部分 入門

第一部分是入門章節, 告訴咱們什麼是單元測試, 爲何要用單元測試, 如何寫好單元測試.架構

什麼是單元測試? 單元測試是一段自動化的代碼, 這段代碼調用被測試的工做單元, 以後對這個單元的單個最終結果的某些假設進行檢驗. 這裏須要注意一個概念: 單元. 從調用系統的一個公共方法到產生一個測試可見的最終結果, 其間這個系統發生的行爲總稱爲一個工做單元.框架

一個相關的概念是集成測試. 單元測試和集成測試的最主要區別在於: 是否使用了真實依賴物, 如數據庫, 網絡鏈接等等.函數

什麼是好的單元測試? 優秀單元測試應該有以下特性:單元測試

  • 自動化, 可重複執行
  • 運行結果是穩定的
  • 容易編寫
  • 運行快速
  • 可以徹底控制被測試的單元

在開發過程當中應該什麼時候編寫單元測試? 這個見仁見智, 能夠採用TDD先寫測試後寫功能, 也能夠寫完功能再補測試.測試

單元測試的目的在於使得代碼可維護. 若是你的單元測試沒有促進這一目標, 就要反思是否是真的寫好了單元測試.ui

第二部分 核心技術

核心技術章節主要介紹瞭如何使系統與外部依賴項隔離, 從而進行去依賴的單元測試.編碼

fake & stub

一般咱們開發的系統都會有各類外部依賴項. 外部依賴項是系統中的一個對象, 被測試代碼與這個對象發生交互, 但你不能控制這個對象. 常見外部依賴項包括文件系統, 線程, 內存以及時間等. 注意: 一旦你的系統中引入的真實的外部依賴項, 那麼你進行的就是集成測試, 而非單元測試.

顯然, 若是無法控制外部依賴項的行爲, 就沒法保證單元測試運行結果的穩定性. 那麼如何使外部依賴項可控? 答案是使用僞對象 (fake) 替代真實的外部依賴對象.

那麼接下來的問題是, 如何用僞對象替代外部依賴? 只須要找到被測試單元使用的外部接口, 而後將接口的底層實現替換成你能控制的代碼. 若是這個接口與被測試單元直接相連, 就添加一個間接層, 隱藏這個接口. 來看下面這個例子:

// 判斷文件是否存在
func IsFileExist(fileName string) bool {
  _, err := os.Stat(fileName)
  if err == nil {
    return true
  }
  if os.IsNotExist(err) {
    return false
  }
  return false
}
複製代碼

這段代碼引入了文件系統依賴, 須要用一個僞對象替代真實文件系統. 然而, 文件系統有關的代碼已經寫死在函數裏了, 這就須要引入一箇中間層, 抽象出文件系統的操做. 這裏咱們聲明一個IFileManager接口:

type IFileManager interface {
  IsFileExist(string) bool
}
複製代碼

提供一個真實的文件系統實現和一個僞對象實現:

type PureFileManager struct{}

func (t *PureFileManager) IsFileExist(fileName string) bool {
  _, err := os.Stat(fileName)
  if err == nil {
    return true
  }
  if os.IsNotExist(err) {
    return false
  }
  return false
}

type FakeFileManager struct{}

func (t *FakeFileManager) IsFileExist(fileName string) bool {
  return true // 行爲徹底由咱們控制, 這裏無腦返回true
}
複製代碼

改寫以前的IsFileExist()函數:

func IsFileExist(fileName string) bool {
  var mgr IFileNameManager = new(PureFileNameManager)
  return mgr.IsFileExist(fileName)
}
複製代碼

這樣雖然添加了一箇中間層, 可是仍是與外部依賴綁定了. 解決辦法是: 使用依賴注入, 在被測試單元中注入一個僞實現. 依賴注入有兩種方法: 構造函數注入和屬性注入. 如何選擇? 若是依賴項是必須的, 就用構造函數注入, 不然儘可能使用屬性注入. 下面爲咱們最終改造後的代碼, 經過這樣的改造, IsFileExist()函數就與文件系統的強依賴解耦了, 能夠進行單元測試了.

var mgr IFileNameManager

func SetPureFileNameManager() {
  mgr = new(PureFileNameManager)
}

func SetFakeFileNameManager() {
  mgr = new(FakeFileManager)
}

func IsFileExist(fileName string) bool {
  return mgr.IsFileExist(fileName)
}
複製代碼

mock

一個工做單元可能有三種最終結果: 返回值, 改變系統狀態, 調用第三方對象. 對單元測試來說, 前兩種結果與第三種有一個顯著區別: 返回值和改變系統狀態都是在當前系統中可觀測到的, 能夠直接對當前系統進行斷言以驗證測試結果正確性. 而調用第三方對象時, 當前系統的狀態有可能未發生任何改變, 驗證測試結果正確性須要對第三方對象進行斷言. 這就引出了另外兩個概念: 存根 (stub)和模擬(mock).

fake對象既能夠做stub, 也能夠做mock, 區別在於: 測試結果是否依賴對fake對象的斷言 (也就是書中說的: stub不會致使測試失敗, 而mock能夠). 若是是, 那fake對象就是mock, 不然就是stub. 換言之, mock關注工做單元對外部依賴影響, stub關注外部依賴返回給工做單元的結果.

來看下面這段代碼. 這一段實現了這樣一個功能: 若是是星期六, 天氣下雨, 就訂一份外賣:

var kfc KFC // KFC餐廳
var address string // 個人收貨地址

type KFC struct {}
func (t *KFC) OrderLunch(address string) {
  fmt.Printf("Lunch is send out to %s, please wait!\n", address)
}

func DailyTask(date, weather string) {
  addr := GetMyAddress()
  if isSatuday(date) && isRainy(weather) {
    kfc.OrderLunch(addr) // 給KFC發送一個訂餐事件
  }
}
複製代碼

咱們如何肯定這個DailyTask()是否真的會在星期六的雨天發送這樣一個訂餐事件? OrderLunch()方法並無返回值, 也沒有改變被測系統的狀態, 須要檢查KFC內部的狀態. 所以, 咱們引入一個MockKFC對象, 調用該對象的OrderLunch()能夠記錄下傳入的address參數.

var kfc IKFC // 把KFC變成一個接口, 便於注入mock對象

type IKFC interface {
  OrderLunch(address) // 送餐
}

type MockKFC struct{
  TestAddr string
}
func (t *MockKFC) OrderLunch(address string) {
  t.TestAddr = address
  fmt.Printf("Lunch is send out to %s, please wait!\n", address)
}
複製代碼

這樣改造以後, 測試代碼就很好寫了:

func TestDailyTask(t *testing.T) {
  kfc = new(MockKFC)
  address = "zju"
  DailyTask("Saturday", "rainy")
  if kfc.TestAddr != address {
    t.Error("address not equal")
  }
}
複製代碼

mock框架

通常來講, 咱們不會手動管理mock對象, 而是採用mock框架幫助咱們完成這些事情. 書中第5,6章介紹了mock框架方方面面, 包括工做原理, 分類, 模式等等. 沒太仔細看.

第三部分 測試代碼

主要介紹了測試代碼的組織方式 (第7章) 以及編寫測試的最佳實踐 (第8章).

如何組織測試代碼

首先須要明白的是, 對於一個應用程序而言, 單元測試和產品源代碼同等重要. 測試代碼一樣須要維護.

  • 使你的測試自動化, 而且與自動化構建創建關聯.
  • 根據測試類型組織測試代碼, 將單元測試和集成測試放到不一樣的目錄下.
  • 確保測試代碼是源代碼管理的一部分, 應將測試代碼放入代碼倉庫中,並保證測試代碼版本和所測試的產品代碼版本相對應.
  • 創建測試代碼和被測代碼的映射關係.
  • 注入橫切關注點. 例如系統時間, 若是改寫爲注入, 無疑會使代碼複雜化. 做者給出的解決方案是封裝一個自定義的系統時間類, 該類能夠對系統時間進行自定義設置. 這樣就很容易對
  • 持續對測試代碼進行重構, 提升測試代碼的可維護性.

如何寫好測試代碼

優秀的測試應該同時具備以下三個屬性: 可靠性, 可維護性, 可讀性.

編寫可靠的測試

關於如何編寫可靠的測試, 做者給出了幾點建議:

  • 跟隨產品需求的變化, 刪除或修改原有測試代碼
  • 避免測試中的控制邏輯
  • 每一個測試只測試一個關注點
  • 把單元測試和集成測試分開
  • 用代碼審查確保測試覆蓋率是有效的

編寫可維護的測試

  • 測試私有方法時, 思考其必要性
  • 去除重複代碼
  • 以可維護的方式使用setup方法 (重要)
  • 實施測試隔離, 一個測試不該依賴於其餘測試, 測試不該該依賴順序
  • 避免對不一樣關注點屢次斷言, 防止某一斷言失敗致使其後的斷言沒法執行
  • 對象比較時, 不要對對象中的每一個屬性進行斷言, 而應該對對象總體進行斷言
  • 避免過分指定 (只檢查最終行爲的正確性, 不要對被測單元的內部行爲進行假設)

編寫可讀的測試

單元測試命名很是重要. 一個測試名包含三個部分: 被測試方法名, 測試場景, 預期行爲. 例如: Sum_ByDefault_ReturnsZero()

注意單元測試中的變量命名規範, 不要出現magic number, 應該在變量名中反映出變量的含義.

給出有意義的斷言信息, 可以清晰地反映測試結果. 同時在代碼上要將斷言和操做分離, 不要把斷言和操做寫到一行裏面.

不要濫用setup和teardown. 初始化模擬對象, 設置預期值這些操做應該放到測試方法中, 而不該該放到setup中. teardown通常用於集成測試, 在單元測試中, 只會在重置一個靜態變量或單例的狀態時纔會使用.

第四部分 測試流程

在組織中引入單元測試

這一章跳出了單元測試的技術細節, 轉而從管理的角度探討了如何推進單元測試在團隊中落地.

如何在組織中引入單元測試? 做者給出的建議是: 小團隊, 低風險項目, 領導者願意接受變革. 須要注意, 必定要在確保你瞭解單元測試的基礎上, 再推進單元測試, 萬不可倉促實施單元測試. 此外, 還須要必定的政策上的支持.

做者指出, 要開始單元測試, 至少須要30%的工做時間. 然而, 引入單元測試並不必定會致使總體流程時間增長. 進行單元測試更容易在開發期修復bug, 從而減小集成測試的時間, 項目的交付期有可能提早.

單元測試是否會搶了QA飯碗? 不會的, 單元測試的存在會使QA更專一於尋找實際應用中的邏輯缺陷, 讓QA專一於更大的問題. 有些公司QA工程師也寫代碼, 開發者和QA工程師均可以編寫單元測試.

做者有一句話寫得很是好: 你須要使用單元測試, 確保人們知道代碼的功能是否受到破壞.

編碼是代碼生命週期的第一步. 在生命週期的大部分階段, 代碼都處於維護模式. "大部分的缺陷並非來自代碼自身, 而是由人們之間的誤解, 不斷變化的需求以及缺乏應用領域知識形成的."

遺留代碼

對一個遺留項目, 如何從0到1開始單元測試? 首先你須要列出項目組件的測試優先級. 能夠經過邏輯複雜度, 依賴數, 重要程度判斷其優先級. 通常來講, 邏輯驅動的容易測試, 依賴驅動的難以測試 (須要mock).

在肯定了優先級以後, 須要選擇測試策略, 先易後難, 先難後易, 各有優劣.

在重構代碼前, 先進行集成測試, 確保重構時不會破壞原有功能.

設計與可測試性

什麼是可測試的設計? 就是代碼架構便於進行測試. 而測試的關鍵在於"接縫". 對靜態語言來講, 須要主動採用容許替換的設計 (即提供接口), 代碼才能得到可測試性. 而對於動態語言, 可測試性設計就顯得不那麼有意義.

可測試的設計與SOLID原則相關, 通常來講知足SOLID原則的設計都是可測試的設計, 而反之不成立, 所以: 可設計性並非優秀設計的目標, 而是優秀設計的副產品.

可測試設計會增長工做量, 編寫更多的代碼. 能夠首先使用簡單設計, 在須要時再進行重構.

延伸閱讀

書中提到了一些其餘的技術書籍, 我的以爲有些書籍還不錯, 列舉以下:

  • Clean Code, 優秀代碼風格
  • Dependency Injection in .NET, 教你寫IoC框架
相關文章
相關標籤/搜索