前端單測,我們應該測什么?
相信很多前端開發在寫單測的時候,最大的問題就是:“我應該測什么東西?” 沒錯,解決問題不是最難的,發現問題才是!知道要測哪個遠比怎么測重要很多!
今天看了 Kent 博客的 《How to know what to test》 的這篇博客,感覺醍醐灌頂,今天就把這篇博文也分享給大家。
正片開始
知道如何做測試很好,也很重要的。我之前就教過很多人測試的基礎知識、如何配置工具、如何針對不用情況寫好測試,等等。但是知道如何測試只是成功的一半,知道要測什么才是更重要的另一半。
永遠記住為什么我們要測試
我們寫測試是因為要確保我們的應用程序在用戶使用它們時能夠正常工作。 有些人可能會用測試用例來提高工作流的效率,但我對提高代碼信心更有興趣,即:我們的測試應該能直接增強我們的代碼信心。這也是我希望你在編寫測試時要考慮的重點:
別太糾結于正在測試的代碼,而要多考慮這些代碼能夠支持的真實用例。
如果你只考慮代碼本身,很容易、也很自然地走向測試代碼細節的不歸路。我們應該要考慮那些更接近用戶的真實使用場景來寫測試。
Code Coverage < Use Case Coverage
在做測試時,代碼覆蓋率是表示我們的代碼有多行被執行的一個指標。舉下面這個例子:
function arrayify(maybeArray) {
if (Array.isArray(maybeArray)) {
return maybeArray
} else if (!maybeArray) {
return []
} else {
return [maybeArray]
}
}
現在,我們還沒有給這個函數寫測試,所以這個函數的覆蓋率為 0%。這種情況下的代碼覆蓋率報告可以讓我們知道:得馬上寫測試了,但它沒有告訴我們這個函數有哪些重要的部分,也沒有告訴我們這個函數支持的真實用例(正是我們在寫測試時最要重點關注的內容)是哪些。
實際上,當我們在考慮應該對整個應用中哪些部分做測試時,覆蓋率報告對于 “我們應該在哪部分投入更多時間” 這個問題幫助不是很大。
覆蓋率報告只能幫助我們知道哪些代碼還沒納入測試。所以,當你看著這份覆蓋率報告時,你不要總想著那些 if/else、循環或者生命周期,而是要問問自己:
這幾行代碼實現對應的是哪些使用用例?我應該要加哪些測試用例來覆蓋它們?
“使用用例覆蓋率” 可以告訴我們當前測試支持了哪些使用用例。可惜的是,現在并沒有類似 “使用用例覆蓋率報告” 這么一說。我們只能自己實現。不過,代碼覆蓋率報告有時候也能告訴我們哪些使用用例沒有覆蓋到。
舉上面函數為例子,看到它的第一眼,我們就能馬上想到它的第一個真實用例:“傳入數組則返回數組”。這就可以作為我們測試用例的標題了:
test('傳入數組則返回數組', () => { expect(arrayify(['Elephant', 'Giraffe'])).toEqual(['Elephant', 'Giraffe'])})
有了上面的測試用例,我們的覆蓋情況如下所示(高亮部分為覆蓋部分):
現在,讓我們來看看還沒被覆蓋的那部分,然后發現還有兩種 Use Case 還沒支持:
- 傳入 falsy 值,則返回空數組
- 傳入非 falsy 值且不是數組時,返回一個數組,其中包含的輸入值
現在再來把測試用例都加上,然后再來看覆蓋情況:
test('傳入 falsy 值,則返回空數組', () => {
expect(arrayify()).toEqual([])
})
馬上就可以覆蓋完了!
test(`傳入非 falsy 值也不是數組時,返回一個數組,其中包含的輸入值`, () => { expect(arrayify('Leopard')).toEqual(['Leopard'])})
好了,現在只要我們保證不改變這個函數的這些使用方法,那么我們有信心地說:這些測試都是能通過的。
代碼覆蓋率并不是一個完美的指標,但它卻能幫助我們制作自己的 “使用用例覆蓋率”。
代碼覆蓋率也能隱藏使用用例
有的時候,代碼覆蓋率是 100%,但不意味著使用用例也被覆蓋了 100%。這就是為什么我有時候在寫測試前都會把所有的使用用例想清楚。
舉個例子,假設有一個 arrayify 函數:
test(`傳入非 falsy 值也不是數組時,返回一個數組,其中包含的輸入值`, () => {
expect(arrayify('Leopard')).toEqual(['Leopard'])
})
我們用這兩個用例來實現 100% 的代碼覆蓋:
- 輸入數組,返回數組
- 輸入非數組,返回數組,其中包含輸入內容
如果我們來思考一下真實的使用用例,會發現少了一種 Case:
- 輸入 Falsy 值,返回空數組
如果用戶直接用 arrayify(),那么這樣的測試用例就不能很好地給足我們代碼的信心了。雖然現在看起來還行,就算不給這個 Case 寫測試,我們的代碼也支持這樣的用例,但是,之所以我們要寫測試,是因為我們要確保做了代碼變更之后,它都能支持我們想要的使用用例。
我們繼續來看這樣做測試的后果:假如現在有人看到這一行 filter(Boolean) ,然后覺得:這是哪個 SB 想到的奇葩寫法。最終把這里去掉了。然而,我們的測試依舊是可以通過的,但所有依賴 “輸入 falsy 值” 的這個 Case 的代碼就都掛了。
要對使用用例做測試,而不是代碼
如何應用到 React 代碼的測試?
在寫測試時,你應該時刻想著要支持兩種用戶:真實用戶和開發者。 再啰嗦一句,如果做測試的時候,你還是一直想著業務代碼而不是真實用例,就會很容易陷入測試 “代碼實現細節” 的陷阱。而這么做的后果是,你的代碼會無形中創造第三種用戶:Test User。
很多人在做 React 代碼測試時,經常會想到一些讓他們不斷測 “實現細節” 的測試點。對此,應該別把太多注意點放在要測試的業務代碼上,多想想那些會對真實用戶以及開發者產生影響的東西是什么,這才是你應該要思考的 Use Case,比如:
- 生命周期方法
- 元素事件回調
- 組件內部狀態
相反,一些跟上面兩類用戶有關的一些東西也是要做測試的,比如,它們都會改變 DOM、發 HTTP 請求、執行 Prop 里的回調,或者產生一些可觀察到的副作用,把它們拿來做測試是很有幫助的:
- 用戶交互(使用 @testing-library/user-event 里的 userEvent):用戶是否在和渲染出來的組件進行交互?
- 修改 Prop(使用 React Testing Library 里的 rerender):如果別的開發者用新的 Props 來渲染你的組件呢?
- 修改 Context(使用 React Testing Library 里的 rerender):如果別的開發者修改了 Context 導致你的組件重新渲染呢?
- 修改訂閱:如果組件訂閱的事件中心做了修改呢?(比如:firebase、redux store、router、media query)
該從何測起?
現在我們都清楚應該要對單測組件或者頁面組件測什么了,那你該從何測起呢?這確實是個讓人頭大的問題,尤其是你要對一個巨大無比的應用進行測試的時候。
好,現在這是你要做的事:從真實用戶的角度來看以及問:
如果應用崩了,那么哪部分會讓人最不爽?
或者換個問法:
應用崩了,最糟糕的地方在哪里?
我會建議你按這個標準來列出你應用支持功能的優先級。 你可以和你的團隊以及 Leader 一起來做這件事,這將會是一次很好的嘗試。而且這次嘗試也會有很多好處:幫助所有人搞清楚測試的重要性,并說服他們:測試也是一件優先級很高的事情。
一旦有了這份優先級清單,我會建議你寫一個端對端的測試來覆蓋住用戶使用最多的場景。一般來說,這種方法都能覆蓋住這份清單前幾項功能。你可能需要多的時間來做這個測試,但是一切都是值得的。
雖然這個 E2E 測試不會給你 100% 的 Use Case 覆蓋率(你千萬別嘗試去弄),也不會給你 100% 代碼覆蓋率(你甚至都別想著要記錄 E2E 的覆蓋率),但它會給你一個很好的開始,而且能立即增強你對當前代碼的信心。
一旦有了幾個 E2E 測試用例之后,你就可以給一些沒在 E2E 范圍內的邊界情況做集成測試,然后再給用到的功能里更復雜的業務邏輯做單元測試。從現在開始,剩下的事情就是不斷加測試就好了。只是別老想著要 100% 的覆蓋率了,不值當。
總結
如果有足夠的時間和經驗,你會培養出一種知道要測試什么的直覺。你可能會犯錯誤或者難受,不要放棄!穩住,我們能贏。
好了,這篇外文就給大家帶到這里了。總的來說,也是很贊同 “要多關注 Use Case 的覆蓋情況而不是代碼覆蓋情況”,畢竟如果完全按照代碼覆蓋率這個指標來的話,有太多的作弊手段了,這完全和寫測試的初衷是相違背的。寫測試的目的應該是增強我們對代碼的自信心,而不是功利地看某個指標。
后面 Kent 說到要如何把測試引入團隊的方法也很值得大家去嘗試:先按功能優先級列出個清單,再寫 E2E 覆蓋住最重要的那部分,再加集成測試,再加單元測試,等一切就緒,那么剩下的就是時間堆測試用例,最后測試用例也能慢慢融入到代碼中了。