Testing that our frontend code interacts with the backend is important. It’s how the user uses our applications, so it’s what our tests should do as well if we want the maximum confidence. However, there are several challenges that come with doing that. The setup required to make this work is non-trivial. It is definitely important that we test that integration, but we can do that with a suite of solid E2E tests using a tool like Cypress.
For our Integration and Unit component tests, we’re going to trade-off some
confidence for convenience and we’ll make up for that with E2E tests. So for all
of our Jest tests, we’ll start up a mock server to handle all of the
window.fetch
requests we make during our tests.
Because window.fetch isn’t supported in JSDOM/Node, we have the
whatwg-fetch
module installed which will polyfill fetch in our testing environment which will allow MSW to handle those requests for us. This is setup automatically in our jest config thanks toreact-scripts
.
To handle these fetch requests, we’re going to start up a “server” which is not actually a server, but simply a request interceptor. This makes it really easy to get things setup (because we don’t have to worry about finding an available port for the server to listen to and making sure we’re making requests to the right port) and it also allows us to mock requests made to other domains.
We’ll be using a tool called MSW for this. Here’s an example of how you can use msw for tests:
// __tests__/fetch.test.jsimport * as React from 'react'import {rest} from 'msw'import {setupServer} from 'msw/node'import {render, waitForElementToBeRemoved, screen} from '@testing-library/react'import {userEvent} from '@testing-library/user-event'import Fetch from '../fetch'const server = setupServer(rest.get('/greeting', (req, res, ctx) => {return res(ctx.json({greeting: 'hello there'}))}),)beforeAll(() => server.listen())afterEach(() => server.resetHandlers())afterAll(() => server.close())test('loads and displays greeting', async () => {render(<Fetch url="/greeting" />)await userEvent.click(screen.getByText('Load Greeting'))await waitForElementToBeRemoved(() => screen.getByText('Loading...'))expect(screen.getByRole('heading')).toHaveTextContent('hello there')expect(screen.getByRole('button')).toHaveAttribute('disabled')})test('handles server error', async () => {server.use(rest.get('/greeting', (req, res, ctx) => {return res(ctx.status(500))}),)render(<Fetch url="/greeting" />)await userEvent.click(screen.getByText('Load Greeting'))await waitForElementToBeRemoved(() => screen.getByText('Loading...'))expect(screen.getByRole('alert')).toHaveTextContent('Oops, failed to fetch!')expect(screen.getByRole('button')).not.toHaveAttribute('disabled')})
That should give you enough to go on, but if you’d like to check out the docs, please do!
📜 MSW
In the last exercise you wrote a test for the Login form by itself, now you’ll be writing a test that connects that login form with a backend request for when the user submits the form.
We’ll use waitForElementToBeRemoved
to wait for the loading indicator to go
away.
In my applications, I love having a mock server to use during development. It’s often more reliable, works offline, doesn’t require a lot of environment setup, and allows me to start writing UI for APIs that aren’t finished yet.
MSW was actually originally built for this use case and we’ve already
implemented this server handler for our app in test/server-handlers.js
, so for
this extra credit, import that array of server handlers and send it along into
the setupServer
call.
Add a test for what happens if the response to our login request is a failure. Our server handlers already handle situations where the username or password are not provided, so you can simply not fill one of those values in and then you’ll want to make sure the error message is displayed.
Copy and pasting output into your test assertion (like the error message in our last extra credit) is no fun. Especially if that error message were to change in the future.
Instead, we can use a special assertion to take a “snapshot” of the error
message and Jest will update our code for us. Use toMatchInlineSnapshot
rather
than an explicit assertion on that error element.
How would we test a situation where the server fails for some unknown reason? There are plenty of situations where we want to test what happens when the server misbehaves. But we don’t want to code those scenarios in our application-wide server handlers for two reasons:
Read more about the benefits of colocation.
So instead, we want one-off server handlers to be written directly in the test
that needs it. This is what MSW’s server.use
API is for. It allows you to add
server handlers after the server has already started. And the
server.resetHandlers()
allows you to remove those added handlers between tests
to preserve test isolation and restore the original handlers.
See if you can add another test to check a situation for when the server misbehaves and sends a status code 500 error.
💰 Here’s something to get you started:
server.use(rest.post(// note that it's the same URL as our app-wide handler// so this will override the other.'https://auth-provider.example.com/api/login',async (req, res, ctx) => {// your one-off handler here},),)
After the instruction, if you want to remember what you’ve just learned, then fill out the elaboration and feedback form:
import * as React from 'react'import {render, screen, waitForElementToBeRemoved} from '@testing-library/react'import userEvent from '@testing-library/user-event'import {build, fake} from '@jackfranklin/test-data-bot'import {rest} from 'msw'import {setupServer} from 'msw/node'import {handlers} from 'test/server-handlers'import Login from '../../components/login-submission'const buildLoginForm = build({fields: {username: fake(f => f.internet.userName()),password: fake(f => f.internet.password()),},})const server = setupServer(...handlers)beforeAll(() => server.listen())afterAll(() => server.close())afterEach(() => server.resetHandlers())test(`logging in displays the user's username`, async () => {render(<Login />)const {username, password} = buildLoginForm()await userEvent.type(screen.getByLabelText(/username/i), username)await userEvent.type(screen.getByLabelText(/password/i), password)await userEvent.click(screen.getByRole('button', {name: /submit/i}))await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i))expect(screen.getByText(username)).toBeInTheDocument()})test('omitting the password results in an error', async () => {render(<Login />)const {username} = buildLoginForm()await userEvent.type(screen.getByLabelText(/username/i), username)// don't type in the passwordawait userEvent.click(screen.getByRole('button', {name: /submit/i}))await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i))expect(screen.getByRole('alert').textContent).toMatchInlineSnapshot(`"password required"`,)})test('unknown server error displays the error message', async () => {const testErrorMessage = 'Oh no, something bad happened'server.use(rest.post('https://auth-provider.example.com/api/login',async (req, res, ctx) => {return res(ctx.status(500), ctx.json({message: testErrorMessage}))},),)render(<Login />)await userEvent.click(screen.getByRole('button', {name: /submit/i}))await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i))expect(screen.getByRole('alert')).toHaveTextContent(testErrorMessage)})
import {rest} from 'msw'const delay = process.env.NODE_ENV === 'test' ? 0 : 1500const handlers = [rest.post('https://auth-provider.example.com/api/login',async (req, res, ctx) => {if (!req.body.password) {return res(ctx.delay(delay),ctx.status(400),ctx.json({message: 'password required'}),)}if (!req.body.username) {return res(ctx.delay(delay),ctx.status(400),ctx.json({message: 'username required'}),)}return res(ctx.delay(delay), ctx.json({username: req.body.username}))},),]export {handlers}
Quick Links
Legal Stuff
Social Media