前端單測,為什么不要測 “實現細節”?
前言
哈嘍,大家好,我是海怪。
相信不少同學在寫單測的時候,最大的困擾不是如何寫測試代碼,而是:“應該測什么?”,“要測多深入”,“哪些不該測”。
最近在給 React 組件寫單測的時候,發現了 Kent (React Testing Library 的貢獻者之一)的 《Testing Implementation Details》 這篇文章,里面對 “為什么不要測代碼實現細節?” 這個問題寫得非常好,今天就把這篇文章也分享給大家。
翻譯中會盡量用更地道的語言,這也意味著會給原文加一層 Buf,想看原文的可點擊文末[1]。
開始
我以前用 enzyme 的時候,都會盡量避免使用某些 API,比如 shallow rendering、instance()、state() 以及 find('ComponentName'),而且 Review 別人的 PR 的時候,也會跟他們說盡量別用這些 API。這樣做的原因主要是因為這些 API 會測到很多代碼的實現細節 (Implementation Details)。 然后,很多人又會問:為什么不要測 代碼的實現細節(Implemantation Details) 呢?很簡單:測試本身就很困難了,我們不應該再弄那么多規則來讓測試變得更復雜。
為什么測試“實現細節”是不好的?
為什么測試實現細節是不好的呢?主要有兩個原因:
- 假錯誤(False Negative):重構的時候代碼運行成功,但測試用例崩了。
- 假正確(False Positive):應用代碼真的崩了的時候,然而測試用例又通過了。
注:這里的測試是指:“確定軟件是否工作”。如果測試通過,那么就是 Positive,代碼能用。如果測試失敗,則是 Negative,代碼不可用。而這里的的 False 是指“不正確”,即不正確的測試結果。
如果上面沒看懂,沒關系,下面我們一個一個來講,先來看這個手風琴組件(Accordion):
// Accordion.js
import * as React from 'react'
import AccordionContents from './accordion-contents'
class Accordion extends React.Component {
state = {openIndex: 0}
setOpenIndex = openIndex => this.setState({openIndex})
render() {
const {openIndex} = this.state
return (
<div>
{this.props.items.map((item, index) => (
<>
<button onClick={() => this.setOpenIndex(index)}>
{item.title}
</button>
{index === openIndex ? (
<AccordionContents>{item.contents}</AccordionContents>
) : null}
</>
))}
</div>
)
}
}
export default Accordion
看到這肯定有人會說:為什么還在用過時了的 Class Component 寫法,而不是用 Function Component 寫法呢?別急,繼續往下看,你會發現一些很有意思的事(相信用過 Enzymes 的人應該能猜到會是什么)。
下面是一份測試代碼,對上面 Accordion 組件里 “實現細節” 進行測試:
// __tests__/accordion.enzyme.js
import * as React from 'react'
// 為什么不用 shadow render,請看 https://kcd.im/shallow
import Enzyme, {mount} from 'enzyme'
import EnzymeAdapter from 'enzyme-adapter-react-16'
import Accordion from '../accordion'
// 設置 Enzymes 的 Adpater
Enzyme.configure({adapter: new EnzymeAdapter()})
test('setOpenIndex sets the open index state properly', () => {
const wrapper = mount(<Accordion items={[]} />)
expect(wrapper.state('openIndex')).toBe(0)
wrapper.instance().setOpenIndex(1)
expect(wrapper.state('openIndex')).toBe(1)
})
test('Accordion renders AccordionContents with the item contents', () => {
const hats = {title: 'Favorite Hats', contents: 'Fedoras are classy'}
const footware = {
title: 'Favorite Footware',
contents: 'Flipflops are the best',
}
const wrapper = mount(<Accordion items={[hats, footware]} />)
expect(wrapper.find('AccordionContents').props().children).toBe(hats.contents)
})
相信有不少同學會用 Enzyme 寫過上面類似的代碼。好,現在讓我們來搞點事情...
重構中的 “假錯誤”
我知道大多數人都不喜歡寫測試,特別是寫 UI 測試。原因千千萬,但其中我聽得最多的一個原因就是:大部分人會花特別多的時間來伺候這些測試代碼(指測試實現細節的測試代碼)。
每次我改點東西,測試都會崩!—— 心聲。
一旦測試代碼寫得不好,會嚴重拖垮你的開發效率。下面來看看這類的測試代碼會產生怎樣的問題。
假如說,現在我們要 將這個組件重構成可以展開多個 Item,而且這個改動只能改變代碼的實現,不影響現有的組件行為。得到重構后代碼是這樣的:
class Accordion extends React.Component {
state = {openIndexes: [0]}
setOpenIndex = openIndex => this.setState({openIndexes: [openIndex]})
render() {
const {openIndexes} = this.state
return (
<div>
{this.props.items.map((item, index) => (
<>
<button onClick={() => this.setOpenIndex(index)}>
{item.title}
</button>
{openIndexes.includes(index) ? (
<AccordionContents>{item.contents}</AccordionContents>
) : null}
</>
))}
</div>
)
}
}
上面將 openIndex 改成 openIndexes,讓 Accordion 可以一次展示多個 AccordionContents??雌饋矸浅M昝溃以?UI 真實的使用場景中也沒任何問題,但當我們回去跑一下測試用例,??kaboom??,會發現 setOpenIndex sets the open index state properly 這個測試用例直接報錯:
expect(received).toBe(expected)
Expected value to be (using ===):
0
Received:
undefined
由于我們把 openIndex 改成 openIndexes,所以在測試中 openIndex 的值就變成了 undefined 了。 可是,這個報錯是真的能說明我們的組件有問題么?No!在真實環境下,組件用得好好的。
這種情況就是上面所說的 “假錯誤”。 它的意思是測試用例雖然失敗了,但它是因為測試代碼有問題所以崩了,并不是因為業務代碼/應用代碼導致崩潰了。
好,我們來把它修復一下,把原來的 toEqual(0) 改成 toEqual([0]),把 toEqual(1) 改成 toEqual([1]):
test('setOpenIndex sets the open index state properly', () => {
const wrapper = mount(<Accordion items={[]} />)
expect(wrapper.state('openIndexes')).toEqual([0])
wrapper.instance().setOpenIndex(1)
expect(wrapper.state('openIndexes')).toEqual([1])
})
小結一下:當重構的時候,這些測試“實現細節”的測試用例很可能出現 “假錯誤”,導致出現很多難維護、煩人的測試代碼。
“假正確”
好,現在我們來看另一種情況 “假正確”。假如現在你同事看到這段代碼:?
<button onClick={() => this.setOpenIndex(index)}>{item.title}</button>
他覺得:每次渲染都要生成一個 () => this.setOpenIndex(index) 函數太影響性能了,我們要盡量減少重新生成函數的次數,直接用第一次定義好的函數就好了,然后就改成了這樣:
<button onClick={this.setOpenIndex}>{item.title}</button>
一跑測試,唉,完美通過了~ ??,沒到瀏覽器去跑跑頁面就把代碼提交了,等別人一拉代碼,頁面又不能用了。(如果大家不清楚這里為什么不能用 onClick={this.setOpenIndex} 可以搜一下 Class Component onClick 的 bind 操作)。
那這里的問題是什么呢?我們不是已經有一個測試用例來證明 “只要 setOpenIndex 調用了,狀態就會改變” 了么?對!有。但是,這并不能證明 setOpenIndex 是真的綁定到了的 onClick 上!所以我們還要另外再寫一個測試用例來測 setOpenIndex 真的綁到 onClick 了。
大家發現問題了么?因為我們只測了業務中非常小的一個實現細節,所以為測這個實現細節,我們不得不補另外很多測試用例,來測其它毫不相關的實現細節,那這樣我們永遠都不可能補完所有實現細節的測試代碼。
這就是上面說的 “假正確”。 它是指,在我們跑測試時用例都通過了,但實際上業務代碼/應用代碼里是有問題的,用例是應該要拋出錯誤的!那我們應該怎么才能覆蓋這些情況呢?好吧,那我們只能又寫一個測試來保證 “點擊按鈕后可以正常更新狀態”。然后呢,我們還得添加一個 100% 的覆蓋率指標,這樣才能完美保證不會有問題。還要寫一些 ESLint 的插件來防止其它人來用這些 API。
算了,給這些 “假正確” 和 “假錯誤” 打補丁,還不如不寫測試,把這些測試都干了得了。如果有一個工具可以解決這個問題不是更好嗎?是的,有的!
不再測試實現細節
當然你也可能用 Enzyme 去重寫這些測試用例,然后限制其它人別用上面這些 API,但是我可能會選擇 React Testing Library,因為它的 API 本身限制了開發者,如果有人想用它來做 “實現細節” 的測試,這將會非常困難。
下面我們來看看 RTL 是怎么做測試的吧:
// __tests__/accordion.rtl.js
import '@testing-library/jest-dom/extend-expect'
import * as React from 'react'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Accordion from '../accordion'
test('can open accordion items to see the contents', () => {
const hats = {title: 'Favorite Hats', contents: 'Fedoras are classy'}
const footware = {
title: 'Favorite Footware',
contents: 'Flipflops are the best',
}
render(<Accordion items={[hats, footware]} />)
expect(screen.getByText(hats.contents)).toBeInTheDocument()
expect(screen.queryByText(footware.contents)).not.toBeInTheDocument()
userEvent.click(screen.getByText(footware.title))
expect(screen.getByText(footware.contents)).toBeInTheDocument()
expect(screen.queryByText(hats.contents)).not.toBeInTheDocument()
})
只需一個測試用例就可以驗證所有的組件行為。無論有沒有調用 openIndex、openIndexes 還是 tacosAreTasty,用例都會通過。這樣就可以解決這些 “假錯誤” 了。如果沒有正確綁定 onClick 點擊事件,也會報錯。這樣也可以解決 “假正確” 的問題。好處是,我們不再需要記住那些復雜的實現邏輯,只要關注理想情況下組件的使用行為,就可以測出用戶使用的真實場景了。
到底什么才是實現細節(Implementation Details)
簡單來說就是:
實現細節(Implementaion Details)就是:使用你代碼的人不會用到、看到、知道的東西。
那誰才是我們代碼的用戶呢?第一種就是跟頁面交互的真實用戶。第二種則是使用這些代碼的開發者。對 React Component 來說,用戶則是可以分為 End User 和 Developer,我們只需要關注這兩即可 。
接下來的問題就是:我們代碼中的哪部分是這兩類用戶會看到、用到和知道的呢?對 End User 來說,他們只會和 render 函數里的內容有交互。而 Developer 則會和組件傳入的 Props 有交互。所以,我們的測試用例只和傳入的 Props 以及輸出內容的 render 函數進行交互就夠了。
這也正是 React Testing Library 的測試思路:把 Mock 的 Props 傳給 Accordion 組件,然后通過 RTL 的 API 來驗證 render 函數輸出的內容、測試<button/>的點擊事件。
現在回過頭再來看 Enzyme 這個庫,開發者一般都是用它來訪問 state 和 openIndex 來做測試。這其實對上面提到的兩類用戶來說,都是毫無意義的,因為他們根本不需要知道什么函數被調用了、哪個 index 被改了、index 是存成數組了還是字符串。然而 Enzyme 的測試用例基本都是在測這些別人根本不 care 的內容。
這也是為什么 Enzyme 測試用例為什么這么容易出現 “假錯誤”,因為 當用它來寫一些 End User 和 Developer 都不 care 的測試用例時,我們實際上是在創造第三個用戶視角:Tests 本身!。而 Tests 這個用戶,正好是誰都不會 care 的那個。所以,自動化測試應該只服務于生產環境的用戶而不是這個誰都不會 care 的第三者。
當你的測試和你軟件使用方式越相似,那么它給你的信心就越大 —— Kent。
React Hooks?
不使用 Enzyme 的另一個原因是 Enzyme 在 React Hooks 使用上有很多問題。事實證明,當測試代碼 “實現細節” 時,“實現細節” 的中的任何修改都會對測試有很大的影響。這是個很大的問題,因為如果你從 Class Component 遷移到 Function Component,你的測試用例是很難保證你會不會搞崩里面哪些東西的。React Testing Library 則可以很好地避免這些問題。
Implementation detail free and refactor friendly。
總結
我們應該如何避免測試 “實現細節” 呢?首先是要用正確的工具,比如 React Testing Library :)。
如果你還是不知道應該測試什么,可以跟著下面這個流程走一波:
- 如果崩了,哪些沒有測試過的代碼影響最嚴重?(檢查流程)。
- 盡量將測試用例縮小到一個單元或幾個代碼單元(比如:按下結賬按鈕,會發一個 /checkout 請求)。
- 思考一下誰是這部分代碼的真實用戶?(比如:Developer 拿來渲染結賬表單,End User 會用它操作點擊按鈕)。
- 給使用者寫一份操作清單,并手動測試確認功能正常(用假數據在購物車中渲染表單,點擊結賬按鈕,確保假 /checkout 請求執行,并獲取成功的響應,確保可以展示成功消息)。
- 將這份手動操作清單轉化成自動化測試。
好了,這篇外文就給大家帶到這里了,希望對大家在單測中有所幫助??偟膩碚f,在測試組件方面應該更多關注 Props 以及 render 出來的內容。測試 “實現細節” 有點像我們撒謊,一次撒謊就要撒更多的謊來圓第一個謊,當我們在測試一個細節的時候,我們只能管中窺豹,這無形中會產生一個不存在的用戶:Test,這也是為什么很多人覺得代碼一改,測試也得改的原因。
參考資料
[1]原文: https://kentcdodds.com/blog/testing-implementation-details。