测试中如何处理 Http 请求?

前言

哈喽,大家好,我是海怪。

不知道大家平时写单测时是怎么处理 网络请求 的,可能有的人会说:“把请求函数 Mock ,返回 Mock 结果就行了呀”。

但在真实的测试场景中往往需要多次改变 Mock 结果,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 的用法,你可能发现不了这里的问题。

好吧,我来公布一下答案:首先第一个问题就是把 client 给 Mock 掉了,问问自己:你怎么知道 client 是一定会被正确调用的呢?当然,你可能会说:client 可以用别的单测来做保障呀。但你又怎么能保证 client 不会把返回值里的 body 改成 data 呢?哦,你是想说你用了 TypeScript 是吧?彳亍!但由于我们把 client Mock 了,所以肯定不会完全保证 client 的功能正确性。你可能还会说:我还有 E2E 测试!

但是,如果我们在这里能真的调用一下 client 不是更能提高我们对 client 的信心么?好过一直猜来猜去嘛。

不过,我们肯定也不是想真的调用 fetch 函数,所以我们会选择把 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 设置为 true
// 我们就不用担心要 cleanup 了
// 这里假设你用了类似 `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()
})

上面肯定能给你带来不少代码信心,毕竟你真的能测请求是否真的发出去了。但是,这里的缺点在于:它不能测 headers 里是否会带有 Content-Type: application/json 没有这一步,我们也不能确定服务器是否真的能处理发出去的请求。 还有一个问题,你怎么能确定用户鉴权的信息是不是真的也被带上呢?

看到这,肯定有人会说:“在 client 的单测里已经验证了呀,你还想我要做啥?我不想再把那里面的测试代码也在这复制一份”。行行行,我知道。但如果有一种即可以不用复制 client 的测试代码,又能提高代码自信的方法呢?继续往下看。

我一直不太喜欢 Mock 类似 fetch 函数的东西,因为最终你会在所有地方把整个后端的逻辑都重新实现一遍。 这通常发生在多个测试之间,非常烦人。特别是在一些测试中,我们要假定后端要返回的内容的时候,就不得不在所有地方都要 Mock 一次。在这种情况下,就会给你和要做测试的东西设置了很多障碍。

我们的测试策略就会变成这样:

  1. 我们把 client Mock 了(第一个例子),然后依赖一些 E2E 测试来保障 client 正确执行,以此给予我们心灵上一丢丢信心。但这也导致了一旦遇到后端的东西,我就要在所有地方都要重新实现一遍后端逻辑
  2. 我们把 window.fetch Mock 了(第二个例子)。这会好点,但这也会遇到第 1 点类似的问题
  3. 把所有东西都放在函数中,然后拿来做单测(这样还行),这样就避免在集成测试中再测一遍(不太好,译注:不太好是因为集成测试应该要对整个功能进行测试,这样分开测就不完整了

最终,这样的测试并没有给我们太多的心理安慰,反而带来很多重复的工作。

很长一段时间里我的解决方法是:声明一个假的 fetch 函数,把后端要 Mock 的内容都放里面。我在 Paypal 的时候就试过,发现还挺好用的。这里举个例子:

// 把它放在 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,不过我已经很满意了。

这样做的好处是对大量测试用例都不用写特别多的代码就能提高我对业务逻辑的信心了。

msw

msw 全称 “Mock Service Worker”。 现在 Service Worker 还只是浏览器中的功能,不能在 Node 端使用。但是,msw 可以支持 Node 端所有测试场景。

它的工作原理是这样的:创建一个 Mock Server 来拦截所有的请求,然后你就可以像是在真的 Server 里去处理请求。

我的做法是:json 来初始化数据库,或者用 faker(现在别用了) 和 test-data-bot 来构造数据。然后用 Server Handler(类似 Express 的写法)和 Mock DB 交互并返回 Mock 数据。这就可以更容易和快速地写测试了(配置好 Handler 后)。

你可能在之前会用 nock 之类的库来做这些事。但 msw 还有一个优势:你可以将这些 “Server Handler” 用在前端本地开发上,适用于以下场景:

  • API 还没实现完
  • API 崩了的时候
  • 网速太慢或者没联网

你可能听说过做类似事情的 Mirage但它不是用 Service Worker 在客户端实现的,所以你不能在开发者的 Network Tab 里看到 HTTP 请求,但是 msw 则可以。 两者对比可以看这里

示例

有了上面的介绍,现在来看看 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 文件上,可以在所有测试前执行
import {server} from './server.js'

beforeAll(() => server.listen())
// 如果你要在特定的用例上使用特定的 Handler,这会在最后把它们重置掉
// (对单测的隔离性很重要)
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

现在我们的测试就可以改成:

// __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 函数里的实现细节
  • 当调用 fetch 时有报错,那么真实的 Server Handler 不会被调用,而且我的测试也会失败,可以避免提交有问题的代码
  • 可以在前端本地开发时复用这些 Server Handler

Colocation 和 error/edge case testing

唯一值得担心的是:你可能会把所有 Server Handler 放在同一个地方,而依赖它们的测试文件又会被放在不同地方,这可能会导致文件放置不集中。

首先,我想说的是,只有那些对你测试很重要,很独特的东西才应该尽可能靠近测试文件。

你不需要在所有测试文件中都要重复 setup 一次,只需要 setup 独特的东西就可以了。 所以,最简单的方式就是:把常用的部分放在 Jest 的 setup 文件里。 不然你会有很多的干扰项,也很难对真正要测的东西进行隔离。

对于自定义的场景,msw 可以在运行时允许你在测试用例中添加自定义的 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)
})

这么一来,你不仅可以把相关逻辑的代码放在一起,还能实现场景自定义。

总结

当然 msw 还有很多其它玩法,读者可以自行探索。下面先让我们来小结一下。

这种测试策略一大优势就是:当你完全忽略发代码的实现细节,你就可以尽情地重构代码,同时你的测试会源源不断地给你信心,让你不用担心会破坏用户体验。这才是测试应该做的事。


好了,这篇外文就给大家带到这里了。总的来说,我还是挺喜欢拦截 Http 请求这种 Mock 方法的。msw 不仅可以在测试中拦截请求,实现集成、E2E 测试,还可以在前端开发时来 Mock 数据,确实是一个有趣的实践。

最近也给我们项目写不少单测,其实单测和集成测试还是有很多互补的地方的。当你发现要测试的东西太复杂,或者太多干扰项时,使用集成测试会让你真正从用户的角度来写测试。这样一来,你就不会过度关注那些覆盖率指标了,而是从一个用户的角度来思考这样的用例能给我带来多少信心。

如果你喜欢我的分享,可以来一波一键三连,点赞、在看就是我最大的动力,比心 ❤️

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,772评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,458评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,610评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,640评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,657评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,590评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,962评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,631评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,870评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,611评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,704评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,386评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,969评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,944评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,179评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,742评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,440评论 2 342

推荐阅读更多精彩内容