Achieving Clean and Maintainable React Component Tests

A Practical Approach with Jest and TypeScript

Tato Patrón
Globant
6 min readJul 31, 2023

--

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:

  1. The modal renders the correct title
  2. The modal renders a body with the text if it’s passed
  3. The modal doesn’t have a body if no text is passed
  4. The confirm button shows Confirm by default, if nothing is passed
  5. The confirm button shows a specific text if passed
  6. The cancel button calls the onCancel function prop when clicked
  7. 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 of getByTestId versus queryByTestId. When using getBy*, we are assuming that the element is always rendered; otherwise, an exception is thrown, and the tests fail. On the other hand, when using queryBy* we know whether the element could be rendered or not. If it’s not, it will return null 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 the props 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:

  1. 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!

--

--