Achieving Clean and Maintainable React Component Tests
A Practical Approach with Jest and TypeScript
For the last year or so on my team, we’ve been strongly enforcing writing unit tests for everything in our React applications. For big new features or minor bug fixes, part of our definition of done is to have the corresponding unit tests.
Having easy-to-write, maintainable, and readable tests has been key for us, and we’ve been delivering more quality and more trustable code.
In this post, I want to share a way of writing tests for React components that helps achieve all this, with a practical example.
The Component
Let’s pretend we have to write tests for the following React component: <ConfirmDialog />
. This simple component shows a message in which the user can confirm or cancel an action. When rendered, it looks something like this:
Below is the actual code; note that we have test ids for each element for this exercise, so we’ll use that.
export const ConfirmModal: FC<ConfirmModalProps> = ({
title,
text,
onConfirm,
onCancel,
confirmButtonText = "Confirm",
}) => (
<div data-testid="Modal">
<h1 data-testid="Modal.Title">{title}</h1>
{!!text && <div data-testid="Modal.Body">{text}</div>}
<div>
<button data-testid="Modal.Buttons.Cancel" onClick={onCancel}>
Cancel
</button>
<button data-testid="Modal.Buttons.Confirm" onClick={onConfirm}>
{confirmButtonText}
</button>
</div>
</div>
);
To make it more interesting, it Modal.Body
is only rendered if text
passed.
The tests
After some thought, we decide that we are going to test that:
- The modal renders the correct title
- The modal renders a body with the text if it’s passed
- The modal doesn’t have a body if no text is passed
- The confirm button shows
Confirm
by default, if nothing is passed - The confirm button shows a specific text if passed
- The cancel button calls the
onCancel
function prop when clicked - The confirm button calls the
onConfirm
function prop when clicked
Our team uses React Testing Library, which we will use.
Round one
After some coding (and copy-pasting 😉), we have our tests ready, and they look like this:
describe("<ConfirmModal />", () => {
test("1. The modal renders the correct title", () => {
render(
<ConfirmModal
title="Some title"
onConfirm={jest.fn()}
onCancel={jest.fn()}
/>
);
expect(screen.getByTestId("Modal.Title")).toHaveTextContent("Some title");
});
test("2. The modal renders a body with the text if passed", () => {
render(
<ConfirmModal
title="Some title"
text="Are you sure?"
onConfirm={jest.fn()}
onCancel={jest.fn()}
/>
);
expect(screen.getByTestId("Modal.Body")).not.toBeNull();
expect(screen.getByTestId("Modal.Body")).toHaveTextContent("Are you sure?");
});
test("3. The modal _doesn't_ have a body if no text is passed", () => {
render(
<ConfirmModal
title="Some title"
onConfirm={jest.fn()}
onCancel={jest.fn()}
/>
);
expect(screen.queryByTestId("Modal.Body")).toBeNull();
});
test("4. The confirm button shows `Confirm` if nothing is passed", () => {
render(
<ConfirmModal
title="Some title"
onConfirm={jest.fn()}
onCancel={jest.fn()}
/>
);
expect(screen.getByTestId("Modal.Buttons.Confirm")).toHaveTextContent(
"Confirm"
);
});
test("5. The confirm button shows a specific text if passed", () => {
render(
<ConfirmModal
title="Some title"
confirmButtonText="Ok!"
onConfirm={jest.fn()}
onCancel={jest.fn()}
/>
);
expect(screen.getByTestId("Modal.Buttons.Confirm")).toHaveTextContent(
"Ok!"
);
});
test("6. The cancel button calls onCancel when clicked", () => {
const onCancel = jest.fn();
render(
<ConfirmModal
title="Some title"
onConfirm={jest.fn()}
onCancel={onCancel}
/>
);
screen.getByTestId("Modal.Buttons.Cancel").click();
expect(onCancel).toHaveBeenCalled();
});
test("7. The confirm button calls onConfirm when clicked", () => {
const onConfirm = jest.fn();
render(
<ConfirmModal
title="Some title"
onConfirm={onConfirm}
onCancel={jest.fn()}
/>
);
screen.getByTestId("Modal.Buttons.Confirm").click();
expect(onConfirm).toHaveBeenCalled();
});
});
On these tests, we are:
- Getting the elements by using the
data-testid
property. Note the intentional use ofgetByTestId
versusqueryByTestId
. When usinggetBy*
, we are assuming that the element is always rendered; otherwise, an exception is thrown, and the tests fail. On the other hand, when usingqueryBy*
we know whether the element could be rendered or not. If it’s not, it will returnnull
and we can test appropriately (you can read more here in About Queries) - Using Jest mocks to mock the function props
And…
Feels good!
What if the component changes and the onConfirm
prop is renamed to onAccept
? We would need to change that on every test. Or maybe a new unrelated required prop is added, and we will miss it (or, luckily, our TypeScript check will complain).
Round two
Let’s create a component that renders <ConfirmDialog />
with defaults for all props. This component lives only on our test file.
Our objective is to:
- Extract the rendering code to avoid repeating code (DRY!)
- Allow the user to override props and keep it type safe
- Improve the readability of our tests by letting the user focus on what the test is about without any additional boilerplate
This component looks like this:
const ConfirmModalDefault = (
props: Partial<ComponentProps<typeof ConfirmModal>>
) => (
<ConfirmModal
title="Some title"
onConfirm={jest.fn()}
onCancel={jest.fn()}
{...props}
/>
);
By default, we will always have the necessary props, and they can be overridden.
By typing the props
object, we ensure that the test <ConfirmModalDefault />
will only pass valid props, but how does it work?
ComponentProps
is a utility type provided by React that gives you the type of theprops
object of a given component. You use it by passing it the type of your component:ComponentProps<typeof YourComponent>
Partial
is a TypeScript utility that returns the type of an object but in which every key is optional.- By combining both, we have a
props
object that can have any valid<ConfirmModal />
prop, but none required
Let’s see what our tests become when they use this component:
describe("<ConfirmModal />", () => {
test("1. The modal renders the correct title", () => {
render(<ConfirmModalDefault />);
expect(screen.getByTestId("Modal.Title")).toHaveTextContent("Some title");
});
test("2. The modal renders a body with the text if passed", () => {
render(<ConfirmModalDefault text="Are you sure?" />);
expect(screen.getByTestId("Modal.Body")).not.toBeNull();
expect(screen.getByTestId("Modal.Body")).toHaveTextContent("Are you sure?");
});
test("3. The modal doesn't have a body if no text is passed", () => {
render(<ConfirmModalDefault />);
expect(screen.queryByTestId("Modal.Body")).toBeNull();
});
test("4. The confirm button shows `Confirm` if nothing is passed", () => {
render(<ConfirmModalDefault />);
expect(screen.getByTestId("Modal.Buttons.Confirm")).toHaveTextContent(
"Confirm"
);
});
test("5. The confirm button shows a specific text if passed", () => {
render(<ConfirmModalDefault confirmButtonText="Ok!" />);
expect(screen.getByTestId("Modal.Buttons.Confirm")).toHaveTextContent(
"Ok!"
);
});
test("6. The cancel button calls onCancel when clicked", () => {
const onCancel = jest.fn();
render(<ConfirmModalDefault onCancel={onCancel} />);
screen.getByTestId("Modal.Buttons.Cancel").click();
expect(onCancel).toHaveBeenCalled();
});
test("7. The confirm button calls onConfirm when clicked", () => {
const onConfirm = jest.fn();
render(<ConfirmModalDefault onConfirm={onConfirm} />);
screen.getByTestId("Modal.Buttons.Confirm").click();
expect(onConfirm).toHaveBeenCalled();
});
});
Excellent!
Looks way cleaner, but tests are very coupled with the testing library and how the component renders. This hurts maintainability (if the test id of the confirm button changes, we have to change it in more than one test) and readability (the testing library specifics).
Final round
Let’s create a dialog object in our test file. The responsibility of this object is: to know how to access each UI piece that we care about while sparing us its details.
const dialog = {
get title() {
return screen.getByTestId("Modal.Title");
},
get body() {
return screen.queryByTestId("Modal.Body");
},
buttons: {
get confirm() {
return screen.getByTestId("Modal.Buttons.Confirm");
},
get cancel() {
return screen.getByTestId("Modal.Buttons.Cancel");
},
},
};
Some considerations:
- Note that we grouped both buttons in the
buttons
property. This simple example may be overdoing it, but imagine if you are testing something more complex. By grouping parts of the UI, this helps readability and maintainability a lot! - Why do we use getters here? To make life easier for the test writer, everything is a property (versus being a function):
dialog.title
dialog.buttons.confirm - Additionally, you could type the response of any getter to give the test writer an understanding of what the element is:
return screen.queryByTestId<HTMLButtonElement>("someId")
And this is what our final tests look like!
describe("<ConfirmModal />", () => {
test("1. The modal renders the correct title", () => {
render(<ConfirmModalDefault />);
expect(dialog.title).toHaveTextContent("Some title");
});
test("2. The modal renders a body with the text if passed", () => {
render(<ConfirmModalDefault text="Are you sure?" />);
expect(dialog.body).not.toBeNull();
expect(dialog.body).toHaveTextContent("Are you sure?");
});
test("3. The modal doesn't have a body if no text is passed", () => {
render(<ConfirmModalDefault />);
expect(dialog.body).toBeNull();
});
test("4. The confirm button shows `Confirm` if nothing is passed", () => {
render(<ConfirmModalDefault />);
expect(dialog.buttons.confirm).toHaveTextContent("Confirm");
});
test("5. The confirm button shows a specific text if passed", () => {
render(<ConfirmModalDefault confirmButtonText="Ok!" />);
expect(dialog.buttons.confirm).toHaveTextContent("Ok!");
});
test("6. The cancel button calls onCancel when clicked", () => {
const onCancel = jest.fn();
render(<ConfirmModalDefault onCancel={onCancel} />);
dialog.buttons.cancel.click();
expect(onCancel).toHaveBeenCalled();
});
test("7. The confirm button calls onConfirm when clicked", () => {
const onConfirm = jest.fn();
render(<ConfirmModalDefault onConfirm={onConfirm} />);
dialog.buttons.confirm.click();
expect(onConfirm).toHaveBeenCalled();
});
});
Recap
To ensure readability and maintainability, we:
- Created a wrapper component that
- Handles all default props while allowing overrides (and don’t repeat ourselves)
- Is type safe
2. Created a UI object (”dialog”) that
- Encapsulates the way we access the DOM in our test
- Groups DOM elements in a clear and readable way
- Is consistent to use in all our tests
Thanks for reading, and I hope this inspires you to make better (and nice to maintain) tests!