I’ve been writing tests for modal components lately and decided to blog about some of the things that are good to know when testing modal components as well as some of the “gotchas”.
Testing of a modal component happens in two separate test suites: the parent container that holds the modal declaration, and the test suite for the modal itself. Within the test suite for the parent container, there needs to be at a minimum two tests: that the modal renders when it is supposed to, and that it is removed from the DOM when closed. If the modal has behavior that interacts with other components on the page however, this is where you might include those tests as well. The modal’s suite will test all of the functionality that happens within the component. In this post we’ll take a look at some examples of what these tests look like as well as some tips and tricks and good things to know about testing in general.
As a first example, we’ll use something that is relevant for just about any application that collects data. In this example, we have a registration form where the user will enter sensitive personal information, so we want to make sure they have read and agreed to our terms of service and privacy policy in order to fulfill our legal obligations before we collect their data. In our first modal, we’ll have two checkboxes with labels that correspond to accepting our ToS and privacy policy, along with a continue button that is disabled until the criteria is met and the boxes are checked.
Here’s what our modal component looks like:
import React, { useState } from 'react' import styles from './styles' const AcceptTermsModal = props => { const [termsAccepted, setTermsAccepted] = useState(false) const [policyAccepted, setPolicyAccepted] = useState(false) const [showAcceptMessage, setShowAcceptMessage] = useState(false) const handleClick = () => { if (!termsAccepted || !policyAccepted) { setShowAcceptMessage(true) } else props.onContinue() } return ( <div className={styles.popupMask}> <div className={styles.popupContentHelper} data-testid='acceptTermsModalContent' > <h1>Before Continuing...</h1> <p> Please review and accept the Terms of Service and Privacy Policy before submitting your personal information. </p> <div className={styles.formRow}> <input type='checkbox' id='acceptTermsCheck' name='acceptTermsCheck' checked={termsAccepted} onChange={() => setTermsAccepted(!termsAccepted)} /> <label htmlFor='acceptTermsCheck'> I agree to the <a target='_blank' href='/tos.html'> Terms of Service </a> </label> </div> <div className={styles.formRow}> <input type='checkbox' id='acceptPrivacyCheck' name='acceptPrivacyCheck' checked={policyAccepted} onChange={() => setPolicyAccepted(!policyAccepted)} /> <label className={styles.checkBoxLabel} htmlFor='acceptPrivacyCheck'> I agree to the <a target='_blank' href='/privacy.html'> Privacy Policy </a> </label> </div> <button className={ !termsAccepted || !policyAccepted ? styles.buttonDisabled : styles.buttonEnabled } onClick={handleClick} > Continue </button> {showAcceptMessage && ( <p role='alert' className={styles.errorText}> You must accept the Terms of Service and Privacy Policy to continue. </p> )} </div> </div> ) } export default AcceptTermsModal
A couple of things worth noting about the modal component:
termsAccepted
and policyAccepted
values are true.The best place to start is with the parent container as we only have two relatively easy tests to write here. For this scenario, we want the modal to display the moment the page loads, so we’re going to blur the background with the registration form and display the modal front and center.
test('It renders the modal when the page loads', async () => { const { getByTestId } = render(<RegistrationPage />) await expect(getByTestId('modalContent')).toBeInTheDocument() })
This tests is very simple. It just renders the registration page and checks to make sure that the div with data-testid='modalContent'
exists in the DOM when the page renders.
The second test is a little more interesting. Here we want to test that the modal is removed from the DOM when it is closed. However, we must meet the criteria necessary in order to close it, which means we have to check the boxes first and click the continue button.
test('Removes the accept ToS and PP modal from the DOM when the user has accepted', async () => { const { queryByTestId, getByTestId, getByRole } = setup() const modal = getByTestId('acceptTermsModalContent') await act(async () => { userEvent.click(getByRole('checkbox', { name: /Terms of Service/i })) userEvent.click(getByRole('checkbox', { name: /Privacy Policy/i })) }) expect(getByRole('checkbox', { name: /Terms of Service/i })).toBeChecked() expect(getByRole('checkbox', { name: /Privacy Policy/i })).toBeChecked() userEvent.click(within(modal).getByRole('button', { name: /Continue/i })) await waitFor(() => { expect(queryByTestId('acceptTermsModalContent')).not.toBeInTheDocument() }) })
Some important things to note about this test:
getByRole
as my preferred selector whenever possible to check accessibility. This way we can make sure the element is accessible as a side effect of our choice in selector. Note that we do not need to include the entire label for the name regexp pattern.getByTestId
and queryByTestId
. getByTestId
requires that the element is in the DOM for it to be selected. In other words, it can’t be used to see if a node is not in the document. For that we need queryByTestId
.within(modal)
on the click event for the continue button. Sometimes when dealing with modals you may have another component with the same selection criteria in the DOM. In this case, we have another Continue button on our registration form behind the modal, so we want to be sure that the right button is getting clicked. The first instinct might be to separate the buttons with data-testid
, but as I said before, we prefer to use getByRole
to maintain our accessibility checks whenever possible.With the tests for the creation and removal of the modal in the parent container complete, we’re ready to write the tests for the modal itself. I want three tests for this modal, as follows:
toggleTermsModal
function is called when the continue button is clicked.You will notice that we are only testing UI behavior. This is because we only care about how the UI functions, not about the implementation details. If the toggleTermsModal
function gets called, for example, we can safely assume that the termsAccepted and policyAccepted states are being properly set when the checkboxes are checked.
With that, let’s take a look at the first test:
import React from 'react' import { screen, render, act, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { AcceptTermsModal } from 'components' const toggleTermsModal = jest.fn() test('Renders the accept ToS and PP checkboxes and continue button', () => { const { getByRole } = render( <AcceptTermsModal onContinue={toggleTermsModal} /> ) expect( getByRole('checkbox', { name: /Terms of Service/i }) ).toBeInTheDocument() expect(getByRole('checkbox', { name: /Privacy Policy/i })).toBeInTheDocument() expect(getByRole('button', { name: /Continue/i })).toBeInTheDocument() })
This one is pretty straight-forward. All we’re doing is rendering the modal and running a few assertions to make sure that the critical elements to the modal’s functionality are added to the DOM. Again, we’re using getByRole
as our preferred query selector as this gives us the advantage of also ensuring that the elements are accessible to screen readers. One might argue that this test is probably unneccessary since the next test is technically already testing if the elements are rendered, but I always include this as my first test for good measure.
test('Calls the toggleTermsModal function', async () => { const { container, getByRole } = render( <AcceptTermsModal onContinue={toggleTermsModal} /> ) await act(async () => { userEvent.click(getByRole('checkbox', { name: /Terms of Service/i })) userEvent.click(getByRole('checkbox', { name: /Privacy Policy/i })) }) expect(getByRole('checkbox', { name: /Terms of Service/i })).toBeChecked() expect(getByRole('checkbox', { name: /Privacy Policy/i })).toBeChecked() userEvent.click(getByRole('button', { name: /Continue/i })) await waitFor(() => { expect(toggleTermsModal).toHaveBeenCalledTimes(1) }) expect(container).toMatchSnapshot() })
This primary purpose of this test is to make sure that the toggleTermsModal
function is called. We’re also grabbing a snapshot of the component in this test. If at any point the UI changes unexpectedly, such as the header or desription text for example, the snapshot will fail. You may have also noticed that we’re running the toBeChecked
assertions on the checkboxes again. “We did that in the parent component tests though,” you may be thinking. I don’t consider this duplication because it is in a different test suite and it comes to testing, I always prefer to err on the side of caution.
test('Displays the error message if the button is clicked without accepting the ToS and PP', async () => { const { getByRole } = render( <AcceptTermsModal onContinue={toggleTermsModal} /> ) await act(async () => { userEvent.click(getByRole('button', { name: /Continue/i })) }) expect(await screen.findAllByRole('alert')).toHaveLength(1) expect(toggleTermsModal).not.toBeCalled() expect(getByRole('alert')).toHaveTextContent( /You must accept the Terms of Service and Privacy Policy to continue./i ) })
In this third test, we’re checking that the message is displayed if the button is clicked before the user has accepted both the ToS and the privacy policy. Notice that I use screen.findAllByRole('alert')
to check that the message is displayed and that it is the only element on the page with the alert role. This, as well as the getByRole('alert')
used in the last assertion, validates the accessibility of the message by ensuring that I have properly marked the paragraph as an alert to screen readers. We’re also checking that the toggleTermsModal
did not get called when the button was clicked and that the message has the approved text content.
Testing can be a little tricky until you get the hang of it, but I’ve found that it helps to look at the different ways people write their tests and understand the reasoning behind the choices they make. You’ll start to see patterns emerge and build your own testing style from it, and keep in mind that it helps to have in-depth knowledge of what’s available in the testing framework.