測試中如何處理 Http 請求?
不知道大家平時寫單測時是怎么處理 網(wǎng)絡(luò)請求 的,可能有的人會說:“把請求函數(shù) Mock ,返回 Mock 結(jié)果就行了呀”。
但在真實(shí)的測試場景中往往需要多次改變 Mock 結(jié)果,Mock fetch 或者 axios.get 就不太夠用了。
帶著上面這個問題我找到了 Kent 的這篇 《Stop mocking fetch》。今天就把這篇文章分享給大家。
正片開始
我們先來看下面這段測試代碼有什么問題:
//?__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)不了這里的問題。
好吧,我來公布一下答案:首先第一個問題就是把 client 給 Mock 掉了,問問自己:你怎么知道 client 是一定會被正確調(diào)用的呢?當(dāng)然,你可能會說:client 可以用別的單測來做保障呀。但你又怎么能保證 client 不會把返回值里的 body 改成 data 呢?哦,你是想說你用了 TypeScript 是吧?彳亍!但由于我們把 client Mock 了,所以肯定不會完全保證 client 的功能正確性。你可能還會說:我還有 E2E 測試!
但是,如果我們在這里能真的調(diào)用一下 client 不是更能提高我們對 client 的信心么?好過一直猜來猜去嘛。
不過,我們肯定也不是想真的調(diào)用 fetch 函數(shù),所以我們會選擇把 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`?的庫來做?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()
})
上面肯定能給你帶來不少代碼信心,畢竟你真的能測請求是否真的發(fā)出去了。但是,這里的缺點(diǎn)在于:它不能測 headers 里是否會帶有 Content-Type: application/json。 沒有這一步,我們也不能確定服務(wù)器是否真的能處理發(fā)出去的請求。還有一個問題,你怎么能確定用戶鑒權(quán)的信息是不是真的也被帶上呢?
看到這,肯定有人會說:“在 client 的單測里已經(jīng)驗證了呀,你還想我要做啥?我不想再把那里面的測試代碼也在這復(fù)制一份”。行行行,我知道。但如果有一種即可以不用復(fù)制 client 的測試代碼,又能提高代碼自信的方法呢?繼續(xù)往下看。
我一直不太喜歡 Mock 類似 fetch 函數(shù)的東西,因為最終你會在所有地方把整個后端的邏輯都重新實(shí)現(xiàn)一遍。 這通常發(fā)生在多個測試之間,非常煩人。特別是在一些測試中,我們要假定后端要返回的內(nèi)容的時候,就不得不在所有地方都要 Mock 一次。在這種情況下,就會給你和要做測試的東西設(shè)置了很多障礙。
我們的測試策略就會變成這樣:
-
我們把
clientMock 了(第一個例子),然后依賴一些 E2E 測試來保障client正確執(zhí)行,以此給予我們心靈上一丟丟信心。但這也導(dǎo)致了一旦遇到后端的東西,我就要在所有地方都要重新實(shí)現(xiàn)一遍后端邏輯 -
我們把
window.fetchMock 了(第二個例子)。這會好點(diǎn),但這也會遇到第 1 點(diǎn)類似的問題 - 把所有東西都放在函數(shù)中,然后拿來做單測(這樣還行),這樣就避免在集成測試中再測一遍(不太好,譯注:不太好是因為集成測試應(yīng)該要對整個功能進(jìn)行測試,這樣分開測就不完整了)
最終,這樣的測試并沒有給我們太多的心理安慰,反而帶來很多重復(fù)的工作。
很長一段時間里我的解決方法是:聲明一個假的 fetch 函數(shù),把后端要 Mock 的內(nèi)容都放里面。我在 Paypal 的時候就試過,發(fā)現(xiàn)還挺好用的。這里舉個例子:
//?把它放在?Jest?的?setup?文件中,就會在所有測試文件前被引入了
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)
??????//?可以在這里添加購物車的邏輯
??????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))
然后,我們的測試就可以寫成這樣了:
//?__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()
})
這個測試方法不需要你做多余的事,就是寫 case。這里還可以給它再多加一個失敗的 Case,不過我已經(jīng)很滿意了。
這樣做的好處是對大量測試用例都不用寫特別多的代碼就能提高我對業(yè)務(wù)邏輯的信心了。
msw
msw 全稱 “Mock Service Worker”。 現(xiàn)在 Service Worker 還只是瀏覽器中的功能,不能在 Node 端使用。但是,msw 可以支持 Node 端所有測試場景。
它的工作原理是這樣的:創(chuàng)建一個 Mock Server 來攔截所有的請求,然后你就可以像是在真的 Server 里去處理請求。
我的做法是:用 json 來初始化數(shù)據(jù)庫,或者用 faker(現(xiàn)在別用了) 和 test-data-bot 來構(gòu)造數(shù)據(jù)。然后用 Server Handler(類似 Express 的寫法)和 Mock DB 交互并返回 Mock 數(shù)據(jù)。這就可以更容易和快速地寫測試了(配置好 Handler 后)。
你可能在之前會用 nock 之類的庫來做這些事。但 msw 還有一個優(yōu)勢:你可以將這些 “Server Handler” 用在前端本地開發(fā)上,適用于以下場景:
- API 還沒實(shí)現(xiàn)完
- API 崩了的時候
- 網(wǎng)速太慢或者沒聯(lián)網(wǎng)
你可能聽說過做類似事情的 Mirage。但它不是用 Service Worker 在客戶端實(shí)現(xiàn)的,所以你不能在開發(fā)者的 Network Tab 里看到 HTTP 請求,但是 msw 則可以。 兩者對比可以看這里。
示例
有了上面的介紹,現(xiàn)在來看看 msw 是如何 Mock Server 的:
//?server-handlers.js
//?放在這里,不僅可以給測試用也能給前端本地使用
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?文件上,可以在所有測試前執(zhí)行
import?{server}?from?'./server.js'
beforeAll(()?=>?server.listen())
//?如果你要在特定的用例上使用特定的?Handler,這會在最后把它們重置掉
//?(對單測的隔離性很重要)
afterEach(()?=>?server.resetHandlers())
afterAll(()?=>?server.close())
現(xiàn)在我們的測試就可以改成:
//?__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í)的Server Handler不會被調(diào)用,而且我的測試也會失敗,可以避免提交有問題的代碼 -
可以在前端本地開發(fā)時復(fù)用這些
Server Handler!
Colocation 和 error/edge case testing
唯一值得擔(dān)心的是:你可能會把所有 Server Handler 放在同一個地方,而依賴它們的測試文件又會被放在不同地方,這可能會導(dǎo)致文件放置不集中。
首先,我想說的是,只有那些對你測試很重要,很獨(dú)特的東西才應(yīng)該盡可能靠近測試文件。
你不需要在所有測試文件中都要重復(fù) setup 一次,只需要 setup 獨(dú)特的東西就可以了。 所以,最簡單的方式就是:把常用的部分放在 Jest 的 setup 文件里。 不然你會有很多的干擾項,也很難對真正要測的東西進(jìn)行隔離。
對于自定義的場景,msw 可以在運(yùn)行時允許你在測試用例中添加自定義的 Server Handler,也可以一鍵重置成你原來的 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()
})
//?邊界情況、錯誤情況,需要添加自定義的?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)
})
這么一來,你不僅可以把相關(guān)邏輯的代碼放在一起,還能實(shí)現(xiàn)場景自定義。
總結(jié)
當(dāng)然 msw 還有很多其它玩法,讀者可以自行探索。下面先讓我們來小結(jié)一下。
這種測試策略一大優(yōu)勢就是:當(dāng)你完全忽略代碼的實(shí)現(xiàn)細(xì)節(jié),你就可以盡情地重構(gòu)代碼,同時你的測試會源源不斷地給你信心,讓你不用擔(dān)心會破壞用戶體驗。這才是測試應(yīng)該做的事。
好了,這篇外文就給大家?guī)У竭@里了。總的來說,我還是挺喜歡攔截 Http 請求這種 Mock 方法的。msw 不僅可以在測試中攔截請求,實(shí)現(xiàn)集成、E2E 測試,還可以在前端開發(fā)時來 Mock 數(shù)據(jù),確實(shí)是一個有趣的實(shí)踐。
最近也給我們項目寫不少單測,其實(shí)單測和集成測試還是有很多互補(bǔ)的地方的。當(dāng)你發(fā)現(xiàn)要測試的東西太復(fù)雜,或者太多干擾項時,使用集成測試會讓你真正從用戶的角度來寫測試。這樣一來,你就不會過度關(guān)注那些覆蓋率指標(biāo)了,而是從一個用戶的角度來思考這樣的用例能給我?guī)矶嗌傩判摹?/strong>
