Pare de mockar o window.fetch
Mikéias Oliveira
Posted on October 10, 2021
Porque você não deve mockar fetch
ou seu cliente de API em seus testes e o que fazer em vez disso.
O que há de errado com este teste?
// __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()
})
Esta é uma pergunta um pouco capciosa. Sem conhecer a API e os requisitos reais, Checkout
bem como o endpoint /checkout
, você não pode realmente responder. Então, desculpe por isso. Mas, um problema com isso é porque você está mockando o client
. Como você realmente sabe que o client
está sendo usado corretamente neste caso? Claro, client
pode ser testado de forma unitária para garantir que está chamando window.fetch
corretamente, mas como você sabe que client
não mudou recentemente sua API para aceitar um body
em vez de data
? Oh, você está usando TypeScript, então eliminou uma categoria de bugs. Boa! Mas definitivamente existem alguns bugs de lógica de negócios que podem surgir porque estamos mockando o client
aqui. Claro, você pode confiar em seus testes E2E para lhe dar essa confiança, mas não seria melhor apenas chamar o client
e ter essa confiança aqui neste nível inferior, onde você tem um ciclo de feedback mais rápido? Se não for muito mais difícil, então com certeza!
Mas não queremos realmente fazer requisições fetch
, certo? Então, vamos mockar 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()
})
Isso lhe dará um pouco mais de confiança de que uma requisição está realmente sendo feita, mas outra coisa que falta neste teste é um assertion
de que headers
tem um Content-Type: application/json
. Sem isso, como você pode ter certeza de que o servidor reconhecerá a solicitação que você está fazendo? Ah, e como você garante que as informações de autenticação também estão sendo passadas corretamente?
Eu ouvi você, "mas verificamos client
em nossos testes de unidade, Kent. O que mais você quer de mim!? Não quero copiar/colar assertions
em todos os lugares!" Eu definitivamente entendo você aí. Mas e se houvesse uma maneira de evitar todo o trabalho extra em assertions
em todos os lugares, mas também obter essa confiança em todos os testes? Continue lendo.
Uma coisa que realmente me incomoda em mockar coisas como fetch
é que você acaba reimplementando todo o seu back-end... em todos os seus testes. Frequentemente em vários testes. É super irritante, especialmente quando é como: "neste teste, nós apenas assumimos as respostas normais do back-end", mas você tem que mockar elas em todo lugar. Nesses casos, é apenas um impasse de configuração que se interpõe entre você e o que você está tentando testar.
O que inevitavelmente acontece é um destes cenários:
Nós simulamos o
client
(como em nosso primeiro teste) e confiamos em alguns testes E2E para nos dar um pouco de confiança de que pelo menos as partes mais importantes estão usando oclient
corretamente. Isso resulta na reimplementação de nosso back-end em qualquer lugar em que testamos coisas que se relacionam com o back-end. Frequentemente duplicando o trabalho.Nós mockamos
window.fetch
(como em nosso segundo teste). Isso é um pouco melhor, mas sofre de alguns dos mesmos problemas que o nº 1.Colocamos todas as nossas coisas em pequenas funções e testamos a unidade de forma isolada (o que não é realmente uma coisa ruim por si só) e não nos incomodamos em testá-las na integração (o que não é ótimo).
Em última análise, temos menos confiança, um loop de feedback mais lento, muitos códigos duplicados ou qualquer combinação deles.
Uma coisa que acabou funcionando muito bem para mim por um longo tempo foi mockar o fetch
em uma função, que é basicamente uma reimplementação de todas as partes do meu back-end que testei. Fiz uma forma disso no PayPal e funcionou muito bem. Você pode pensar assim:
// 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))
Agora meu teste pode ser assim:
// __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()
})
Meu teste de "caminho feliz" não precisa fazer nada de especial. Talvez eu adicionasse uma opção para um caso de falha, mas fiquei muito feliz com isso.
O que é ótimo sobre isso é que eu apenas aumento minha confiança e tenho ainda menos código de teste para escrever na maioria dos casos.
Então eu descobri msw
msw é a abreviação de "Mock Service Worker". Agora, os service workers não funcionam no Node, eles são um recurso do navegador. No entanto, msw suporta Node de qualquer maneira para fins de testes.
A ideia básica é esta: criar um servidor mock que intercepte todas as solicitações e trate-o como você faria se fosse um servidor real. Em minha própria implementação, isso significa que faço um "banco de dados" com arquivos json
com "seeds" para o banco de dados ou "builders" usando algo como faker ou test-data-bot. Em seguida, crio handlers de servidor (semelhante à API do Express) e interajo com esse banco de dados fictício. Isso torna meus testes rápidos e fáceis de escrever (uma vez que você tenha tudo configurado).
Você pode ter usado algo parecido com nock para fazer esse tipo de coisa antes. Mas o legal do msw
(e algo sobre o qual posso escrever mais tarde) é que você também pode usar exatamente os mesmos handlers de servidor no navegador durante o desenvolvimento. Isso tem alguns grandes benefícios:
- Se o endpoint não estiver pronto.
- Se o endpoint estiver quebrado.
- Se sua conexão com a Internet for lenta ou inexistente.
Você deve ter ouvido falar do Mirage, que faz quase a mesma coisa. Porém (atualmente) o Mirage não usa um service worker no cliente e eu realmente gosto que a aba network funcione da mesma forma, quer eu tenha o msw instalado ou não. Saiba mais sobre suas diferenças.
Exemplo
Então, com essa introdução, aqui está como faríamos nosso exemplo acima com o msw por trás do nosso servidor mock:
// 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())
Agora meu teste pode ser assim:
// __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()
})
Estou mais feliz com esta solução do que mockar fetch porque:
Não preciso me preocupar com os detalhes de implementação das propriedades e headers das respostas.
Se eu errar na forma como chamo fetch, meu handler de servidor não será chamado e meu teste (corretamente) falhará, o que me salvaria de enviar códigos quebrados.
Posso reutilizar exatamente esses mesmos handlers de servidor em meu desenvolvimento!
Colocation
e testes de erro/caso extremo
Uma preocupação razoável sobre essa abordagem é que você acaba colocando todos os seus handlers de servidor em um só lugar e, em seguida, os testes que dependem desses handlers acabam em arquivos totalmente diferentes, então você perde os benefícios da colocation.
Em primeiro lugar, eu diria que você deseja colocar apenas as coisas que são importantes e exclusivas para o seu teste. Você não gostaria de ter que duplicar toda a configuração em cada teste. Apenas as partes que são únicas. Portanto, o "caminho feliz" é normalmente melhor apenas incluir no arquivo de configuração, removido do próprio teste. Caso contrário, você terá muito ruído e será difícil isolar o que está realmente sendo testado.
Mas e quanto aos casos extremos e erros? Para eles, o MSW tem a capacidade de adicionar handlers de servidor adicionais no tempo de execução (dentro de um teste) e, em seguida, redefinir o servidor para os handlers originais (removendo efetivamente os handlers de tempo de execução) para preservar o isolamento do teste. Aqui está um exemplo:
// __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)
})
Portanto, você pode ter colocation onde for necessário e abstração onde for sensato.
Conclusão
Definitivamente, há mais coisas para fazer com msw
, mas vamos encerrar por agora. Se você quiser ver msw
em ação, meu workshop de 4 partes "Build React Apps" (incluído no EpicReact.Dev) usa e você pode encontrar todo o material no GitHub.
Um aspecto muito legal desse modelo de teste é que, como você está muito longe dos detalhes de implementação, pode fazer refatorações significativas e seus testes podem lhe dar a confiança de que você não quebrou a experiência do usuário. É para isso que servem os testes!! Adoro quando isso acontece:
I completely changed how authentication works in my app the other day and only needed to make a small tweak to my test utils and all my tests (unit, integration, and E2E) passed, giving me confidence that my user experience was unaffected by the change. That's what tests are for! https://t.co/tG5wJ76dut
— Kent C. Dodds (@kentcdodds ) June 7, 2020
Boa sorte!
Tradução livre do artigo “Stop mocking fetch” originalmente escrito por Kent C. Dodds, publicado em 03 de Junho de 2020.
Posted on October 10, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.