測(cè)試中如何處理 Http 請(qǐng)求姨俩?

前言

哈嘍鸟赫,大家好,我是海怪今缚。

不知道大家平時(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ì)變成這樣:

  1. 我們把 client Mock 了(第一個(gè)例子)颖医,然后依賴一些 E2E 測(cè)試來(lái)保障 client 正確執(zhí)行位衩,以此給予我們心靈上一丟丟信心。但這也導(dǎo)致了一旦遇到后端的東西熔萧,我就要在所有地方都要重新實(shí)現(xiàn)一遍后端邏輯
  2. 我們把 window.fetch Mock 了(第二個(gè)例子)糖驴。這會(huì)好點(diǎn)僚祷,但這也會(huì)遇到第 1 點(diǎn)類似的問(wèn)題
  3. 把所有東西都放在函數(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)力,比心 ??

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末雪位,一起剝皮案震驚了整個(gè)濱河市竭钝,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌雹洗,老刑警劉巖香罐,帶你破解...
    沈念sama閱讀 218,941評(píng)論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異时肿,居然都是意外死亡庇茫,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,397評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門螃成,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)港令,“玉大人,你說(shuō)我怎么就攤上這事锈颗∏昱” “怎么了?”我有些...
    開封第一講書人閱讀 165,345評(píng)論 0 356
  • 文/不壞的土叔 我叫張陵击吱,是天一觀的道長(zhǎng)淋淀。 經(jīng)常有香客問(wèn)我,道長(zhǎng)覆醇,這世上最難降的妖魔是什么朵纷? 我笑而不...
    開封第一講書人閱讀 58,851評(píng)論 1 295
  • 正文 為了忘掉前任炭臭,我火速辦了婚禮,結(jié)果婚禮上袍辞,老公的妹妹穿的比我還像新娘鞋仍。我一直安慰自己,他們只是感情好搅吁,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,868評(píng)論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著谎懦,像睡著了一般界拦。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上截碴,一...
    開封第一講書人閱讀 51,688評(píng)論 1 305
  • 那天隐岛,我揣著相機(jī)與錄音,去河邊找鬼齐帚。 笑死彼哼,一個(gè)胖子當(dāng)著我的面吹牛敢朱,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播孝常,決...
    沈念sama閱讀 40,414評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼构灸,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼喜颁!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起隔披,我...
    開封第一講書人閱讀 39,319評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎漓库,沒想到半個(gè)月后渺蒿,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體茂装,經(jīng)...
    沈念sama閱讀 45,775評(píng)論 1 315
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡少态,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,945評(píng)論 3 336
  • 正文 我和宋清朗相戀三年嫌佑,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了侨歉。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片幽邓。...
    茶點(diǎn)故事閱讀 40,096評(píng)論 1 350
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡牵舵,死狀恐怖畸颅,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情兵睛,我是刑警寧澤祖很,帶...
    沈念sama閱讀 35,789評(píng)論 5 346
  • 正文 年R本政府宣布假颇,位于F島的核電站,受9級(jí)特大地震影響姜钳,放射性物質(zhì)發(fā)生泄漏哥桥。R本人自食惡果不足惜激涤,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,437評(píng)論 3 331
  • 文/蒙蒙 一送滞、第九天 我趴在偏房一處隱蔽的房頂上張望犁嗅。 院中可真熱鬧晤碘,春花似錦园爷、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,993評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至腾节,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間庆冕,已是汗流浹背访递。 一陣腳步聲響...
    開封第一講書人閱讀 33,107評(píng)論 1 271
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留响巢,地道東北人棒妨。 一個(gè)月前我還...
    沈念sama閱讀 48,308評(píng)論 3 372
  • 正文 我出身青樓灾炭,卻偏偏與公主長(zhǎng)得像蜈出,于是被迫代替她去往敵國(guó)和親铡原。 傳聞我的和親對(duì)象是個(gè)殘疾皇子燕刻,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,037評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容