How It Should Have Been Tested, Episode II: React.js and its tests

Andrey Lebedev
12 min readDec 23, 2023

In this second part of the #HowItShouldHaveBeenTested series I will touch on the topic of applying TDD with React.js, and give an example of a holistic way of teaching React.js with testing being an integral part of the course and not just a distinct, semi-optional chapter at the end of the list.

As a basis for this demonstration I took a brilliant course called “React.js Essential Training” by Eve Porcello. As I stated in my previous article of How It Should Have Been Tested, I am not criticising the original course, nor committing plagiarism. It is instead to demonstrate how this course could have been presented if TDD had been integrated as a natural and inseparable part of the development process.

In this article, I will cover just one major chapter about the React state, hoping to continue this broad topic of TDD in Front-End in the subsequent episodes of #HowItShouldHaveBeenTested.

Generating a project with Create React App

I will start by generating a project with Create React App. We will generate a new project which we will call react-app-tdd:

npx create-react-app react-app-tdd

Now, if we open the newly created project in Intellij IDEA, we’ll see the following structure:

The first thing to which we pay attention is the file called App.test.js. Let’s have a look at it:

Let’s run this test and ensure that it passes:

Before we continue, let’s first enhance this test’s code slightly by splitting it into logical blocks, following the Given-When-Then notation. This is quite a famous technique from the BDD world, advocated also by Martin Fowler in one of his articles.

test('renders learn react link', () => {
// Given
render(<App />);
// When
const linkElement = screen.getByText(/learn react/i);
// Then
expect(linkElement).toBeInTheDocument();
});

To start, let’s make the page display a single message Hello from React. For that we first modify the test:

import { render, screen } from '@testing-library/react';
import App from './App';
test('renders index page', () => {
// Given
render(<App />);
// When
const textOnThePage = screen.getByText("Hello from React");
// Then
expect(textOnThePage).toBeInTheDocument();
});

Please note: we forego using the case-insensitive construction /hello from react/i, because who said that we don’t care about the case sensivity of the text on the page?

If we run the test it will, as expected, fail:

Unable to find an element with the text: Hello from React. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible.

The next step is to adjust the code in App.js:

The test passes. Now, let’s say, we want the text to be displayed in bold:

const textOnThePage = screen.getByRole("heading", { level: 1, name: "Hello from React" });

Here we specify that we are looking for an element of the “heading” role, of the level “1”, that contains this text precisely.

The test now fails with the error message:

Unable to find an accessible element with the role "heading" and name "Hello from React

To make this test pass, we just need to wrap our text into h1 container:

<h1>Hello from React</h1>

This way through testing we reached the initial state of the example as in the original course, before we can delve into the topic of destructuring.

Destructuring objects

Let’s imagine that we want to customise our component and provide a possibility to pass the greeting actor’s name as a parameter to our App component. Since our test serves as a play ground for this case, we first introduce these changes there:

// Given
render(<App helloFrom={"John Doe"}/>);
const textOnThePage = screen.getByRole("heading", { level: 1, name: "Hello from John Doe" });

It is essential to change the greeting actor from “React” to something else (in our case “John Doe”). If we didn’t do that, our test would still be passing, and that violates the principle of TDD red/green looping. Since our test indeed now fails, we can proceed with changing the main codebase. At the first step the change would look rather simple and silly:

<h1>Hello from John Doe</h1>

No wonder that our test now passes. And this is a good time to introduce the concept of parameterised tests in Jest. We will use the “it.each” construction for that, supplying it with three different strings: “John Doe”, “React” and “John React”. We also wrap our test into the describe function for better structure and readability:

describe("Tests App component", () => {
it.each(["John Doe", "React", "John React"])("Renders hello from %s", (expectedHelloFrom) => {
// Given
render(<App helloFrom={expectedHelloFrom}/>);
// When
const textOnThePage = screen.getByRole("heading", { level: 1, name: "Hello from " + expectedHelloFrom});
// Then
expect(textOnThePage).toBeInTheDocument();
})
});

With this parameterised test we get essentially three different tests, where only one of them passes:

So now it is time to enhance the master code:

Et voila: our beloved test is green again:

Now, since we have a robust test for our component we can refactor our production code and introduce the destructuring technique by replacing App(props) with Apps({helloFrom}):

And our test is still passing, so we are pretty sure that we did our refactoring correctly and in a safe manner.

Understanding the useState hook through testing

First, let’s understand what we aim to achieve. Imagine we want to have a simple UI on a page where clicking different buttons we change the state of an entity. Let’s say we want to define the person’s form of address, to choose between “Mr.” and “Dr.”. Doubtlessly we will start with modifying our test, and the first change will be very trivial:

const textOnThePage = screen.getByRole("heading", { level: 1, name: "Hello from Mr. " + expectedHelloFrom });

After ensuring that this test now fails, we perform this a rather naïve and silly modification of the production code:

<h1>Hello from Mr. {helloFrom}</h1>

At this stage the existing test doesn’t serve our purpose anymore, therefore we should add a new test that is focused on this new functionality:

it("Should change the form of address to Dr.", async () => {
// Given
render(<App helloFrom="John Doe"/>);
const button = screen.getByTestId('change-form-to-dr');
// When
await userEvent.click(button);
// Then
const textOnThePage = screen.getByRole("heading", { level: 1, name: "Hello from Dr. John Doe" });
expect(textOnThePage).toBeInTheDocument();
});

This test accurately describes what behaviour we expect to observe: clicking a button with a testing id “change-form-to-dr” should consequently change the text on the page. Note, that we use userEvent instead of fireEvent. The userEvent provides a better testing experience, because it simulates a real user interaction with the UI. That’s why it is crucial to make our test function async and add an await instruction before userEvent.click().

First run of this test reveals that the button with this id doesn’t exist, so let’s create it:

<button data-testid="change-form-to-dr">Dr.</button>

We can argue whether we should test the button’s caption here, but for the sake of simplicity let’s assume that this is a part of a visual testing (a completely different topic to discuss). Anyway, our test advances now and complains that the new text with the “internal” keyword is not found. It’s time to introduce the useState hook and implement this new functionality:

import './App.css';
import {useState} from "react";
function App({helloFrom}) {
const [formAddress, setFormAddress] = useState("Mr.");
return (
<div className="App">
<h1>Hello from {formAddress} {helloFrom}</h1>
<button data-testid="change-form-to-dr" onClick={() => setFormAddress("Dr.")}>Dr.</button>
</div>
);
}
export default App;

The test is passing now, hurray! To finish this implementation, we should add another similar test, that changes the type of the form of address back to “Mr.”:

it("Should change the form of address to Mr.", async () => {
// Given
render(<App helloFrom="John Doe"/>);
const changeToDrButton = screen.getByTestId('change-form-to-dr');
userEvent.click(changeToDrButton);
const changeToMrButton = screen.getByTestId('change-form-to-mr');
// When
await userEvent.click(changeToMrButton );
// Then
const textOnThePage = screen.getByRole("heading", { level: 1, name: "Hello from Mr. John Doe" });
expect(textOnThePage).toBeInTheDocument();
});

Clearly, the test will fail, so to make it pass we add a new button:

<button data-testid="change-form-to-mr" onClick={() => setFormAddress("Mr.")}>Mr.</button>

By doing so, we ensure that all our tests pass and are marked as green again:

Studying the useEffect hook through testing

Let’s say we want to log certain messages in the console when we change the form of address. Let’s create a test for that. With that, we will learn how to use Jest’s library spyOn technique:

it("Should log message when the form of address is changing", async () => {
// Given
const logSpy = jest.spyOn(console, 'log');
render(<App helloFrom="John Doe"/>);
const changeToDrButton = screen.getByTestId('change-form-to-dr');
// When
await userEvent.click(changeToDrButton );
// Then
expect(logSpy).toHaveBeenCalledWith("John Doe's form of address was changed to Dr.");
});

When trying to run this test we get the following error message:

Error: expect(jest.fn()).toHaveBeenCalledWith(...expected)\
Expected: "John Doe's form of address was changed to Dr."
Number of calls: 0

So let’s add the corresponding code to make this test to pass:

...
const [formAddress, setFormAddress] = useState("Mr.");
useEffect(() => {
console.log(helloFrom + "'s form of address was changed to " + formAddress);
});
...

Our test passes, but it is not complete. Let’s make it is a bit more strict and precise, ensuring that the log is called only once:

expect(logSpy).toBeCalledTimes(1);

And this breaks our test, since we have an error now:

Error: expect(jest.fn()).toBeCalledTimes(expected)
Expected number of calls: 1
Received number of calls: 2

It turns out we didn’t specify the dependency array for the useEffect (and yet, we did not that on purpose, remember: adding only enough code in order to make the test to pass). So let’s fix it now by providing an empty dependency array:

useEffect(() => {
console.log(helloFrom + "'s form of address was changed to " + formAddress);
},[]);

We run our test again and we see a new error now:

Error: expect(jest.fn()).toHaveBeenCalledWith(...expected)
Expected: "John Doe's form of address was changed to Dr."
Received: "John Doe's form of address was changed to Mr."
Number of calls: 1

This forces us to specify precisely the arguments in the dependency array:

useEffect(() => {
console.log(helloFrom + "'s form of address was changed to " + formAddress);
},[formAddress]);

This, however, drops us back to the initial error with the wrong number of calls:

Error: expect(jest.fn()).toBeCalledTimes(expected)
Expected number of calls: 1
Received number of calls: 2

Thus we learn that useEffect is actually called on the very first render of the component and it logs the initial state of the form of address. What can we do about it? We can either adapt our test or introduce a bit more tricky logic, that will start logging only after the first click one of the buttons. But let’s keep it simple for now. Since our initial hypothesis was wrong, we should rollback our changes in the master code and change the test after. So, first:

useEffect(() => {
console.log(helloFrom + "'s form of address was changed to " + formAddress);
});

And then:

    // Given
const logSpy = jest.spyOn(console, 'log');
render(<App helloFrom="John Doe"/>);
expect(logSpy).toBeCalledTimes(1);
expect(logSpy).toHaveBeenCalledWith("John Doe's form of address was changed to Mr.");
const changeToDrButton = screen.getByTestId('change-form-to-dr');
// When
await userEvent.click(changeToDrButton );
// Then
expect(logSpy).toBeCalledTimes(2);
expect(logSpy).toHaveBeenCalledWith("John Doe's form of address was changed to Dr.");

And this test passes! That means the code we put in place for useEffect initially is enough for this particular test.

However, let’s imagine that we want to have more complicated logic and to have another state. Let’s create an interface to change the “Hello” word to “Good-bye” and vice-versa, with “Hello” being the default word:

it("Should change Hello to Good-bye", async () => {
// Given
render(<App helloFrom="John Doe"/>);
const button = screen.getByTestId('change-hello-to-good-bye');
// When
await userEvent.click(button);
// Then
const textOnThePage = screen.getByRole("heading", { level: 1, name: "Good-bye from Mr. John Doe" });
expect(textOnThePage).toBeInTheDocument();
});

The test fails, therefore we consequently add the new button and the new state to make this test to pass:

function App({helloFrom}) {
const [formAddress, setFormAddress] = useState("Mr.");
const [firstWord, setFirstWord] = useState("Hello");
useEffect(() => {
console.log(helloFrom + "'s form of address was changed to " + formAddress);
});
return (
<div className="App">
<h1>{firstWord} from {formAddress} {helloFrom}</h1>
<button data-testid="change-form-to-dr" onClick={() => setFormAddress("Dr.")}>Dr.</button>
<button data-testid="change-form-to-mr" onClick={() => setFormAddress("Mr.")}>Mr.</button>
<br/>
<button data-testid="change-hello-to-good-bye" onClick={() => setFirstWord("Good-bye")}>Good-bye</button>
</div>
);
}

All tests are green, but we want to be more strict and let’s add an additional assertions to our last test function to check that our logging is still working as we expected:

it("Should change Hello to Good-bye", async () => {
// Given
const logSpy = jest.spyOn(console, 'log');
render(<App helloFrom="John Doe"/>);
const button = screen.getByTestId('change-hello-to-good-bye');
// When
await userEvent.click(button);
// Then
const textOnThePage = screen.getByRole("heading", { level: 1, name: "Good-bye from Mr. John Doe" });
expect(textOnThePage).toBeInTheDocument();
expect(logSpy).toBeCalledTimes(1);
expect(logSpy).toHaveBeenCalledWith("John Doe's form of address was changed to Mr.");
});

And these new checks do not pass:

Error: expect(jest.fn()).toBeCalledTimes(expected)
Expected number of calls: 1
Received number of calls: 2

What’s the problem? The problem is again with the useEffect hook, more specifically with its dependency array. And this is the time to make it correct:

useEffect(() => {
console.log(helloFrom + "'s form of address was changed to " + formAddress);
}, [formAddress]);

With that our tests are green again. We could continue with adding another button that changes “Good-bye” back to “Hello”, but we won’t learn anything new here, so we’ll conclude the topic of the useEffect hook.

Discovering the useReducer hook through testing

Let’s now add a checkbox on the page, that will define whether this message has been sent or not:

it("Should have a checkbox about the sending status", () => {
// Given
render(<App helloFrom="John Doe"/>);
// When
const checkbox = screen.getByTestId('sent-status');
const checkboxLabel = screen.getByTestId('sent-status-label');
// Then
expect(checkbox).toBeInTheDocument();
expect(checkboxLabel).toHaveTextContent("not yet sent");
});

Test fails, in order to fix it we add a single line of code:

<input type="checkbox" data-testid="sent-status"/><label data-testid="sent-status-label">not yet sent</label>

Let’s now create a test that verifies the dynamic change of the label when we select the checkbox:

it("Should change the sending status", async () => {
// Given
render(<App helloFrom="John Doe"/>);
const checkbox = screen.getByTestId('sent-status');
// When
await userEvent.click(checkbox);
// Then
const checkboxLabel = screen.getByTestId('sent-status-label');
expect(checkboxLabel).toHaveTextContent("already sent");
});

To make this test pass, we need to introduce a new state:

const [sent, setSent] = useState(false);

and enhance the checkbox and label logic:

<input type="checkbox" data-testid="sent-status" onChange={() => setSent((sent) => !sent)}/>
<label data-testid="sent-status-label">{sent ? "already sent" : "not yet sent"}</label>

The test now passes and we can perform a small refactoring by introducing the useReducer hook:

const [sent, setSent] = useReducer((sent) => !sent, false);

and the onChange function in the checkbox becomes just {setSent}:

<input type="checkbox" data-testid="sent-status" onChange={setSent}/>

Knowing that our tests fully cover the described functionality, we can run them now to ensure that our refactoring hasn’t broken anything:

And it is all good.

Test Code Coverage in React

In the previous chapter I mentioned that we fully cover our functionality with tests. But how can we verify that this is indeed the case? For the Java project in the previous article we used the jacoco library. React.js, and more specifically the Jest library has its own coverage tool. So in order to see the statistics on the test code coverage we just need to run the following command from the root directory of the project:

npm test -- --coverage

The result of this command would be not only running the tests, but also gathering the coverage statistics and displaying it below the logs:

Thus, we can see that we indeed have 100% test code coverage, even though this wasn’t our goal. This is just a normal outcome from following the TDD technique.

By the way, if we want to ensure that we indeed achieved this code coverage with our tests, we can temporarily skip the execution of one of the tests, like that:

it.skip("Should change the sending status", () => {

By adding the “.skip” after the “it” instruction, we can disable the test, without any need to modify further code (like commenting it). If we run the tests with coverage again, we will see that the coverage has reduced indeed:

What’s next?

In the next episodes of the series “How it should have been tested” I am planning to cover wider topics such as development of hooks through testing, mocking API calls, and mocking backend calls as well.

Acknowledgement

I want to thank Grégory Del Frate for his review of this article and for providing highly valuable advice for its improvement.

--

--

Andrey Lebedev

PhD in CS, a Software engineer with more than 20 years of experience. Check my LinkedIn profile for more information: https://www.linkedin.com/in/andremoniy/