前言
哈嘍端圈,大家好焦读,我是海怪。
相信不少同學(xué)在寫單測(cè)的時(shí)候舱权,最大的困擾不是如何寫測(cè)試代碼矗晃,而是:“應(yīng)該測(cè)什么?”宴倍,“要測(cè)多深入”张症,“哪些不該測(cè)”。
最近在給 React 組件寫單測(cè)的時(shí)候鸵贬,發(fā)現(xiàn)了 Kent (React Testing Library 的貢獻(xiàn)者之一)的 《Testing Implementation Details》 這篇文章俗他,里面對(duì) “為什么不要測(cè)代碼實(shí)現(xiàn)細(xì)節(jié)?” 這個(gè)問題寫得非常好阔逼,今天就把這篇文章也分享給大家兆衅。
翻譯中會(huì)盡量用更地道的語言,這也意味著會(huì)給原文加一層 Buf,想看原文的可點(diǎn)擊 這里羡亩。
開始
我以前用 enzyme 的時(shí)候摩疑,都會(huì)盡量避免使用某些 API,比如 shallow rendering
夕春、instance()
未荒、state()
以及 find('ComponentName')
专挪,而且 Review 別人的 PR 的時(shí)候及志,也會(huì)跟他們說盡量別用這些 API。這樣做的原因主要是因?yàn)檫@些 API 會(huì)測(cè)到很多代碼的實(shí)現(xiàn)細(xì)節(jié) (Implementation Details)寨腔。 然后速侈,很多人又會(huì)問:為什么不要測(cè) 代碼的實(shí)現(xiàn)細(xì)節(jié)(Implemantation Details) 呢?很簡(jiǎn)單:測(cè)試本身就很困難了迫卢,我們不應(yīng)該再弄那么多規(guī)則來讓測(cè)試變得更復(fù)雜倚搬。
為什么測(cè)試“實(shí)現(xiàn)細(xì)節(jié)”是不好的?
為什么測(cè)試實(shí)現(xiàn)細(xì)節(jié)是不好的呢乾蛤?主要有兩個(gè)原因:
- 假錯(cuò)誤(False Negative):重構(gòu)的時(shí)候代碼運(yùn)行成功每界,但測(cè)試用例崩了
- 假正確(False Positive):應(yīng)用代碼真的崩了的時(shí)候,然而測(cè)試用例又通過了
注:這里的測(cè)試是指:“確定軟件是否工作”家卖。如果測(cè)試通過眨层,那么就是 Positive,代碼能用上荡。如果測(cè)試失敗趴樱,則是 Negative,代碼不可用酪捡。而這里的的 False 是指“不正確”叁征,即不正確的測(cè)試結(jié)果。
如果上面沒看懂逛薇,沒關(guān)系捺疼,下面我們一個(gè)一個(gè)來講,先來看這個(gè)手風(fēng)琴組件(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
看到這肯定有人會(huì)說:為什么還在用過時(shí)了的 Class Component 寫法永罚,而不是用 Function Component 寫法呢帅涂?別急,繼續(xù)往下看尤蛮,你會(huì)發(fā)現(xiàn)一些很有意思的事(相信用過 Enzymes 的人應(yīng)該能猜到會(huì)是什么)媳友。
下面是一份測(cè)試代碼,對(duì)上面 Accordion
組件里 “實(shí)現(xiàn)細(xì)節(jié)” 進(jìn)行測(cè)試:
// __tests__/accordion.enzyme.js
import * as React from 'react'
// 為什么不用 shadow render产捞,請(qǐng)看 https://kcd.im/shallow
import Enzyme, {mount} from 'enzyme'
import EnzymeAdapter from 'enzyme-adapter-react-16'
import Accordion from '../accordion'
// 設(shè)置 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)
})
相信有不少同學(xué)會(huì)用 Enzyme 寫過上面類似的代碼醇锚。好,現(xiàn)在讓我們來搞點(diǎn)事情...
重構(gòu)中的 “假錯(cuò)誤”
我知道大多數(shù)人都不喜歡寫測(cè)試,特別是寫 UI 測(cè)試焊唬。原因千千萬恋昼,但其中我聽得最多的一個(gè)原因就是:大部分人會(huì)花特別多的時(shí)間來伺候這些測(cè)試代碼(指測(cè)試實(shí)現(xiàn)細(xì)節(jié)的測(cè)試代碼)。
每次我改點(diǎn)東西赶促,測(cè)試都會(huì)崩液肌!—— 心聲
一旦測(cè)試代碼寫得不好,會(huì)嚴(yán)重拖垮你的開發(fā)效率鸥滨。下面來看看這類的測(cè)試代碼會(huì)產(chǎn)生怎樣的問題嗦哆。
假如說,現(xiàn)在我們要 將這個(gè)組件重構(gòu)成可以展開多個(gè) Item婿滓,而且這個(gè)改動(dòng)只能改變代碼的實(shí)現(xiàn)老速,不影響現(xiàn)有的組件行為。得到重構(gòu)后代碼是這樣的:
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
可以一次展示多個(gè) AccordionContents
橘券。看起來非常完美卿吐,而且在 UI 真實(shí)的使用場(chǎng)景中也沒任何問題旁舰,但當(dāng)我們回去跑一下測(cè)試用例,??kaboom??嗡官,會(huì)發(fā)現(xiàn) setOpenIndex sets the open index state properly
這個(gè)測(cè)試用例直接報(bào)錯(cuò):
expect(received).toBe(expected)
Expected value to be (using ===):
0
Received:
undefined
由于我們把 openIndex
改成 openIndexes
箭窜,所以在測(cè)試中 openIndex
的值就變成了 undefined
了。 可是谨湘,這個(gè)報(bào)錯(cuò)是真的能說明我們的組件有問題么绽快?No!在真實(shí)環(huán)境下紧阔,組件用得好好的坊罢。
這種情況就是上面所說的 “假錯(cuò)誤”。 它的意思是測(cè)試用例雖然失敗了擅耽,但它是因?yàn)闇y(cè)試代碼有問題所以崩了活孩,并不是因?yàn)闃I(yè)務(wù)代碼/應(yīng)用代碼導(dǎo)致崩潰了。
好乖仇,我們來把它修復(fù)一下憾儒,把原來的 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])
})
小結(jié)一下:當(dāng)重構(gòu)的時(shí)候乃沙,這些測(cè)試“實(shí)現(xiàn)細(xì)節(jié)”的測(cè)試用例很可能出現(xiàn) “假錯(cuò)誤”起趾,導(dǎo)致出現(xiàn)很多難維護(hù)、煩人的測(cè)試代碼警儒。
“假正確”
好训裆,現(xiàn)在我們來看另一種情況 “假正確”眶根。假如現(xiàn)在你同事看到這段代碼
<button onClick={() => this.setOpenIndex(index)}>{item.title}</button>
他覺得:每次渲染都要生成一個(gè) () => this.setOpenIndex(index)
函數(shù)太影響性能了,我們要盡量減少重新生成函數(shù)的次數(shù)边琉,直接用第一次定義好的函數(shù)就好了属百,然后就改成了這樣:
<button onClick={this.setOpenIndex}>{item.title}</button>
一跑測(cè)試,唉变姨,完美通過了~ ??族扰,沒到瀏覽器去跑跑頁面就把代碼提交了,等別人一拉代碼定欧,頁面又不能用了渔呵。(如果大家不清楚這里為什么不能用 onClick={this.setOpenIndex}
可以搜一下 Class Component onClick
的 bind
操作)。
那這里的問題是什么呢忧额?我們不是已經(jīng)有一個(gè)測(cè)試用例來證明 “只要 setOpenIndex
調(diào)用了厘肮,狀態(tài)就會(huì)改變” 了么愧口?對(duì)睦番!有。但是耍属,這并不能證明 setOpenIndex
是真的綁定到了 <button/>
的 onClick
上托嚣!所以我們還要另外再寫一個(gè)測(cè)試用例來測(cè) setOpenIndex
真的綁到 onClick
了。厚骗。
大家發(fā)現(xiàn)問題了么示启?因?yàn)槲覀冎粶y(cè)了業(yè)務(wù)中非常小的一個(gè)實(shí)現(xiàn)細(xì)節(jié),所以為測(cè)這個(gè)實(shí)現(xiàn)細(xì)節(jié)领舰,我們不得不補(bǔ)另外很多測(cè)試用例夫嗓,來測(cè)其它毫不相關(guān)的實(shí)現(xiàn)細(xì)節(jié),那這樣我們永遠(yuǎn)都不可能補(bǔ)完所有實(shí)現(xiàn)細(xì)節(jié)的測(cè)試代碼冲秽。
這就是上面說的 “假正確”舍咖。 它是指,在我們跑測(cè)試時(shí)用例都通過了锉桑,但實(shí)際上業(yè)務(wù)代碼/應(yīng)用代碼里是有問題的排霉,用例是應(yīng)該要拋出錯(cuò)誤的!那我們應(yīng)該怎么才能覆蓋這些情況呢民轴?好吧攻柠,那我們只能又寫一個(gè)測(cè)試來保證 “點(diǎn)擊按鈕后可以正常更新狀態(tài)”。然后呢后裸,我們還得添加一個(gè) 100% 的覆蓋率指標(biāo)瑰钮,這樣才能完美保證不會(huì)有問題。還要寫一些 ESLint 的插件來防止其它人來用這些 API微驶。
算了浪谴,給這些 “假正確” 和 “假錯(cuò)誤” 打補(bǔ)丁,還不如不寫測(cè)試,把這些測(cè)試都干了得了较店。如果有一個(gè)工具可以解決這個(gè)問題不是更好嗎士八?是的,有的梁呈!
不再測(cè)試實(shí)現(xiàn)細(xì)節(jié)
當(dāng)然你也可能用 Enzyme 去重寫這些測(cè)試用例婚度,然后限制其它人別用上面這些 API,但是我可能會(huì)選擇 React Testing Library官卡,因?yàn)樗?API 本身限制了開發(fā)者蝗茁,如果有人想用它來做 “實(shí)現(xiàn)細(xì)節(jié)” 的測(cè)試,這將會(huì)非常困難寻咒。
下面我們來看看 RTL 是怎么做測(cè)試的吧:
// __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()
})
只需一個(gè)測(cè)試用例就可以驗(yàn)證所有的組件行為哮翘。無論有沒有調(diào)用 openIndex
、openIndexes
還是 tacosAreTasty
毛秘,用例都會(huì)通過饭寺。這樣就可以解決這些 “假錯(cuò)誤” 了。如果沒有正確綁定 onClick
點(diǎn)擊事件叫挟,也會(huì)報(bào)錯(cuò)艰匙。這樣也可以解決 “假正確” 的問題。好處是抹恳,我們不再需要記住那些復(fù)雜的實(shí)現(xiàn)邏輯员凝,只要關(guān)注理想情況下組件的使用行為,就可以測(cè)出用戶使用的真實(shí)場(chǎng)景了奋献。
到底什么才是實(shí)現(xiàn)細(xì)節(jié)(Implementation Details)
簡(jiǎn)單來說就是:
實(shí)現(xiàn)細(xì)節(jié)(Implementaion Details)就是:使用你代碼的人不會(huì)用到健霹、看到、知道的東西瓶蚂。
那誰才是我們代碼的用戶呢糖埋?第一種就是跟頁面交互的真實(shí)用戶。第二種則是使用這些代碼的開發(fā)者扬跋。對(duì) React Component 來說阶捆,用戶則是可以分為 End User 和 Developer,我們只需要關(guān)注這兩即可 钦听。
接下來的問題就是:我們代碼中的哪部分是這兩類用戶會(huì)看到洒试、用到和知道的呢?對(duì) End User 來說朴上,他們只會(huì)和 render
函數(shù)里的內(nèi)容有交互垒棋。而 Developer 則會(huì)和組件傳入的 Props
有交互。所以痪宰,我們的測(cè)試用例只和傳入的 Props
以及輸出內(nèi)容的 render
函數(shù)進(jìn)行交互就夠了叼架。
這也正是 React Testing Library 的測(cè)試思路:把 Mock 的 Props
傳給 Accordion
組件畔裕,然后通過 RTL 的 API 來驗(yàn)證 render
函數(shù)輸出的內(nèi)容、測(cè)試 <button/>
的點(diǎn)擊事件乖订。
現(xiàn)在回過頭再來看 Enzyme 這個(gè)庫(kù)扮饶,開發(fā)者一般都是用它來訪問 state
和 openIndex
來做測(cè)試。這其實(shí)對(duì)上面提到的兩類用戶來說乍构,都是毫無意義的甜无,因?yàn)樗麄兏静恍枰朗裁春瘮?shù)被調(diào)用了、哪個(gè) index
被改了哥遮、index
是存成數(shù)組了還是字符串岂丘。然而 Enzyme 的測(cè)試用例基本都是在測(cè)這些別人根本不 care 的內(nèi)容。
這也是為什么 Enzyme 測(cè)試用例為什么這么容易出現(xiàn) “假錯(cuò)誤”眠饮,因?yàn)?當(dāng)用它來寫一些 End User 和 Developer 都不 care 的測(cè)試用例時(shí)奥帘,我們實(shí)際上是在創(chuàng)造第三個(gè)用戶視角:Tests 本身!仪召。而 Tests 這個(gè)用戶寨蹋,正好是誰都不會(huì) care 的那個(gè)。所以返咱,自動(dòng)化測(cè)試應(yīng)該只服務(wù)于生產(chǎn)環(huán)境的用戶而不是這個(gè)誰都不會(huì) care 的第三者钥庇。
當(dāng)你的測(cè)試和你軟件使用方式越相似牍鞠,那么它能給你的信心就越大 —— Kent
React Hooks咖摹?
不使用 Enzyme 的另一個(gè)原因是 Enzyme 在 React Hooks 使用上有很多問題。事實(shí)證明难述,當(dāng)測(cè)試代碼 “實(shí)現(xiàn)細(xì)節(jié)” 時(shí)萤晴,“實(shí)現(xiàn)細(xì)節(jié)” 的中的任何修改都會(huì)對(duì)測(cè)試有很大的影響。這是個(gè)很大的問題胁后,因?yàn)槿绻銖?Class Component 遷移到 Function Component店读,你的測(cè)試用例是很難保證你會(huì)不會(huì)搞崩里面哪些東西的。 React Testing Library 則可以很好地避免這些問題攀芯。
Implementation detail free and refactor friendly.
總結(jié)
我們應(yīng)該如何避免測(cè)試 “實(shí)現(xiàn)細(xì)節(jié)” 呢屯断?首是是要用正確的工具,比如 React Testing Library :)
如果你還是不知道應(yīng)該測(cè)試什么侣诺,可以跟著下面這個(gè)流程走一波:
- 如果崩了殖演,哪些沒有測(cè)試過的代碼影響最嚴(yán)重?(檢查流程)
- 盡量將測(cè)試用例縮小到一個(gè)單元或幾個(gè)代碼單元(比如:按下結(jié)賬按鈕年鸳,會(huì)發(fā)一個(gè) /checkout 請(qǐng)求)
- 思考一下誰是這部分代碼的真實(shí)用戶趴久?(比如:Developer 拿來渲染結(jié)賬表單,End User 會(huì)用它操作點(diǎn)擊按鈕)
- 給使用者寫一份操作清單搔确,并手動(dòng)測(cè)試確認(rèn)功能正常(用假數(shù)據(jù)在購(gòu)物車中渲染表單彼棍,點(diǎn)擊結(jié)賬按鈕灭忠,確保假 /checkout 請(qǐng)求執(zhí)行,并獲取成功的響應(yīng)座硕,確背谧鳎可以展示成功消息)
- 將這份手動(dòng)操作清單轉(zhuǎn)化成自動(dòng)化測(cè)試
好了,這篇外文就給大家?guī)У竭@里了华匾,希望對(duì)大家在單測(cè)中有所幫助缆蝉。總的來說瘦真,在測(cè)試組件方面應(yīng)該更多關(guān)注 Props
以及 render
出來的內(nèi)容刊头。測(cè)試 “實(shí)現(xiàn)細(xì)節(jié)” 有點(diǎn)像我們?nèi)鲋e,一次撒謊就要撒更多的謊來圓第一個(gè)謊诸尽,當(dāng)我們?cè)跍y(cè)試一個(gè)細(xì)節(jié)的時(shí)候原杂,我們只能管中窺豹,這無形中會(huì)產(chǎn)生一個(gè)不存在的用戶:Test您机,這也是為什么很多人覺得代碼一改穿肄,測(cè)試也得改的原因。
如果你喜歡我的分享际看,可以來一波一鍵三連咸产,點(diǎn)贊、在看就是我最大的動(dòng)力仲闽,比心 ??