You can get a huge amount of confidence and coverage from integration tests that test an entire page, or even your entire app. Let’s write a test that renders our whole app using React Testing Library and navigate around it like a normal user would. These tests are typically a bit longer, but they provide a huge amount of value.
App.js
import React from 'react'
import { BrowserRouter as Router, Route, Link, Switch } from 'react-router-dom'
import { submitForm } from './api'
const MultiPageForm = React.createContext()
function MultiPageFormProvider({ initialValues = {}, ...props }) {
const [initState] = React.useState(initialValues)
const [form, setFormValues] = React.useReducer(
(s, a) => ({ ...s, ...a }),
initState,
)
const resetForm = () => setFormValues(initialValues)
return (
<MultiPageForm.Provider
value={{ form, setFormValues, resetForm }}
{...props}
/>
)
}
function useMultiPageForm() {
const context = React.useContext(MultiPageForm)
if (!context) {
throw new Error(
'useMultiPageForm must be used within a MiltiPageFormProvider',
)
}
return context
}
function Main() {
return (
<>
<h1>Welcome home</h1>
<Link to="/page-1">Fill out the form</Link>
</>
)
}
function Page1({ history }) {
const { form, setFormValues } = useMultiPageForm()
return (
<>
<h2>Page 1</h2>
<form
onSubmit={(e) => {
e.preventDefault()
history.push('/page-2')
}}
>
<label htmlFor="food">Favorite Food</label>
<input
id="food"
value={form.food}
onChange={(e) => setFormValues({ food: e.target.value })}
/>
</form>
<Link to="/">Go Home</Link> | <Link to="/page-2">Next</Link>
</>
)
}
function Page2({ history }) {
const { form, setFormValues } = useMultiPageForm()
return (
<>
<h2>Page 2</h2>
<form
onSubmit={(e) => {
e.preventDefault()
history.push('/confirm')
}}
>
<label htmlFor="drink">Favorite Drink</label>
<input
id="drink"
value={form.drink}
onChange={(e) => setFormValues({ drink: e.target.value })}
/>
</form>
<Link to="/page-1">Go Back</Link> | <Link to="/confirm">Review</Link>
</>
)
}
function Confirm({ history }) {
const { form, resetForm } = useMultiPageForm()
function handleConfirmClick() {
submitForm(form).then(
() => {
resetForm()
history.push('/success')
},
(error) => {
history.push('/error', { state: { error } })
},
)
}
return (
<>
<h2>Confirm</h2>
<div>
<strong>Please confirm your choices</strong>
</div>
<div>
<strong id="food-label">Favorite Food</strong>:{' '}
<span aria-labelledby="food-label">{form.food}</span>
</div>
<div>
<strong id="drink-label">Favorite Drink</strong>:{' '}
<span aria-labelledby="drink-label">{form.drink}</span>
</div>
<Link to="/page-2">Go Back</Link> |{' '}
<button onClick={handleConfirmClick}>Confirm</button>
</>
)
}
function Success() {
return (
<>
<h2>Congrats. You did it.</h2>
<div>
<Link to="/">Go home</Link>
</div>
</>
)
}
function Error({
location: {
state: { error },
},
}) {
return (
<>
<div>Oh no. There was an error.</div>
<pre>{error.message}</pre>
<Link to="/">Go Home</Link>
<Link to="/confirm">Try again</Link>
</>
)
}
function App() {
return (
<MultiPageFormProvider initialValues={{ food: '', drink: '' }}>
<Router>
<Switch>
<Route path="/page-1" component={Page1} />
<Route path="/page-2" component={Page2} />
<Route path="/confirm" component={Confirm} />
<Route path="/success" component={Success} />
<Route path="/error" component={Error} />
<Route component={Main} />
</Switch>
</Router>
</MultiPageFormProvider>
)
}
export default App
Test:
import React from 'react' import { render, fireEvent } from '@testing-library/react' import { submitForm as mockSubmitForm } from '../extra/api' import App from '../extra/app' import '@testing-library/jest-dom/extend-expect' jest.mock('../extra/api') test('Can fill out a form across multiple pages', async () => { mockSubmitForm.mockResolvedValueOnce({ success: true }) const testData = { food: 'test food', drink: 'test drink' } const { getByLabelText, getByText, findByText } = render(<App />) // use regex fireEvent.click(getByText(/fill.*form/i)) // pass the data fireEvent.change(getByLabelText(/food/i), { target: { value: testData.food }, }) fireEvent.click(getByText(/next/i)) fireEvent.change(getByLabelText(/drink/i), { target: { value: testData.drink }, }) fireEvent.click(getByText(/review/i)) expect(getByLabelText(/food/i)).toHaveTextContent(testData.food) expect(getByLabelText(/drink/i)).toHaveTextContent(testData.drink) // solve multi confirm text, add selector fireEvent.click(getByText(/confirm/i, { selector: 'button' })) expect(mockSubmitForm).toHaveBeenCalledWith(testData) expect(mockSubmitForm).toHaveBeenCalledTimes(1) // findBy*, using await fireEvent.click(await findByText(/home/i)) expect(getByText(/welcome home/i)).toBeInTheDocument() })
Imporved version: by findBy*
By using some of the get queries, we’re assuming that those elements will be available on the page right when we execute the query. This is a bit of an implementation detail and it’d be cool if we could not make that assumption in our test. Let’s swap all those for find queries.
jest.mock('../extra/api') afterEach(() => { jest.clearAllMocks() }) test('Can fill out a form across multiple pages', async () => { mockSubmitForm.mockResolvedValueOnce({ success: true }) const testData = { food: 'test food', drink: 'test drink' } const { findByLabelText, findByText } = render(<App />) fireEvent.click(await findByText(/fill.*form/i)) fireEvent.change(await findByLabelText(/food/i), { target: { value: testData.food }, }) fireEvent.click(await findByText(/next/i)) fireEvent.change(await findByLabelText(/drink/i), { target: { value: testData.drink }, }) fireEvent.click(await findByText(/review/i)) expect(await findByLabelText(/food/i)).toHaveTextContent(testData.food) expect(await findByLabelText(/drink/i)).toHaveTextContent(testData.drink) fireEvent.click(await findByText(/confirm/i, { selector: 'button' })) expect(mockSubmitForm).toHaveBeenCalledWith(testData) expect(mockSubmitForm).toHaveBeenCalledTimes(1) fireEvent.click(await findByText(/home/i)) expect(await findByText(/welcome home/i)).toBeInTheDocument() })
Improved version: user-event
import user from '@testing-library/user-event' test('Can fill out a form across multiple pages', async () => { mockSubmitForm.mockResolvedValueOnce({ success: true }) const testData = { food: 'test food', drink: 'test drink' } const { findByLabelText, findByText } = render(<App />) user.click(await findByText(/fill.*form/i)) user.type(await findByLabelText(/food/i), testData.food) user.click(await findByText(/next/i)) user.type(await findByLabelText(/drink/i), testData.drink) user.click(await findByText(/review/i)) expect(await findByLabelText(/food/i)).toHaveTextContent(testData.food) expect(await findByLabelText(/drink/i)).toHaveTextContent(testData.drink) user.click(await findByText(/confirm/i, { selector: 'button' })) expect(mockSubmitForm).toHaveBeenCalledWith(testData) expect(mockSubmitForm).toHaveBeenCalledTimes(1) user.click(await findByText(/home/i)) expect(await findByText(/welcome home/i)).toBeInTheDocument() })