測(cè)試中如何處理 Http 請(qǐng)求?
前言
不知道大家平時(shí)寫(xiě)單測(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》。今天就把這篇文章分享給大家。
正片開(kāi)始
我們先來(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è)你用了類(lèi)似 `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。 沒(méi)有這一步,我們也不能確定服務(wù)器是否真的能處理發(fā)出去的請(qǐng)求。還有一個(gè)問(wèn)題,你怎么能確定用戶(hù)鑒權(quán)的信息是不是真的也被帶上呢?
看到這,肯定有人會(huì)說(shuō):“在 client 的單測(cè)里已經(jīng)驗(yàn)證了呀,你還想我要做啥?我不想再把那里面的測(cè)試代碼也在這復(fù)制一份”。行行行,我知道。但如果有一種即可以不用復(fù)制 client 的測(cè)試代碼,又能提高代碼自信的方法呢?繼續(xù)往下看。
我一直不太喜歡 Mock 類(lèi)似 fetch 函數(shù)的東西,因?yàn)樽罱K你會(huì)在所有地方把整個(gè)后端的邏輯都重新實(shí)現(xiàn)一遍。 這通常發(fā)生在多個(gè)測(cè)試之間,非常煩人。特別是在一些測(cè)試中,我們要假定后端要返回的內(nèi)容的時(shí)候,就不得不在所有地方都要 Mock 一次。在這種情況下,就會(huì)給你和要做測(cè)試的東西設(shè)置了很多障礙。
我們的測(cè)試策略就會(huì)變成這樣:
我們把 clientMock 了(第一個(gè)例子),然后依賴(lài)一些 E2E 測(cè)試來(lái)保障client正確執(zhí)行,以此給予我們心靈上一丟丟信心。但這也導(dǎo)致了一旦遇到后端的東西,我就要在所有地方都要重新實(shí)現(xiàn)一遍后端邏輯我們把 window.fetchMock 了(第二個(gè)例子)。這會(huì)好點(diǎn),但這也會(huì)遇到第 1 點(diǎn)類(lèi)似的問(wèn)題把所有東西都放在函數(shù)中,然后拿來(lái)做單測(cè)(這樣還行),這樣就避免在集成測(cè)試中再測(cè)一遍(不太好,譯注:不太好是因?yàn)榧蓽y(cè)試應(yīng)該要對(duì)整個(gè)功能進(jìn)行測(cè)試,這樣分開(kāi)測(cè)就不完整了)
最終,這樣的測(cè)試并沒(méi)有給我們太多的心理安慰,反而帶來(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)物車(chē)的邏輯
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è)試就可以寫(xiě)成這樣了:
// __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è)試方法不需要你做多余的事,就是寫(xiě) case。這里還可以給它再多加一個(gè)失敗的 Case,不過(guò)我已經(jīng)很滿(mǎn)意了。
這樣做的好處是對(duì)大量測(cè)試用例都不用寫(xiě)特別多的代碼就能提高我對(duì)業(yè)務(wù)邏輯的信心了。
msw
msw 全稱(chēng) “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(類(lèi)似 Express 的寫(xiě)法)和 Mock DB 交互并返回 Mock 數(shù)據(jù)。這就可以更容易和快速地寫(xiě)測(cè)試了(配置好 Handler 后)。
你可能在之前會(huì)用 nock 之類(lèi)的庫(kù)來(lái)做這些事。但 msw 還有一個(gè)優(yōu)勢(shì):你可以將這些 “Server Handler” 用在前端本地開(kāi)發(fā)上,適用于以下場(chǎng)景:
API 還沒(méi)實(shí)現(xiàn)完 API 崩了的時(shí)候 網(wǎng)速太慢或者沒(méi)聯(lián)網(wǎng)
你可能聽(tīng)說(shuō)過(guò)做類(lèi)似事情的 Mirage。但它不是用 Service Worker 在客戶(hù)端實(shí)現(xiàn)的,所以你不能在開(kāi)發(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)題的代碼可以在前端本地開(kāi)發(fā)時(shí)復(fù)用這些 Server Handler!
Colocation 和 error/edge case testing
唯一值得擔(dān)心的是:你可能會(huì)把所有 Server Handler 放在同一個(gè)地方,而依賴(lài)它們的測(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)你完全忽略代碼的實(shí)現(xiàn)細(xì)節(jié),你就可以盡情地重構(gòu)代碼,同時(shí)你的測(cè)試會(huì)源源不斷地給你信心,讓你不用擔(dān)心會(huì)破壞用戶(hù)體驗(yàn)。這才是測(cè)試應(yīng)該做的事。
好了,這篇外文就給大家?guī)У竭@里了。總的來(lái)說(shuō),我還是挺喜歡攔截 Http 請(qǐng)求這種 Mock 方法的。msw 不僅可以在測(cè)試中攔截請(qǐng)求,實(shí)現(xiàn)集成、E2E 測(cè)試,還可以在前端開(kāi)發(fā)時(shí)來(lái) Mock 數(shù)據(jù),確實(shí)是一個(gè)有趣的實(shí)踐。
最近也給我們項(xiàng)目寫(xiě)不少單測(cè),其實(shí)單測(cè)和集成測(cè)試還是有很多互補(bǔ)的地方的。當(dāng)你發(fā)現(xiàn)要測(cè)試的東西太復(fù)雜,或者太多干擾項(xiàng)時(shí),使用集成測(cè)試會(huì)讓你真正從用戶(hù)的角度來(lái)寫(xiě)測(cè)試。這樣一來(lái),你就不會(huì)過(guò)度關(guān)注那些覆蓋率指標(biāo)了,而是從一個(gè)用戶(hù)的角度來(lái)思考這樣的用例能給我?guī)?lái)多少信心。
如果你喜歡我的分享,可以來(lái)一波一鍵三連,點(diǎn)贊、在看就是我最大的動(dòng)力,比心 ??
往期干貨:
26個(gè)經(jīng)典微信小程序+35套微信小程序源碼+微信小程序合集源碼下載(免費(fèi))
干貨~~~2021最新前端學(xué)習(xí)視頻~~速度領(lǐng)取
前端書(shū)籍-前端290本高清pdf電子書(shū)打包下載
點(diǎn)贊和在看就是最大的支持??
