前言
哈嘍鸟赫,大家好,我是海怪今缚。
不知道大家平時(shí)寫單測(cè)時(shí)是怎么處理 網(wǎng)絡(luò)請(qǐng)求 的算柳,可能有的人會(huì)說(shuō):“把請(qǐng)求函數(shù) Mock ,返回 Mock 結(jié)果就行了呀”姓言。
但在真實(shí)的測(cè)試場(chǎng)景中往往需要多次改變 Mock 結(jié)果瞬项,Mock fetch
或者 axios.get
就不太夠用了。
帶著上面這個(gè)問(wèn)題我找到了 Kent 的這篇 《Stop mocking fetch》何荚。今天就把這篇文章分享給大家囱淋。
正片開始
我們先來(lái)看下面這段測(cè)試代碼有什么問(wèn)題:
// __tests__/checkout.js
import * as React from 'react'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {client} from '~/utils/api-client'
jest.mock('~/utils/api-client')
test('clicking "confirm" submits payment', async () => {
const shoppingCart = buildShoppingCart()
render(<Checkout shoppingCart={shoppingCart} />)
client.mockResolvedValueOnce(() => ({success: true}))
userEvent.click(screen.getByRole('button', {name: /confirm/i}))
expect(client).toHaveBeenCalledWith('checkout', {data: shoppingCart})
expect(client).toHaveBeenCalledTimes(1)
expect(await screen.findByText(/success/i)).toBeInTheDocument()
})
如果不告訴你 <Checkout />
的功能和 /checkout
API 的用法,你可能發(fā)現(xiàn)不了這里的問(wèn)題餐塘。
好吧妥衣,我來(lái)公布一下答案:首先第一個(gè)問(wèn)題就是把 client
給 Mock 掉了,問(wèn)問(wèn)自己:你怎么知道 client
是一定會(huì)被正確調(diào)用的呢戒傻?當(dāng)然税手,你可能會(huì)說(shuō):client
可以用別的單測(cè)來(lái)做保障呀。但你又怎么能保證 client
不會(huì)把返回值里的 body
改成 data
呢需纳?哦芦倒,你是想說(shuō)你用了 TypeScript 是吧?彳夭霍妗兵扬!但由于我們把 client
Mock 了麻裳,所以肯定不會(huì)完全保證 client
的功能正確性。你可能還會(huì)說(shuō):我還有 E2E 測(cè)試器钟!
但是津坑,如果我們?cè)谶@里能真的調(diào)用一下 client
不是更能提高我們對(duì) client
的信心么?好過(guò)一直猜來(lái)猜去嘛傲霸。
不過(guò)国瓮,我們肯定也不是想真的調(diào)用 fetch
函數(shù),所以我們會(huì)選擇把 window.fetch
給 Mock 了:
// __tests__/checkout.js
import * as React from 'react'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
beforeAll(() => jest.spyOn(window, 'fetch'))
// Jest 的 rsetMocks 設(shè)置為 true
// 我們就不用擔(dān)心要 cleanup 了
// 這里假設(shè)你用了類似 `whatwg-fetch` 的庫(kù)來(lái)做 fetch 的 Polyfill
test('clicking "confirm" submits payment', async () => {
const shoppingCart = buildShoppingCart()
render(<Checkout shoppingCart={shoppingCart} />)
window.fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({success: true}),
})
userEvent.click(screen.getByRole('button', {name: /confirm/i}))
expect(window.fetch).toHaveBeenCalledWith(
'/checkout',
expect.objectContaining({
method: 'POST',
body: JSON.stringify(shoppingCart),
}),
)
expect(window.fetch).toHaveBeenCalledTimes(1)
expect(await screen.findByText(/success/i)).toBeInTheDocument()
})
上面肯定能給你帶來(lái)不少代碼信心狞谱,畢竟你真的能測(cè)請(qǐng)求是否真的發(fā)出去了。但是禁漓,這里的缺點(diǎn)在于:它不能測(cè) headers
里是否會(huì)帶有 Content-Type: application/json
跟衅。 沒有這一步,我們也不能確定服務(wù)器是否真的能處理發(fā)出去的請(qǐng)求播歼。 還有一個(gè)問(wèn)題伶跷,你怎么能確定用戶鑒權(quán)的信息是不是真的也被帶上呢?
看到這秘狞,肯定有人會(huì)說(shuō):“在 client
的單測(cè)里已經(jīng)驗(yàn)證了呀叭莫,你還想我要做啥?我不想再把那里面的測(cè)試代碼也在這復(fù)制一份”烁试。行行行雇初,我知道。但如果有一種即可以不用復(fù)制 client
的測(cè)試代碼减响,又能提高代碼自信的方法呢靖诗?繼續(xù)往下看。
我一直不太喜歡 Mock 類似 fetch
函數(shù)的東西支示,因?yàn)樽罱K你會(huì)在所有地方把整個(gè)后端的邏輯都重新實(shí)現(xiàn)一遍刊橘。 這通常發(fā)生在多個(gè)測(cè)試之間,非常煩人颂鸿。特別是在一些測(cè)試中促绵,我們要假定后端要返回的內(nèi)容的時(shí)候,就不得不在所有地方都要 Mock 一次嘴纺。在這種情況下败晴,就會(huì)給你和要做測(cè)試的東西設(shè)置了很多障礙。
我們的測(cè)試策略就會(huì)變成這樣:
- 我們把
client
Mock 了(第一個(gè)例子)颖医,然后依賴一些 E2E 測(cè)試來(lái)保障client
正確執(zhí)行位衩,以此給予我們心靈上一丟丟信心。但這也導(dǎo)致了一旦遇到后端的東西熔萧,我就要在所有地方都要重新實(shí)現(xiàn)一遍后端邏輯 - 我們把
window.fetch
Mock 了(第二個(gè)例子)糖驴。這會(huì)好點(diǎn)僚祷,但這也會(huì)遇到第 1 點(diǎn)類似的問(wèn)題 - 把所有東西都放在函數(shù)中,然后拿來(lái)做單測(cè)(這樣還行)贮缕,這樣就避免在集成測(cè)試中再測(cè)一遍(不太好辙谜,譯注:不太好是因?yàn)榧蓽y(cè)試應(yīng)該要對(duì)整個(gè)功能進(jìn)行測(cè)試,這樣分開測(cè)就不完整了)
最終感昼,這樣的測(cè)試并沒有給我們太多的心理安慰装哆,反而帶來(lái)很多重復(fù)的工作。
很長(zhǎng)一段時(shí)間里我的解決方法是:聲明一個(gè)假的 fetch
函數(shù)定嗓,把后端要 Mock 的內(nèi)容都放里面蜕琴。我在 Paypal 的時(shí)候就試過(guò),發(fā)現(xiàn)還挺好用的宵溅。這里舉個(gè)例子:
// 把它放在 Jest 的 setup 文件中凌简,就會(huì)在所有測(cè)試文件前被引入了
import * as users from './users'
async function mockFetch(url, config) {
switch (url) {
case '/login': {
const user = await users.login(JSON.parse(config.body))
return {
ok: true,
status: 200,
json: async () => ({user}),
}
}
case '/checkout': {
const isAuthorized = user.authorize(config.headers.Authorization)
if (!isAuthorized) {
return Promise.reject({
ok: false,
status: 401,
json: async () => ({message: 'Not authorized'}),
})
}
const shoppingCart = JSON.parse(config.body)
// 可以在這里添加購(gòu)物車的邏輯
return {
ok: true,
status: 200,
json: async () => ({success: true}),
}
}
default: {
throw new Error(`Unhandled request: ${url}`)
}
}
}
beforeAll(() => jest.spyOn(window, 'fetch'))
beforeEach(() => window.fetch.mockImplementation(mockFetch))
然后,我們的測(cè)試就可以寫成這樣了:
// __tests__/checkout.js
import * as React from 'react'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
test('clicking "confirm" submits payment', async () => {
const shoppingCart = buildShoppingCart()
render(<Checkout shoppingCart={shoppingCart} />)
userEvent.click(screen.getByRole('button', {name: /confirm/i}))
expect(await screen.findByText(/success/i)).toBeInTheDocument()
})
這個(gè)測(cè)試方法不需要你做多余的事恃逻,就是寫 case
雏搂。這里還可以給它再多加一個(gè)失敗的 Case,不過(guò)我已經(jīng)很滿意了寇损。
這樣做的好處是對(duì)大量測(cè)試用例都不用寫特別多的代碼就能提高我對(duì)業(yè)務(wù)邏輯的信心了凸郑。
msw
msw
全稱 “Mock Service Worker”。 現(xiàn)在 Service Worker 還只是瀏覽器中的功能矛市,不能在 Node 端使用芙沥。但是,msw
可以支持 Node 端所有測(cè)試場(chǎng)景尘盼。
它的工作原理是這樣的:創(chuàng)建一個(gè) Mock Server 來(lái)攔截所有的請(qǐng)求憨愉,然后你就可以像是在真的 Server 里去處理請(qǐng)求。
我的做法是:用 json
來(lái)初始化數(shù)據(jù)庫(kù)卿捎,或者用 faker
(現(xiàn)在別用了) 和 test-data-bot
來(lái)構(gòu)造數(shù)據(jù)配紫。然后用 Server Handler
(類似 Express 的寫法)和 Mock DB 交互并返回 Mock 數(shù)據(jù)。這就可以更容易和快速地寫測(cè)試了(配置好 Handler 后)午阵。
你可能在之前會(huì)用 nock
之類的庫(kù)來(lái)做這些事躺孝。但 msw
還有一個(gè)優(yōu)勢(shì):你可以將這些 “Server Handler” 用在前端本地開發(fā)上,適用于以下場(chǎng)景:
- API 還沒實(shí)現(xiàn)完
- API 崩了的時(shí)候
- 網(wǎng)速太慢或者沒聯(lián)網(wǎng)
你可能聽說(shuō)過(guò)做類似事情的 Mirage底桂。但它不是用 Service Worker
在客戶端實(shí)現(xiàn)的植袍,所以你不能在開發(fā)者的 Network Tab 里看到 HTTP 請(qǐng)求,但是 msw
則可以籽懦。 兩者對(duì)比可以看這里于个。
示例
有了上面的介紹,現(xiàn)在來(lái)看看 msw
是如何 Mock Server 的:
// server-handlers.js
// 放在這里暮顺,不僅可以給測(cè)試用也能給前端本地使用
import {rest} from 'msw' // msw 支持 GraphQL
import * as users from './users'
const handlers = [
rest.get('/login', async (req, res, ctx) => {
const user = await users.login(JSON.parse(req.body))
return res(ctx.json({user}))
}),
rest.post('/checkout', async (req, res, ctx) => {
const user = await users.login(JSON.parse(req.body))
const isAuthorized = user.authorize(req.headers.Authorization)
if (!isAuthorized) {
return res(ctx.status(401), ctx.json({message: 'Not authorized'}))
}
const shoppingCart = JSON.parse(req.body)
// do whatever other things you need to do with this shopping cart
return res(ctx.json({success: true}))
}),
]
export {handlers}
// test/server.js
import {rest} from 'msw'
import {setupServer} from 'msw/node'
import {handlers} from './server-handlers'
const server = setupServer(...handlers)
export {server, rest}
// test/setup-env.js
// 加到 Jest 的 setup 文件上厅篓,可以在所有測(cè)試前執(zhí)行
import {server} from './server.js'
beforeAll(() => server.listen())
// 如果你要在特定的用例上使用特定的 Handler秀存,這會(huì)在最后把它們重置掉
// (對(duì)單測(cè)的隔離性很重要)
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
現(xiàn)在我們的測(cè)試就可以改成:
// __tests__/checkout.js
import * as React from 'react'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
test('clicking "confirm" submits payment', async () => {
const shoppingCart = buildShoppingCart()
render(<Checkout shoppingCart={shoppingCart} />)
userEvent.click(screen.getByRole('button', {name: /confirm/i}))
expect(await screen.findByText(/success/i)).toBeInTheDocument()
})
比起 Mock fetch
,我更喜歡這種方案的理由是:
- 不用管
fetch
函數(shù)里的實(shí)現(xiàn)細(xì)節(jié) - 當(dāng)調(diào)用
fetch
時(shí)有報(bào)錯(cuò)羽氮,那么真實(shí)的Server Handler
不會(huì)被調(diào)用或链,而且我的測(cè)試也會(huì)失敗,可以避免提交有問(wèn)題的代碼 - 可以在前端本地開發(fā)時(shí)復(fù)用這些
Server Handler
档押!
Colocation 和 error/edge case testing
唯一值得擔(dān)心的是:你可能會(huì)把所有 Server Handler 放在同一個(gè)地方澳盐,而依賴它們的測(cè)試文件又會(huì)被放在不同地方,這可能會(huì)導(dǎo)致文件放置不集中令宿。
首先叼耙,我想說(shuō)的是,只有那些對(duì)你測(cè)試很重要粒没,很獨(dú)特的東西才應(yīng)該盡可能靠近測(cè)試文件旬蟋。
你不需要在所有測(cè)試文件中都要重復(fù) setup 一次,只需要 setup 獨(dú)特的東西就可以了革娄。 所以,最簡(jiǎn)單的方式就是:把常用的部分放在 Jest 的 setup 文件里冕碟。 不然你會(huì)有很多的干擾項(xiàng)拦惋,也很難對(duì)真正要測(cè)的東西進(jìn)行隔離安寺。
對(duì)于自定義的場(chǎng)景厕妖,msw
可以在運(yùn)行時(shí)允許你在測(cè)試用例中添加自定義的 Server Handler
,也可以一鍵重置成你原來(lái)的 Handler挑庶,以此保留隔離性言秸。 比如:
// __tests__/checkout.js
import * as React from 'react'
import {server, rest} from 'test/server'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
// 啥也不需要改
test('clicking "confirm" submits payment', async () => {
const shoppingCart = buildShoppingCart()
render(<Checkout shoppingCart={shoppingCart} />)
userEvent.click(screen.getByRole('button', {name: /confirm/i}))
expect(await screen.findByText(/success/i)).toBeInTheDocument()
})
// 邊界情況、錯(cuò)誤情況迎捺,需要添加自定義的 Handler
// 注意 afterEach(() => server.resetHandlers())
// 可以確保在最近移除新增特殊的 Handler
test('shows server error if the request fails', async () => {
const testErrorMessage = 'THIS IS A TEST FAILURE'
server.use(
rest.post('/checkout', async (req, res, ctx) => {
return res(ctx.status(500), ctx.json({message: testErrorMessage}))
}),
)
const shoppingCart = buildShoppingCart()
render(<Checkout shoppingCart={shoppingCart} />)
userEvent.click(screen.getByRole('button', {name: /confirm/i}))
expect(await screen.findByRole('alert')).toHaveTextContent(testErrorMessage)
})
這么一來(lái)举畸,你不僅可以把相關(guān)邏輯的代碼放在一起,還能實(shí)現(xiàn)場(chǎng)景自定義凳枝。
總結(jié)
當(dāng)然 msw
還有很多其它玩法抄沮,讀者可以自行探索。下面先讓我們來(lái)小結(jié)一下岖瑰。
這種測(cè)試策略一大優(yōu)勢(shì)就是:當(dāng)你完全忽略發(fā)代碼的實(shí)現(xiàn)細(xì)節(jié)叛买,你就可以盡情地重構(gòu)代碼,同時(shí)你的測(cè)試會(huì)源源不斷地給你信心蹋订,讓你不用擔(dān)心會(huì)破壞用戶體驗(yàn)率挣。這才是測(cè)試應(yīng)該做的事。
好了露戒,這篇外文就給大家?guī)У竭@里了椒功〈废洌總的來(lái)說(shuō),我還是挺喜歡攔截 Http 請(qǐng)求這種 Mock 方法的蛾茉。msw
不僅可以在測(cè)試中攔截請(qǐng)求讼呢,實(shí)現(xiàn)集成、E2E 測(cè)試谦炬,還可以在前端開發(fā)時(shí)來(lái) Mock 數(shù)據(jù)悦屏,確實(shí)是一個(gè)有趣的實(shí)踐。
最近也給我們項(xiàng)目寫不少單測(cè)键思,其實(shí)單測(cè)和集成測(cè)試還是有很多互補(bǔ)的地方的础爬。當(dāng)你發(fā)現(xiàn)要測(cè)試的東西太復(fù)雜,或者太多干擾項(xiàng)時(shí)吼鳞,使用集成測(cè)試會(huì)讓你真正從用戶的角度來(lái)寫測(cè)試看蚜。這樣一來(lái),你就不會(huì)過(guò)度關(guān)注那些覆蓋率指標(biāo)了赔桌,而是從一個(gè)用戶的角度來(lái)思考這樣的用例能給我?guī)?lái)多少信心供炎。
如果你喜歡我的分享,可以來(lái)一波一鍵三連疾党,點(diǎn)贊音诫、在看就是我最大的動(dòng)力,比心 ??