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.
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() })
[React Testing] Write React Application Integration Tests with React Testing Library