今天的前端夜點心我們來聊聊在專案中單元測試應該測些什麼?
以國內網際網路的開發節奏,在前端業務專案中全面覆蓋單元測試有時顯得不太可行,主要是因為以下這些絆腳石:
- UI 互動複雜,路徑難以覆蓋全面
- 工期緊,開發對實踐 TDD,BDD 所帶來的長遠效益沒有信心
- 產品經理們時不時打著「敏捷開發」的旗號改需求,使得剛剛辛辛苦苦寫完的測試指令碼完全作廢
在這樣的處境下,一味強調單元測試的邏輯覆蓋率是沒有太大意義的,明確在哪裡應用單測的能取得最大的邊際效益是更有意義的事情。
以下筆者根據自己的一些在單測的實戰經驗,列出了三項關於「單元測試應該測什麼」的觀點並附以一些例子與大家交流:
單元測試並非測試的全部
拿來主義地對待單元測試
單測只是一種區域性模組測試,是諸多測試方案中的一種,認識到這一點可以避免我們為了測試而測試,或者為了指標而測試。
同時也應該認識到單測本身的覆蓋能力也是有限的,全部用例的 PASS 和 100% 的覆蓋率都不能保證被測試模組的所有邏輯路徑都有正確的行為。
是否對一個模組使用單元測試往往取決於這個模組的邏輯穩定性和業務型別
例如對於一個底層 npm 包專案,單元測試幾乎是他唯一的程式碼質量保障手段,這時就應該儘可能通過單元測試驗證它在各種應用場景下的行為是否符合預期,來最低成本地保證它每次發包和更新的質量。對這類專案,徹底應用 BDD 開發模式也會獲得越來越高的開發效率收益。
而對於一個功能複雜的 UI 元件,除了單元測試,還有 E2E 測試,自動化迴歸測試,QA 手動測試(?)來保障它的程式碼質量。此時使用單元測試的邊際效益可能不是最高的,可以考慮通過別的手段來回歸它的邏輯。也可以考慮在初版功能驗證上線後通過快照測試(snapshot)來回歸驗證每一次迭代的邏輯。
邊界環境的模擬
讓模組穿梭時空
單測的一個很重要的意義是幫助我們在開發階段模擬出 QA 手動測試(?)甚至線上使用場景下都不易觸達的邊界場景,如:
- 模擬個別瀏覽器下的 JS 版本
- 模擬某個 URL 狀態
- 模擬某種本地快取狀態
- 模擬不同時區下的情形
- 模擬時間過了一個小時(這幾乎只有單元測試能夠做到)
等等
使用這類模擬對模組進行單元測試的邊際效益是極高的,往往比 QA 去作等價的模擬快得多。
比如下面這段指令碼,通過 jest 的 timer mock 能力,實現了對 expire
函式的測試:
const expire = (callback) => setTimeout(callback, 60000); // 一分鐘以後過期
test('到點就呼叫回撥', () => {
const callback = jest.fn();
expire(callback);
jest.advanceTimersByTime(59999);
expect(callback).not.toBeCalled();
jest.advanceTimersByTime(1);
expect(callback).toBeCalledOnce();
})
複製程式碼
這段程式碼通過 jest.advanceTimersByTime
精確模擬了巨集任務的執行過程,同步完成了原本需要一分鐘才能驗證一次的非同步流程的測試。
又比如下面的測試指令碼用來測試一個名為 catchFromURL
的工具函式,該函式可以從當前的 URL 中獲取指定的引數作為返回值返回,同時從 URL 中抹去該引數。
這中需求通過 URL 攜帶 token 資訊的業務場景(如單點登入)中是非常常見的。
test('通過URL獲取指定的引數值並抹去之', () => {
const CURRENT_ORIGIN = document.location.origin;
const testHref = `${CURRENT_ORIGIN}/list/2/detail?a=123b&b=true#section2`;
history.replaceState(null, '', testHref);
expect(catchFromURL('a')).toBe('123b');
expect(document.location.href).toBe(`${CURRENT_ORIGIN}/list/2/detail?b=true#section2`);
})
複製程式碼
這段測試程式碼通過 jsdom 來實現對需要測試的環境的模擬。環境的構造和模擬其實是單元測試中的一個難點,由於 jsdom 本身的一些缺陷(如沒有實現 Navigator)使得在測試指令碼執行的 node 環境中模擬正確的瀏覽器環境往往需要用到很多的 Hack 技術,這一點在未來的夜點心中會著重中展開討論。
點到為止
less is more
測試程式碼無需關心被測試模組的具體實現,點到為止地測試幾種必要的流程場景即可。這一方面可以減少寫測試邏輯的時間,一方面可以使得業務邏輯具有更大的實現自由度。
對一個業務模組,測試指令碼只需要關心該模組所關聯的所有外部性即可:
- 對於函式模組而言,控制它引用的模組、它的輸入和它的副作用,驗證它的輸出和對副作用的影響
- 對於元件模組而言,控制它依賴的服務、它依賴的子元件、它的 props和它的事件,驗證它的渲染結果和 props 中回撥的呼叫情況,而不應該關心它的 state。
下面的指令碼通過 enzyme
元件測試工具測試了一個名為 ValidatableInput
的 React 元件。這個元件在失焦(blur)時會觸發 onValidate
回撥,並傳入 inputValue
引數。
test('失焦時觸發 onValidate', () => {
const onValidate = jest.mock();
const inputValue = '輸入的內容';
const wrapper = shallow(
<ValidatableInput
placeholder={''}
value={inputValue}
alert={''}
onChange={onChange}
onValidate={onValidate}
/>
);
wrapper.find('.validatable-input').first().simulate('blur');
expect(onValidate).toBeCalledWith(inputValue);
});
複製程式碼
在上述測試用例中我們的測試邏輯完全基於行為開展,只關心失焦的「動作」和執行回撥的「反饋」,沒有去斷言任何關於元件狀態的內容。
這樣元件可以根據它的需要自由地實現它的內部邏輯,例如新增通過外部的 Provider
來提供 value
和 onChange
成為受控元件的能力。這些實現的變化都不會影響當前這條測試用例的有效性。
上面就是一些對應該用單元測試測什麼的看法,把單測用在它最擅長的地方,才能在緊湊的開發節奏中取得事半功倍的效果。
下面的擴充套件閱讀,貼了一篇關於「測試覆蓋率是否是一個真正的工程質量指標」的文章,感興趣的同學可以康康