这个测试有什么问题?
// __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 这样的嘲讽,有一件事让我非常困扰,那就是你最终会重新实现你的整个后端......在你的测试中无处不在。通常是在多个测试中。这是很烦人的,尤其是当它像。"在这个测试中,我们只是假设正常的后端响应",但你必须在所有地方模拟这些。在这些情况下,这真的只是设置噪音,在你和你真正试图测试的东西之间。
不可避免地发生的是这些情况之一。
- 我们模拟出客户端(就像我们的第一个测试),并依靠一些E2E测试来给我们一点信心,至少最重要的部分是正确使用
client。这样做的结果是,在我们测试触及后端的任何地方都要重新实现我们的后端。往往是重复的工作。 - 我们模拟出
window.fetch(如我们的第二个测试)。这样做会好一点,但也有一些与#1相同的问题。 - 我们把所有的东西都放在小的函数中,并在孤立的情况下进行单元测试(这本身并不是一件坏事),而不费力地在集成中测试它们(不是一件好事)。
最终,我们有更少的信心,更慢的反馈循环,大量重复的代码,或者这些的任何组合。
有一件事在很长一段时间内对我来说是非常有效的,那就是在一个函数中模拟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 文件制作一个 "数据库",作为数据库的 "种子",或者用faker或test-data-bot之类的东西制作 "建造者"。然后我制作服务器处理程序(类似于Express API)并与模拟数据库交互。这使得我的测试快速而容易编写(一旦你有了东西设置)。
你可能已经用过类似 nock来做这种事情。但是,关于msw ,最酷的事情是(我以后可能会写),你也可以在开发期间在浏览器中使用所有完全相同的 "服务器处理程序"。这有几个很大的好处。
- 如果端点还没有准备好
- 如果端点被破坏
- 如果你的网络连接很慢或不存在
你可能听说过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()
})
比起模拟获取,我对这个解决方案更满意,因为。
- 我不需要担心fetch响应属性和头信息的实现细节。
- 如果我调用
fetch的方式出了问题,那么我的服务器处理程序就不会被调用,我的测试(正确地)就会失败,这将使我免于运送损坏的代码。 - 我可以在我的开发中重复使用这些完全相同的服务器处理程序!
同位和错误/边缘案例测试
对这种方法的一个合理的担心是,你最终会把所有的服务器处理程序放在一个地方,然后依赖这些服务器处理程序的测试最终会放在完全不同的文件中,所以你失去了主机托管的好处。
首先,我想说的是,你只想把那些对你的测试来说很重要和独特的东西放在一起。你不希望在每个测试中重复所有的设置。只有那些独特的部分。因此,"快乐路径 "的东西通常最好只包括在你的设置文件中,从测试本身删除。否则,你会有太多的噪音,而且很难分离出真正被测试的东西。
但是边缘情况和错误怎么办?对于这些,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上找到所有材料。
这种测试方法的一个非常酷的方面是,因为你离实现细节很远,你可以进行重大的重构,而你的测试可以让你相信你没有破坏用户体验。这就是测试的作用!!我喜欢这种情况的发生。
祝您好运!