- Published on
Unit Testing Modal Components
- Authors
- Name
- Dan Orlando
- @danorlando1
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:
- I added class names as if we were using CSS modules here to help make the structure of the modal clear and to demonstrate an important point about our continue button. In the event that the user clicks the Continue button without accepted the ToS and privacy policy, I want to show a message explaining that they must accept to continue, which is seen at the bottom of the component. However, we cannot actually disable the button until the boxes are checked or the button will not register the Click event, which we need to happen in order to show this message. Instead, we have a style class to make it look like the button is disabled, and our conditional styling logic will switch to the enabled style class when the
termsAccepted
andpolicyAccepted
values are true.
Testing the Parent Container
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:
- I always use
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. - You may be wondering why we have both
getByTestId
andqueryByTestId
.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 needqueryByTestId
. - You may also be wondering why we have
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 withdata-testid
, but as I said before, we prefer to usegetByRole
to maintain our accessibility checks whenever possible.
Testing the Modal
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:
- I want to make sure the modal contains the two checkboxes and the continue button because the modal's purpose and functionality depend on these elements.
- I want to know that the
toggleTermsModal
function is called when the continue button is clicked. - That the relevant message is displayed if the continue button is clicked before the terms of service and privacy policy have been accepted.
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.