單元測試的基本概念 | 單元測試的藝術 第 3 版 | 閱讀筆記
28 Mar 2024「單元測試的藝術」讀書會 - 單元測試的基本概念 (The Art of Unit Testing, 3e - The Basics of Unit Testing) 閱讀筆記。
Hi
大家好,我是 Summer,今天想跟大家分享的是「The Basics of Unit Testing」這個章節。
Agenda
這是這次分享的大綱,會談到怎麼界定單元測試的範圍、怎麼樣寫好單元測試、單元測試與整合測試的差異,以及推行 TDD 有什麼好處、怎麼推行 TDD。
計時器
我們先來看一個例子,這裡有一個 <Timer>
元件,它的功能是倒數計時,會顯示還剩下的秒數,它會在經過 3 秒後倒數結束,並且在結束時顯示 Time's Up
訊息。
這是 <Timer>
元件的程式碼。
const Timer = () => {
// entry point 函式被呼叫
const [seconds, setSeconds] = useState(3);
const intervalIDRef = useRef(null);
const startTimer = useCallback(() => {
// exit point 呼叫外部函式
intervalIDRef.current = setInterval(
() => setSeconds((prev) => prev - 1), // exit point 改變狀態
timerTickInterval // timerTickInterval is a constant value 1000
);
}, []);
const stopTimer = useCallback(() => {
clearInterval(intervalIDRef.current); // exit point 呼叫外部函式
intervalIDRef.current = null;
}, []);
useEffect(() => {
startTimer();
return () => clearInterval(intervalIDRef.current); // exit point 呼叫外部函式
}, []);
useEffect(() => {
if (seconds === 0) {
stopTimer();
}
}, [seconds]);
// exit point 函式回傳結果
return (
<div>{seconds === 0 ? `Time\'s Up` : `Remaining seconds: ${seconds}`}</div>
);
};
點此看 demo。
SUT
- 定義:SUT (subject / system / suite under test) 或 CUT (component / class / code under test) 是指正在測試的東西,意即要測試的目標,可能是一個方法、一個類別、一個模組、一個系統。
- SUT 可以用來界定單元測試的 test suite 的範圍。
- 這裡的 SUT 是
<Timer>
元件。
Unit of Work
- 定義:從呼叫一個函式開始,直到這個函式產生結果的過程中的所有動作。
- unit of work 是用來觀察程式碼的流程,從觀察程式碼的流程,之後就能利用這個流程來決定 exit point。
- 這裡的 unit of work 是執行
<Timer>
元件經過的過程,其中包含初始化狀態、啟動 timer、時間倒數、停止 timer、顯示渲染結果。
Entry Point
在觀察程式碼的流程的時候,如果我們要看跟測試有關係的部份,主要是要看怎麼觸發還有會產生怎樣的影響,「怎麼觸發」稱為 entry point;會產生怎樣的影響,也就是會造成流程改變的地方,稱為 exit point。
- 定義:函式被呼叫、開始執行的地方。
- 這裡的 entry point 如同註解標記,是
<Timer>
元件開始執行的地方。
Exit Point
- 定義:會造成流程改變的地方,分為:回傳結果、改變狀態、呼叫 third-party dependency。
- dependency 表示在測試中沒有完全主控權的東西,例如:外部函式、外部模組、外部系統、外部服務等。等等談到 mock 的時候會來討論 dependency 要怎麼處理。
- 這裡的 exit point 有:
- 回傳結果:return JSX。
- 改變狀態:
- 在這裡會利用 React
useState
hook 來建立setSeconds
以及更新seconds
狀態,一般來說,我認為這不是 exit point,因為這只能算是儲存 local variable 的一種方式。但是,這個倒數計時器用到的 timer 是基於使用者的系統時間所設定的,它是由瀏覽器的 JavaScript 引擎實現 timer 的狀態。在這裡不會改變 timer 的狀態而只是做讀取的動作,但 timer 的值的確會不斷自動的改變,進而影響元件的表現,所以這是 exit point。 - 在我寫測試的經驗裡面,測試程式的實作細節或內部狀態是脆弱沒有彈性的,而測試外部的行為和表現會是更穩定的,所以我寫測試時會傾向確保外部行為和表現是正確的,而不是確認內部狀態,所以稍等在測試這一條 exit point 的時候,我會測試的不是
seconds
的值的變更。
- 在這裡會利用 React
- 呼叫外部函式:呼叫
setInterval
啟動計時器、呼叫clearInterval
停止計時器。
- exit point 是切分 test case 的基礎,每個 exit point 通常會規劃至少一個 test case,可以再利用傳入不同參數來規劃不同的 test case。因此,一條 unit test 的 test case 會確認一個 exit point 在特定條件下的正確性。
-
每種 exit point 會用不同的測試技術來確認 test case 的正確性,下面的程式碼是針對
<Timer>
元件的 exit point 來檢查正確性的測試範例。- Return-value-based exit point:檢查得到的結果是否如預期。
- 前面提到的
<Timer>
元件的 JSX 會根據seconds
的狀態決定回傳Time's Up
或Remaining seconds
就是這種 return-value-based exit point,所以會根據目前的seconds
的狀態來檢查回傳的內容。
- 前面提到的
- Mock-object-based test:由於是呼叫 dependency 也就是前面提到的外部函式,所以需要 mock 來處理。
- 在測試時間相關的功能時,不太可能真的等待預定的秒數,舉例來說,若某個倒數計時的元件要等待 30 秒,如果真的將測試實作等待 30 秒,這個測試必定會花很多時間。因此,在測試時間相關的功能時,會使用假的 timer 來協助測試。假的 timer 是指把原本的 timer,在測試程式裡替換成測試框架實作的替身,例如,在 Jest 裡面就會用
useFakeTimers
。設定的方式會是進行測試之前,會透過beforeAll
hook 呼叫 Jest 的useFakeTimers
,用以將時間替換成測試框架實作的替身,這樣就能經由操作這個替身來測試時間相關的功能;在測試完成後,會透過afterAll
hook 呼叫 Jest 的useRealTimers
,還原原本的時間設定,避免影響其他測試的執行。 - 在我的經驗中維護實作與模擬兩份程式碼是困難的,而且模擬測試沒辦法真的測到真實的互動,因此 mock 要盡量減少使用,這是需要取捨的。
- 在測試時間相關的功能時,不太可能真的等待預定的秒數,舉例來說,若某個倒數計時的元件要等待 30 秒,如果真的將測試實作等待 30 秒,這個測試必定會花很多時間。因此,在測試時間相關的功能時,會使用假的 timer 來協助測試。假的 timer 是指把原本的 timer,在測試程式裡替換成測試框架實作的替身,例如,在 Jest 裡面就會用
-
State-based test:關於狀態改變的部份,會需要 spy 或是呼叫其內部提供的
getCount
或getState
API 等其他工具來幫忙檢查。- 在這裡由於 timer 會不斷改變,照理說我們可以去監測 timer 是否如預期改變狀態,但是這樣會去檢測內部實作,這樣的測試是脆弱的,因為內部實作可能會改變,到時候測試就會失效。為了測試穩定,有幾種變通方式:
- 第一種是選擇測試 JSX 的顯示結果,這個範例已經用在前面的 return-value-based exit point。
- 第二種是測試 timer 改變後,相對應的呼叫是否被正確執行。舉例來說,測試 timer 改變後,也就是倒數結束時,是否有呼叫
clearInterval
。在這裡是使用 Jest 的jest.spyOn
將clearInterval
包裝成一個 spy 也就是spyOnClearInterval
,spyOnClearInterval
會在測試環境中替換掉真實的clearInterval
,就可以利用spyOnClearInterval
來監控它在元件移除時的呼叫情況,它會紀錄呼叫的次數和傳遞的參數等,稍後就可以用這些資訊以及toHaveBeenCalledTimes
來驗證clearInterval
是否按照預期的方式被呼叫。這樣就可以達到監測 timer 是否如預期改變狀態的目的。
describe('Timer component', () => { beforeAll(() => { jest.useFakeTimers(); }); afterAll(() => { jest.useRealTimers(); }); it('should show remaining 2 seconds when after 1 second', () => { const { getByText } = render(<Timer />); // Mock-object-based test act(() => { jest.advanceTimersByTime(1000); }); // Return-value-based exit point expect(getByText('Remaining seconds: 2')).toBeInTheDocument(); }); it("should show Time's Up when after 3 seconds", () => { const { getByText } = render(<Timer />); // Mock-object-based test act(() => { jest.advanceTimersByTime(3000); }); // Return-value-based exit point expect(getByText("Time's Up")).toBeInTheDocument(); }); it('cleans up the timer on unmount', () => { const { unmount } = render(<Timer />); const spyOnClearInterval = jest.spyOn(global, 'clearInterval'); unmount(); // State-based test expect(spyOnClearInterval).toHaveBeenCalledTimes(1); }); });
- 在這裡由於 timer 會不斷改變,照理說我們可以去監測 timer 是否如預期改變狀態,但是這樣會去檢測內部實作,這樣的測試是脆弱的,因為內部實作可能會改變,到時候測試就會失效。為了測試穩定,有幾種變通方式:
- Return-value-based exit point:檢查得到的結果是否如預期。
單元測試的特質
前面談論的是怎麼界定單元測試的範圍,接下來要談的是怎麼樣寫好單元測試。
首先要來看怎樣是單元測試?怎樣不是單元測試?以下任一條件不符會需要對程式碼進行重構讓它符合條件,或是歸類到整合測試。
- 簡單、清楚、易執行
- 目標明確、提示清楚明確
- (O)
should get 3 when 1 + 2
這就很明確表達1 + 2 = 3
,通常會用Given-When-Then
或it should
搭配 3A Pattern(Arrange → Act → Assert)來命名測試。 - (X)
get correct answer when execute sum
這樣就是很不清楚的、沒有效率的表達方式。
- (O)
- 好懂好維護:檢測元件狀態就是很難維護的例子,這在重構會很困難,所以要避免這樣的測試,現在大多是以測試外部行為和表現來確保測試的穩定性。
- (O) 對程式的外部行為進行測試。
- (X) 關注內在實作細節。
- 配置簡單
- (O) 一鍵執行。
- (X) 測試的配置不要過於複雜,例如:過多資源、設定、前置工作等。例如:環境配置需要 3 個 docker 來搭配測試,可能就會希望減少到 1 個就好。
- 自動執行:希望是能完全由程式自動測試,而不是需搭配手動測試。例如:有些測試可能要帶入假資料,比較好的作法會是把假資料存成 JSON 檔案讓測試帶入,而不是需要用 UI 介面操作來手動輸入,或其他 Chrome plugin 來協助完成。
- (O) 完全由程式自動測試。
- (X) 需搭配手動測試。
- 目標明確、提示清楚明確
-
穩定的、可預期的、特定輸入得到一致輸出,非 flaky test。flaky test 是指測試有時會通過、有時會失敗,這樣的測試是不穩定的,會讓開發者失去信心,不知道測試結果是對的還是錯的,這樣的測試是沒有意義的。
- 有完全的控制權:對於沒有完全控制權的東西要用模擬來取代。模擬是指用假的東西來取代真的東西,例如:用假的資料庫、用假的網路、用假的檔案系統等。模擬的好處是可以讓測試更穩定,避免 flaky test,但是模擬的壞處是無法測試真實的狀況,所以模擬只能用在單元測試,而不是整合測試。
- 隔離於獨立的狀態與環境、與其他測試沒有相依,目的同樣也是希望測試是穩定的,不會造成 flaky test。解法是確保測試在執行前處於相同的起始狀態。
- 沒有資源相依,例如:可用 mock 或固定測試資料、使用 memory,或是排除造成不穩定的測試的狀況,例如:寫檔、寫資料庫、網路。但 mock 並不會測試到真實的狀況,所以不見得是正確的,因此常常需要手動在正式環境測試。用模擬的在 unit test 以及手動在正式環境上測試,兩者都有是比較好的。
- 同步且線性執行,較好理解、避免 flaky test。
- 執行速度快:執行速度會牽涉到開發者執行測試的意願、收到回饋的效率、調整程式碼的範圍。執行速度愈快,開發者愈容易常常跑測試、愈容易找到問題、愈容易調整程式碼。常見的例子像是
- 一次只檢測一個目的。
- 比對特定元素,減少快照的使用,產生與儲存快照耗費大量時間空間。
單元測試 vs 整合測試
比較單元測試與整合測試的差異。
- 整合測試即是有相依狀況的單元測試。
- 這本書提到,由於整合測試可以 cover 更多的程式碼,所以有人會認為整合測試比單元測試好,但是單元測試可以測到更多細節。
我個人的觀點是:
- 不同的測試的目的和給予的提示是不同的,unit testing 所提供的資訊能準確解決問題,而 integration testing 更接近使用者層級的測試、可涵蓋更多情境,情境才是能提高測試的有效性,達成有效撰寫測試、完善覆蓋功能的目的。
- 在功能分批開發的狀況下,為了確保程式碼品質,每次進 code 都會實作適合的測試,這些測試可能是重複的,但是如同前面提到,達到的目標是不同的,能給予的提示也是不一樣的,都應予保留。若在開發功能後才實作測試,通常會著重在實作 integration testing 上。
- 針對不同產品與開發階段,著重的焦點與測試種類會有所不同。產品規模較大時,需要與不同元件、產品或服務整合,以確保功能正常,因此,integration testing 的比例會比較高;反之,若產品規模較小、功能單純,此時要檢驗的大多是自身功能,unit testing 的比例就會比較高。
良好的單元測試
良好的單元測試具備哪些特質?
- Readability 可讀性:如果不能讀懂就沒辦法維護。
- Maintainability 可維護性:程式碼會修改,測試也會修改,不能修改的測試無法因應程式碼的變化。
- Trust 可信性:如果測試的成功或失敗不能讓開發者信任,那麼測試就沒有意義。例如:開發者會開始手動測試,失去自動化測試所能提供的的時間效益;或是,增加新功能或重構時沒有提供應有的保護能力,就會很難持續開發,也會導致程式碼品質不穩定,或是無意間更動核心邏輯。
TDD
推行 TDD 有什麼好處
- 寫測試的時機點:
- 寫完功能。
- 合併到主分支前。
- 寫功能之前,依照情境寫一個會失敗的測試然後修好它,接著再來一次,像是繼續寫下一個測試或重構,這就是 TDD (test-first or test-driven development)。
- TDD 的好處是驗證測試的正確性,看到測試失敗後再寫產品的程式碼,就可以確保這些測試在功能失效時會失敗。檢測測試本身,測試本身可能是錯的,要仔細驗證。
怎麼推行 TDD
推行 TDD 成功的三要素:
- 測試的品質,知道如何寫好的測試:可維護、可讀性、可信任。這本書會著重在這裡,TDD 和 SOLID 就不是本書重點。
- 先寫測試:先寫測試,再寫產品的程式碼。
- SOLID 設計:寫好的測試和產品程式碼的設計。SOLID design 是指 Single Responsibility Principle、Open/Closed Principle、Liskov Substitution Principle、Interface Segregation Principle、Dependency Inversion Principle。關於如何撰寫好的設計文件,可參考 Sean Chou 的文章 如何寫一份前端的開發設計文件?。
在我個人的開發經驗裡面,TDD 很難落實的主要原因是:
- 不知道怎麼寫測試,或是不知道怎麼寫好測試,我會推薦 91 老師的課。
- 需求變化大,需求變化大的情況下,寫好的測試可能會變得沒有用,不如先寫功能,等需求穩定了再來寫測試。解法是可以盡量讓測試有彈性來應對需求的變化,或是用 design document 產生的 UI flow diagram 讓 ChatGPT 幫忙產生測試程式。
- 時程壓力,只能開發功能,而忽略測試。我會希望這樣的狀況盡量不要成為常態,因為沒有測試的保護,功能的品質會變得不穩定,開發者會失去信心,開發速度也會變慢。如果真的沒有時間寫測試,我會開 follow-up 的 task 做後續追蹤,盡量排時間補上測試。
不過儘管是這樣,TDD 始終沒有成為我待過的任何一個組織的常態工作流程,大多成為個人愛好或習慣而已。
總結
- 什麼是好的單元測試:速度快、對要測試的程式碼有完全的主控權、沒有與其他測試或資源相依、同步和線性執行。
- public function 是 entry point,用來進入 unit of work 來觸發測試;exit point 是測試會檢查的地方,包含回傳結果、改變狀態、呼叫 third-party dependency,分別需要不同的測試技術來做檢測。
- unit of work 是從 entry point 到 exit point 的過程,是用來觀察程式碼的流程,之後就能利用這個流程來決定 exit point。
- integration testing 即是有相依狀況的 unit testing。
- 好的測試應具備可讀性、可維護性、可信任性。
- TDD 是指在開發前先寫測試的技術,TDD 的好處是驗證測試的正確性,看到測試失敗後再寫產品的程式碼,就可以確保這些測試在功能失效時會失敗。