Test Double 概觀:mock 與 spy 是什麼,以及 restore、reset、clear 的差異
進入新的章節「Stubs, Spies, and Mocks」,這篇先從最上位的概念 Test Double 開始,說明為什麼測試常常需要用假的東西取代真實的外部依賴。
Test Double:mock、spy、stub 共用的上位概念
Test Double 是一個籠統的統稱,涵蓋 mock、spy、stub,以及其他各種在測試裡拿來模擬、造假的東西。講師坦言,如果不是為了準備課程,他自己也常常搞不清楚這些詞彼此的差異。這些名詞本質上都是同一個概念的不同變化,差別主要在外觀跟方法名稱,術語本身不是重點,概念上抓到就好。
為什麼需要它:把程式碼獨立出來測試
核心目的是把程式碼放在隔離的環境裡測試。實際的應用程式通常會呼叫 API、處理時間、用到 console.log 或 fetch,或是依賴某個第三方套件,但測試不需要連帶去驗證別人寫的程式碼對不對,真正該確認的是「我改了自己的程式碼之後,它還能不能正常運作」。
有些情況下沒辦法完全掌控外部世界,這時候想確認的往往不是回傳值,而是「這個東西有沒有被正確呼叫」,例如某個函式應該要呼叫 alert 或 fetch,測試想驗證的就是它有沒有真的被呼叫、呼叫時帶的參數對不對。理想上程式碼都應該是純函式(idempotent):東西進去、回傳值出來,沒有任何副作用。但操作 DOM 是副作用,寫 log 也是副作用,幾乎所有重要的事情都是副作用,這就是寫程式的現實,測試有時候免不了要處理外部世界的東西。
用力過猛的代價:這是一場取捨遊戲
擁有這些能替換外部依賴的能力,不代表可以無限上綱地用。前面課程已經提醒過,如果每個東西都要模擬、控制,最後只會做出一堆沒有意義的測試;就像稍早提到隨機值的例子(擲骰子、ID 產生器),有時候只是想讓某些原本不可預期的東西暫時固定下來而已。
要不要用 test double,很大程度是一場取捨遊戲,答案常常是「看情況」:
- 如果能快速準備一份乾淨的後端資料狀態給單一測試用,測完再重置回乾淨狀態,而且整個流程接進測試流程裡並不痛苦,那當然值得這麼做
- 如果選擇真的去模擬網路請求,代價是這也意味著要自己掌控整台伺服器的行為。講師提過好幾次遇到的實際狀況:後端團隊在 staging 環境改壞了一支 API,結果自己的測試也跟著壞掉;模擬網路請求確實犧牲了一些真實性,但如果測試會因為別人改了一個跟自己程式碼無關的 CSS 而失敗,也不是什麼好事
- 不用每次測試都真的啟動、關閉伺服器,也能少一個可能出錯的環節
- 時間相關的東西同樣是老問題
這些考量沒有哪個是絕對正確的鐵律,更像是各自情境底下的利弊權衡。
Mock 跟 Spy 分別在做什麼
- Mock:偽造一個外部的東西,讓程式碼在測試裡呼叫到的不是真正的實作
- Spy:讓你可以在旁邊觀察一個函式,記錄它有沒有被呼叫、呼叫時帶了什麼參數。特別適合用在函式本身沒有回傳值、但內部呼叫了某個內建或第三方函式庫的情況,這時候沒有回傳值可以直接斷言,只能靠 spy 確認它確實做了該做的事
如果聽到這裡會想到「這不是可以直接用依賴注入,把值傳進去就好了嗎」,這個想法完全正確:需要的時候再用 test double,但用之前要仔細想清楚是不是真的需要。
測試後怎麼把東西還原:clear、reset、restore 的差異
替換掉一個外部依賴之後,測試結束後通常需要把它還原,這裡有幾種做法:
restore:把原本的實作放回去,例如原本 mock 掉的fetch,呼叫這個之後就會變回真正的fetch,之後有需要還是可以再重新 mock 一次reset:保留現有的 mock 本身,但把它的實作清空、狀態重置,記錄下來的呼叫紀錄也一併清掉clear:單純把記錄下來的呼叫紀錄清掉,mock 本身的行為維持不變
restore 涵蓋了 reset 跟 clear 做的事,再加上把原始實作復原這一步。如果不確定該用哪一個,先用 restore 就對了,等真的遇到需要細分的情境再回頭研究其他兩個的差異。實務上常見的做法是把這些還原邏輯放進 beforeEach,每個測試開始前先把上一個測試留下的東西清乾淨。
為什麼會有這些機制:從「共用同一個 process」到「測試隔離」
在比較舊的測試環境裡,所有測試都在同一個 process 裡執行,一個測試如果 mock 掉了一堆東西又忘記清乾淨,後面的測試就會沿用這個被污染過的狀態繼續跑,出錯了也很難追。現在主流的測試執行環境(包括 Vitest 跟 Jest)都內建了測試隔離:每一個測試都在獨立的環境裡執行,互相污染的風險比以前低很多。不過如果專案裡還在用比較舊的工具鏈,這些還原機制的重要性就會相對高一些。
底層其實都是同一批老字號函式庫
雖然 Test Double 這一整套概念聽起來很新,但背後用的東西其實一點都不新。Vitest 底層用的 mocking、stubbing 函式庫是 Sinon,一個超過十年歷史的老函式庫;前面一直在用的 expect 斷言語法,來自另一個同樣有十幾年歷史的斷言函式庫 Chai;Jest 則是包了一層在有十五年歷史的測試執行器 Jasmine 上面。也就是說,Vitest 跟 Jest 很大程度只是在這些老函式庫外面包了一層,底層的運作方式其實沒有真的變過。這也是為什麼講師會說術語上的差異不重要,因為不同工具鏈骨子裡處理這些事情的方式其實很相似。有時候在編輯器裡把游標移到這些方法上,型別提示(IntelliSense)也會露出底層真正呼叫到的函式庫。
複習
測試裡用來統稱各種模擬、假造之物的上位詞彙是什麼?
Test Double
在測試情境裡,spy 是什麼?
一種可以觀察函式的方法,用來確認它被呼叫時帶了什麼參數,特別適合用在函式本身沒有回傳值、但內部呼叫了其他函式的情況
使用 test double 的主要目的是什麼?
讓程式碼可以在隔離的環境下測試,不需要依賴 API、跟時間有關的函式,或第三方函式庫這類外部依賴
為什麼現代的測試執行器比較不用擔心 mock 污染的問題?
Vitest、Jest 這類現代測試執行器大多內建測試隔離機制,每個測試都在獨立的環境裡執行,降低了前一個測試殘留的 mock 狀態影響到後面測試的風險
什麼是 test double,有哪些常見的例子?
Test double 是測試裡用來取代真實依賴的各種模擬、假造物件的統稱。常見的例子包括 mock(偽造一個外部的東西)、spy(觀察一個函式、確認它被呼叫時帶了什麼參數)跟 stub,這些都能讓程式碼在不依賴外部 API、第三方函式庫,或是像時間這種難以預測的因素之下進行測試
小測驗
測試裡用來統稱各種模擬、假造之物的上位詞彙是什麼?
Test Doubles在測試術語裡,「spy」指的是什麼?
一個用來觀察、追蹤方法呼叫情形的函式使用 test double 的主要目的是什麼?
讓程式碼在隔離的環境下測試,並控制外部依賴為什麼 Vitest 這類現代測試執行器會提供測試隔離機制?
避免測試之間因為彼此的 mock 依賴互相干擾在單元測試裡,使用 test double 的主要目的是什麼?
藉由模擬 API、時間、第三方函式庫這類外部依賴,讓程式碼能在隔離的環境下測試此文章是 FrontendMasters 上的 Testing Fundamentals 課程筆記
