編寫單元測試時,不要使用 Mocks

已發表: 2018-05-02

注意:這是我們最新的技術工程帖子,由首席工程師 Seth Ammons 撰寫。 特別感謝Sam Nguyen、 Kane Kim、Elmer Thomas 和 Kevin Gillette 對本文的同行評審 如果有更多這樣的帖子,請查看我們的技術博客。

我真的很喜歡為我的代碼編寫測試,尤其是單元測試。 它給我的信心是偉大的。 拿起我很久沒做過的東西並能夠運行單元和集成測試讓我知道如果需要我可以無情地重構,只要我的測試有良好和有意義的覆蓋率並繼續通過,以後我還會有功能軟件。

單元測試指導代碼設計,使我們能夠快速驗證故障模式和邏輯流程是否按預期工作。 有了這個,我想寫一些可能更具爭議性的東西:在編寫單元測試時,不要使用模擬。

讓我們在桌子上得到一些定義

單元測試和集成測試有什麼區別? 我所說的模擬是什麼意思,你應該用什麼來代替? 這篇文章的重點是在 Go 中工作,所以我對這些詞的看法是在 Go 的上下文中。

當我說單元測試時,我指的是那些通過測試小代碼單元來確保正確錯誤處理和指導系統設計的測試。 按單元,我們可以指整個包、接口或單個方法。

集成測試是您實際與依賴系統和/或庫交互的地方。 當我說“模擬”時,我​​特別指的是“模擬對象”一詞,即我們“用虛擬實現替換域代碼,這些虛擬實現既模擬真實功能又強制對我們的代碼行為進行斷言 [1]”(強調礦)。

說得更短一點:模擬斷言行為,例如:

MyMock.Method("foo").Called(1).WithArgs("bar").Returns("raz")

我提倡“假貨而不是假貨”。

假貨是一種可能包含商業行為的測試替身[2]。 假貨只是適合接口的結構,是我們控制行為的一種依賴注入形式。 fake 的主要好處是它們減少了代碼中的耦合,而 mock 增加了耦合,而耦合使重構更加困難 [3]。

在這篇文章中,我打算證明偽造品提供了靈活性並允許輕鬆測試和重構。 與模擬相比,它們減少了依賴關係,並且易於維護。

讓我們來看一個比“測試求和函數”更高級的示例,正​​如您在此類典型帖子中看到的那樣。 但是,我需要為您提供一些上下文,以便您可以更輕鬆地理解本文後面的代碼。

在 SendGrid,我們的一個系統傳統上在本地文件系統上有文件,但由於需要更高的可用性和更好的吞吐量,我們正在將這些文件轉移到 S3。

我們有一個需要能夠讀取這些文件的應用程序,我們選擇了一個可以在“本地”或“遠程”兩種模式下運行的應用程序,具體取決於配置。 許多代碼示例中忽略的一個警告是,在遠程故障的情況下,我們會退回到本地讀取文件。

有了這個,這個應用程序就有了一個包吸氣劑。 我們需要確保 package getter 可以從遠程文件系統或本地文件系統獲取文件。

天真的方法:只調用庫和系統級調用

天真的方法是我們的實現包將調用getter.New(...)並將設置遠程或本地文件獲取所需的信息傳遞給它,並將返回一個Getter 。 然後,返回的值將能夠使用定位遠程或本地文件所需的參數調用MyGetter.GetFile(...)

這將為我們提供我們的基本結構。 當我們創建新的Getter時,我們初始化任何潛在的遠程文件獲取所需的參數(訪問密鑰和秘密),並且我們還傳遞了一些源自我們的應用程序配置的值,例如useRemoteFS將告訴代碼嘗試遠程文件系統。

我們需要提供一些基本功能。 在此處查看原始代碼 [4]; 下面是精簡版。 請注意,這是一個未完成的示例,我們將進行重構。

這裡的基本思想是,如果我們配置為從遠程文件系統讀取,並且我們獲得遠程文件系統詳細信息(主機、存儲桶和密鑰),那麼我們應該嘗試從遠程文件系統讀取。 在我們對系統遠程讀取有信心後,我們會將所有文件讀取轉移到遠程文件系統,並刪除對從本地文件系統讀取的引用。

這段代碼對單元測試不是很友好; 請注意,要驗證它是如何工作的,我們實際上不僅需要訪問本地文件系統,還需要訪問遠程文件系統。 現在,我們可以做一個集成測試並設置一些 Docker 魔術來擁有一個 s3 實例,從而允許我們驗證代碼中的快樂路徑。

僅進行集成測試並不理想,因為單元測試通過輕鬆測試替代代碼和故障路徑來幫助我們設計更健壯的軟件。 我們應該為更大的“它真的有效”類型的測試保存集成測試。 現在,讓我們專注於單元測試。

我們怎樣才能使這段代碼更易於單元測試? 有兩種思想流派。 一種是使用模擬生成器(例如 https://github.com/vektra/mockery 或 https://github.com/golang/mock)創建樣板代碼以在測試模擬時使用。

您可以走這條路線並生成文件系統調用和 Minio 客戶端調用。 或者,也許你想避免依賴,所以你手動生成你的模擬。 事實證明,模擬 Minio 客戶端並不簡單,因為您有一個具體類型的客戶端,它返回一個具體類型的對象。

我說有比嘲笑更好的方法。 如果我們重構我們的代碼以使其更具可測試性,我們不需要額外導入模擬和相關雜物,也不需要了解額外的測試 DSL 來自信地測試接口。 我們可以將我們的代碼設置為不會過度耦合,測試代碼將只是使用 Go 接口的普通 Go 代碼。 我們開始做吧!

接口方法:更抽象,更容易測試

我們需要測試什麼? 這就是一些新的 Gophers 出錯的地方。 我看到人們理解利用接口的價值,但覺得他們需要與他們正在使用的包的具體實現相匹配的接口。

他們可能會看到我們有一個 Minio 客戶端,因此他們可能會首先製作與 Minio 客戶端(或任何其他 s3 客戶端)的所有方法和用途相匹配的接口。 他們忘記了“接口越大,抽象越弱”的 Go 諺語 [5][6]。

我們不需要針對 Minio 客戶端進行測試。 我們需要測試我們可以遠程或本地獲取文件(並驗證一些故障路徑,例如遠程故障)。 讓我們重構最初的方法並將 Minio 客戶端拉到遠程 getter 中。 當我們這樣做時,讓我們對本地文件讀取的代碼做同樣的事情,並創建一個本地 getter。 以下是基本接口,我們將有類型來實現每個接口:

有了這些抽象,我們就可以重構我們的初始實現。 我們將把localFetcherremoteFetcher放到Getter結構中並重構GetFile以使用它們。 在此處查看完整版本的重構代碼 [7]。 以下是使用新界面版本的稍微簡化的代碼段:

這個新的、重構的代碼更易於單元測試,因為我們將接口作為Getter結構的參數,並且我們可以更改偽造的具體類型。 我們不需要模擬操作系統調用或需要完全模擬 Minio 客戶端或大型接口,我們只需要兩個簡單的假貨: fakeLocalFetcherfakeRemoteFetcher

這些贗品有一些屬性,可以讓我們指定它們返回的內容。 我們將能夠返回文件數據或我們喜歡的任何錯誤,並且我們可以驗證調用GetFile方法是否按預期處理數據和錯誤。

考慮到這一點,測試的核心變成:

使用這種基本結構,我們可以將其全部包含在表驅動測試中 [8]。 測試表中的每個案例都將測試本地或遠程文件訪問。 我們將能夠在遠程或本地文件訪問中註入錯誤。 我們可以驗證傳播的錯誤,文件內容是否被傳遞,以及預期的日誌條目是否存在。

我繼續並在此處提供的一個表驅動測試中包含了所有潛在的測試用例和排列 [9](您可能會注意到一些方法簽名有點不同 - 它允許我們執行諸如注入記錄器和斷言日誌語句之類的事情)。

漂亮,嗯? 我們可以完全控制我們希望GetFile的行為方式,並且可以針對結果進行斷言。 我們將代碼設計為對單元測試友好,現在可以驗證在GetFile方法中實現的成功和錯誤路徑。

代碼是鬆散耦合的,未來的重構應該是輕而易舉的事。 我們通過編寫簡單的 Go 代碼來做到這一點,任何熟悉 Go 的開發人員都應該能夠在需要時理解和擴展。

Mocks:關於細節、堅韌不拔的實現細節呢?

我們在提議的解決方案中沒有得到什麼,模擬會給我們帶來什麼? 展示傳統模擬的好處的一個很好的問題可能是,“你怎麼知道你用正確的參數調用了 s3 客戶端? 使用模擬,我可以確保我將鍵值傳遞給鍵參數,而不是存儲桶參數。”

這是一個有效的問題,應該在某個地方進行測試。 我在這裡提倡的測試方法並不能驗證您是否以正確的順序使用存儲桶和密鑰參數調用了 Minio 客戶端。

我最近讀到的一句名言說:“模擬引入了假設,而假設又引入了風險 [10]”。 您假設客戶端庫實現正確,您假設所有邊界都是可靠的,您假設您知道庫的實際行為方式。

模擬庫只會模擬假設並使您的測試更加脆弱,並且在您更新代碼時會發生變化(這是 Martin Fowler 在 Mocks Aren't Stubs [3] 中得出的結論)。 當橡膠遇到問題時,我們將不得不驗證我們實際上是否正確使用了 Minio 客戶端,這意味著集成測試(這些可能存在於 Docker 設置或測試環境中)。 因為我們將同時進行單元測試和集成測試,所以不需要單元測試來涵蓋確切的實現,因為集成測試將涵蓋這一點。

在我們的示例中,單元測試指導我們的代碼設計,並允許我們快速測試錯誤和邏輯流程是否按設計工作,完全按照他們需要做的事情。
對於一些人來說,他們覺得這還不夠單元測試覆蓋率。 他們擔心以上幾點。 有些人會堅持使用俄羅斯娃娃風格的界面,其中一個界面返回另一個界面,另一個界面返回另一個界面,可能如下所示:

然後他們可能會將 Minio 客戶端的每個部分提取到每個包裝器中,​​然後使用模擬生成器(向構建和測試添加依賴項,增加假設,並使事情變得更脆弱)。 最後,mockist 將能夠說類似的話:

myClientMock.ExpectsCall("GetObject").Returns(mockObject).NumberOfCalls(1).WithArgs(key, bucket) - 如果你能回憶起這個特定 DSL 的正確咒語。

這將是許多與使用 Minio 客戶端的實現選擇直接相關的額外抽象。 當我們發現需要改變對客戶的假設或完全需要不同的客戶時,這將導致脆弱的測試。

這增加了現在和將來的端到端代碼開發時間,增加了代碼複雜性並降低了可讀性,可能增加了對模擬生成器的依賴,並為我們提供了一個可疑的附加價值,即知道我們是否混淆了存儲桶和關鍵參數無論如何,我們會在集成測試中發現。

隨著越來越多的對像被引入,耦合變得越來越緊密。 我們可能已經做了一個 logger mock,然後我們開始做一個 metrics mock。 在不知不覺中,您正在添加一個日誌條目或一個新指標,並且您剛剛破壞了無數測試,這些測試並不期望額外的指標通過。

上次我在 Go 中被這個問題困擾時,模擬框架甚至不會告訴我哪個測試或文件失敗了,因為它遇到了一個新指標(這需要通過對測試進行註釋來對測試進行二進制搜索)以便能夠找到我們需要更改模擬行為的位置)。 模擬可以增加價值嗎? 當然。 值得付出代價嗎? 在大多數情況下,我不相信。

接口:取勝的簡單性和單元測試

我們已經證明,我們可以通過在 Go 中簡單使用接口來指導設計並確保遵循正確的代碼和錯誤路徑。 通過編寫遵循接口的簡單偽造品,我們可以看到我們不需要模擬、模擬框架或模擬生成器來創建為測試而設計的代碼。 我們還注意到,單元測試並不是萬能的,您必須編寫集成測試以確保系統正確地相互集成。

我希望將來能獲得一些關於運行集成測試的巧妙方法的帖子; 敬請關注!

參考

1: Endo-Testing: Unit Testing with Mock Objects (2000): 模擬對象的定義見介紹
2:小嘲諷者:見關於贗品的部分,具體來說,“贗品有商業行為。 你可以通過提供不同的數據來驅動假貨以不同的方式行事。”
3: Mocks Aren't Stubs: 見章節,“那麼我應該成為古典主義者還是模仿者?” Martin Fowler 說:“我沒有看到 mockist TDD 有任何令人信服的好處,並且擔心耦合測試與實現的後果。”
4:Naive Approach:簡化版的代碼。 見[7]。
5:https://go-proverbs.github.io/:Go Proverbs 列表以及會談鏈接。
6:https://www.youtube.com/watch?v=PAAkCSZUG1c&t=5m17s:直接鏈接到 Rob Pike 關於接口大小和抽象的談話。
7:完整版demo代碼:你可以克隆repo並運行`go test`。
8:表驅動測試:一種用於組織測試代碼以減少重複的測試策略。
9:測試完整版的演示代碼。 您可以使用 `go test` 運行它們。
10:編寫測試時要問自己的問題 Michal Charemza:模擬引入假設,假設引入風險。