停止嘲弄fetch

110 阅读9分钟

这个测试有什么问题?

// __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 的实际API和要求以及/checkout 端点,你就无法真正回答。所以,对此很抱歉。但是,有一个问题是,因为你在模拟client ,你怎么能真正知道客户端在这种情况下被正确使用?当然,可以对client 进行单元测试,以确保它正确地调用window.fetch ,但你怎么知道client 最近没有改变其API以接受body ,而不是data ?哦,你使用TypeScript,所以你已经消除了一类错误。但肯定有一些业务逻辑错误会溜进来,因为我们在这里模拟了client 。当然,你可以依靠你的E2E测试来给你信心,但如果只是调用client ,并在这个较低的层次上获得信心,在这里你有一个更紧密的反馈循环,不是更好吗? 如果这不是更困难,那么当然!但我们不希望实际上是在使用TypeScript,所以你会发现有一些错误。

但是,我们并不想实际提出fetch 的请求,对吗?因此,让我们模拟出window.fetch

// __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'))
// assuming jest's resetMocks is configured to "true" so
// we don't need to worry about cleanup
// this also assumes that you've loaded a fetch polyfill like `whatwg-fetch`

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 单元测试中验证过了,Kent。你还想从我这里得到什么呢!?我不想到处复制/粘贴断言!"我完全理解你的想法。但是,如果有一种方法可以避免所有关于断言的额外工作,但也可以在每个测试中获得这种信心呢?继续阅读。

对于像fetch 这样的嘲讽,有一件事让我非常困扰,那就是你最终会重新实现你的整个后端......在你的测试中无处不在。通常是在多个测试中。这是很烦人的,尤其是当它像。"在这个测试中,我们只是假设正常的后端响应",但你必须在所有地方模拟这些。在这些情况下,这真的只是设置噪音,在你和你真正试图测试的东西之间。

不可避免地发生的是这些情况之一。

  1. 我们模拟出客户端(就像我们的第一个测试),并依靠一些E2E测试来给我们一点信心,至少最重要的部分是正确使用client 。这样做的结果是,在我们测试触及后端的任何地方都要重新实现我们的后端。往往是重复的工作。
  2. 我们模拟出window.fetch (如我们的第二个测试)。这样做会好一点,但也有一些与#1相同的问题。
  3. 我们把所有的东西都放在小的函数中,并在孤立的情况下进行单元测试(这本身并不是一件坏事),而不费力地在集成中测试它们(不是一件好事)。

最终,我们有更少的信心,更慢的反馈循环,大量重复的代码,或者这些的任何组合。

有一件事在很长一段时间内对我来说是非常有效的,那就是在一个函数中模拟fetch,这基本上是对我测试过的所有后端部分的重新实现。我在PayPal做了一个这样的形式,效果非常好。你可以这样想。

// add this to your setupFilesAfterEnv config in jest so it's imported for every test file
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)
      // do whatever other things you need to do with this shopping cart
      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()
})

我的happy-path测试不需要做任何特别的事情。也许我会在失败的情况下添加一个fetch mock,但我对这个很满意。

这样做的好处是,我只增加了我的信心,而且在大多数情况下,我需要写的测试代码更少。

然后我发现msw

msw是 "Mock Service Worker "的简称。现在,服务工作者并不在Node中工作,它们是浏览器的一个功能。然而,无论如何,msw支持Node的测试目的。

基本的想法是这样的:创建一个模拟服务器,拦截所有的请求,并像处理一个真正的服务器那样处理它。在我自己的实现中,这意味着我用json 文件制作一个 "数据库",作为数据库的 "种子",或者用fakertest-data-bot之类的东西制作 "建造者"。然后我制作服务器处理程序(类似于Express API)并与模拟数据库交互。这使得我的测试快速而容易编写(一旦你有了东西设置)。

你可能已经用过类似 nock来做这种事情。但是,关于msw ,最酷的事情是(我以后可能会写),你也可以在开发期间在浏览器中使用所有完全相同的 "服务器处理程序"。这有几个很大的好处。

  1. 如果端点还没有准备好
  2. 如果端点被破坏
  3. 如果你的网络连接很慢或不存在

你可能听说过Mirage,它做了很多相同的事情。然而(目前)Mirage在客户端不使用服务工作者,而且我非常喜欢网络标签的工作方式,无论我是否安装了msw。了解更多关于它们的区别

例子

因此,在介绍了这些之后,下面是我们如何使用msw 支持我们的模拟服务器来完成上述例子。

// server-handlers.js
// this is put into here so I can share these same handlers between my tests
// as well as my development in the browser. Pretty sweet!
import {rest} from 'msw' // msw supports graphql too!
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
// add this to your setupFilesAfterEnv config in jest so it's imported for every test file
import {server} from './server.js'

beforeAll(() => server.listen())
// if you need to add a handler after calling setupServer for some specific test
// this will remove that handler for the rest of them
// (which is important for test isolation):
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()
})

比起模拟获取,我对这个解决方案更满意,因为。

  1. 我不需要担心fetch响应属性和头信息的实现细节。
  2. 如果我调用fetch 的方式出了问题,那么我的服务器处理程序就不会被调用,我的测试(正确地)就会失败,这将使我免于运送损坏的代码。
  3. 我可以在我的开发中重复使用这些完全相同的服务器处理程序!

同位和错误/边缘案例测试

对这种方法的一个合理的担心是,你最终会把所有的服务器处理程序放在一个地方,然后依赖这些服务器处理程序的测试最终会放在完全不同的文件中,所以你失去了主机托管的好处。

首先,我想说的是,你只想把那些对你的测试来说很重要和独特的东西放在一起。你不希望在每个测试中重复所有的设置。只有那些独特的部分。因此,"快乐路径 "的东西通常最好只包括在你的设置文件中,从测试本身删除。否则,你会有太多的噪音,而且很难分离出真正被测试的东西。

但是边缘情况和错误怎么办?对于这些,MSW有能力让你在运行时添加额外的服务器处理程序(在测试中),然后将服务器重置为原来的处理程序(有效地删除运行时处理程序),以保持测试的隔离性。这里有一个例子。

// __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'

// happy path test, no special server stuff
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()
})

// edge/error case, special server stuff
// note that the afterEach(() => server.resetHandlers()) we have in our
// setup file will ensure that the special handler is removed for other tests
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 ,肯定还有更多的事情要做,但我们现在就来总结一下。如果你想看看msw ,我的4部分工作坊 "构建React应用程序"(包含在EpicReact.Dev中)就使用了它,你可以在GitHub上找到所有材料

这种测试方法的一个非常酷的方面是,因为你离实现细节很远,你可以进行重大的重构,而你的测试可以让你相信你没有破坏用户体验。这就是测试的作用!!我喜欢这种情况的发生。

twitter.com/kentcdodds/…

祝您好运!