React Test-Driven Development.

At the start of my journey in testing React applications, I experienced frustration with the diversity of testing approaches, methods, and technologies utilized. I found myself asking questions such as “Where do I begin?” and “What precisely should I be testing?”. Some React components appeared to be so simple that the necessity of testing them was unclear.

React provides an abundance of tools to help with testing, however, writing the tests can pose a challenge. Fortunately, the Test-driven Development (TDD) approach in React makes testing enjoyable. In this article I will show you how to combine React and TDD by using the Jest testing framework, React testing library and Cypress.

What is TDD?

Test-Driven Development (TDD) is a software development approach in which tests are developed to specify and validate what the code will do. Simply put, tests for each functionality are created and tested first and if the test fails then the new code is written in order to pass the test. This makes code simple and bug-free.

Jest is a JavaScript testing framework that lets developers to run tests on JavaScript and TypeScript code and can be easily integrated with React JS.

React Testing library is a set of helpers that allows developers test React components without relying on their implementation details. 

Cypress is the best framework to run end to end tests. It comes with its own test environment and syntax.

The feature we will build is a simple list of notes.

Let´s build the app.

Create simple app using create-react-app and install the Cypress.

The next step is to create an end-to-end test describing the functionality we want users to be able to do:

  • Enter a note,
  • Save it,
  • And see it in the list.

In the cypress folder, create an e2e folder and inside it create a file creating_a_note.cy.js. Then type the following contents:

describe('Creating a note', () => {
    it('Displays the note in the list', () => {
      cy.visit('http://localhost:3000');
  
      cy.get('[data-testid="noteText"]')
        .type('New note');
  
      cy.get('[data-testid="saveButton"]')
        .click();
  
      cy.get('[data-testid="noteText"]')
        .should('have.value', '');
  
      cy.contains('New note');
    });
  });

The code describes the actions that a user would need to take:

  • Visit the site
  • Enter the text “New Note” into a text field
  • Click a button to save
  • Confirm that the text field is cleared out
  • Verify that the “New Note” appears somewhere on the screen

After creating the test, the next step in TDD is to run the test and watch it fail. This test will initially fail because we have not yet implemented the functionality.

Timed out retrying after 4000ms: Expected to find element: [data-testid="noteText"], but never found it.

The next step in TDD is to write just enough production code to fix the current test failure. 

Let’s create component NewNoteForm,

export default function NewNoteForm() {
  return (
    <input
      type="text"
      data-testid="noteText"
   />
  );}

  and use it in our App component.

import NewNoteForm from "./NewNoteForm";
function App() {
  return (
   <NewNoteForm/>
  );}
export default App;

Now the error we have is :

Timed out retrying after 4000ms: Expected to find element: [data-testid="saveButton"], but never found it.

To fix this error is easy. We just add a <button> to our component. 

export default function NewNoteForm() {
  return (
    <>
      <input type="text" data-testid="noteText" />
      <button data-testid="saveButton">Save</button>
    </>
  );}

Rerun the Cypress test. Now we get a new kind of test failure:

Timed out retrying after 4000ms: expected '<input>' to have value '', but the value was 'New note'

We have reached to our first assertion, which is that the note text box should be empty – but it isn’t. We haven’t yet added the behavior to our app to clear the text box.

Instead of adding the behavior directly, let’s step down from the “outside” level of end-to-end tests to an “inside” component test. 

Create a file src/NewNoteForm.spec.js and add the following:

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import NewNoteForm from "./NewNoteForm";
describe("<NewNoteForm />", () => {
  describe("clicking the save button", () => {
    async function saveNote() {
      render(<NewNoteForm />);

      await userEvent.type(screen.getByTestId("noteText"), "New note");
      userEvent.click(screen.getByTestId("saveButton"));
    }
it("clears the text field", async () => {
      await saveNote();
      expect(screen.getByTestId("noteText").value).toEqual("");
    });
  });
});

Run  component test. We get the same error as we did with the end-to-end test:

end-to-end image react

React Testing Library has a different API than Cypress, but a lot of the test seems the same as the end-to-end test. With RTL Instead of testing the whole app running together, we’re testing just the NewNoteForm by itself.

Now, let’s add the behavior to the component to get this test to pass. We’ll need to make the input a controlled component and its text will be available in the parent component’s state. And next, we want to clear out inputText when the save button is clicked:

import { useState } from "react";
export default function NewNoteForm() {
  const [inputText, setInputText] = useState("");
  function handleTextChange(event) {
    setInputText(event.target.value);
  }
  function handleSave() {
    setInputText("");
  }
  return (
    <>
      <input
        type="text"
        data-testid="noteText"
        value={inputText}
        onChange={handleTextChange}
      />
      <button data-testid="saveButton" onClick={handleSave}>
        Save
      </button>
    </>
  );
}

When you save the file, the component test reruns and passes.

After a component test passes, step back up to the outer end-to-end test to see what the next error is. 

Rerun creating_a_note.cy.js. Now our final assertion fails:

Timed out retrying after 4000ms: Expected to find content: 'New note' but never did.

Finally, the test is going to lead us to implement the actual feature: storing the note entered and displaying it in the list.

Let’s add event handler behavior to the NewNoteForm. Now, the component test won’t be asserting the same thing as the end-to-end test. The end-to-end test is looking for the ‘New note’ content on the screen, but the component test will only be asserting whether the NewNoteForm component calls the event handler.

Add another test case to NewNoteForm.spec.js:

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import NewNoteForm from "./NewNoteForm";

describe("<NewNoteForm />", () => {
  describe("clicking the save button", () => {
    let saveHandler;

    beforeEach(async function saveNote() {
      saveHandler = jest.fn().mockName("saveHandler");
      render(<NewNoteForm onSave={saveHandler} />);

      await userEvent.type(screen.getByTestId("noteText"), "New note");
      userEvent.click(screen.getByTestId("saveButton"));
    });
    it("clears the text field", () => {
      expect(screen.getByTestId("noteText").value).toEqual("");
    });

    it("calls the save handler", async () => {
      expect(saveHandler).toHaveBeenCalledWith("New note");
    });
  });
});

Run the component test again. You’ll see the “clears the text field” test pass, and the “calls the save handler” test fails:

 Let’s fix that:

export default function NewNoteForm({ onSave }) {
  const [inputText, setInputText] = useState("");
  function handleTextChange(event) {
    setInputText(event.target.value);
  }
  function handleSave() {
    onSave(inputText);
    setInputText("");
  }…

And NewNoteForm component:

import NewNoteForm from "./NewNoteForm";
function App() {
  function handleSave() {}
  return <NewNoteForm onSave={handleSave} />;
}
export default App;

Rerun the end-to-end test and we get:

Timed out retrying after 4000ms: Expected to find content: 'New note' but never did.

We no longer get the onSave error. But we still not displaying the note.

Next, we need to save the note in state in the App component.

import { useState } from "react";
import NewNoteForm from "./NewNoteForm";
function App() {
  const [notes, setNotes] = useState([]);
  function handleSave(newNote) {
    setNotes([newNote, ...notes]);
  }
  return <NewNoteForm onSave={handleSave} />;
}
export default App;

Next, to display the notes, let’s create NoteList component.

export default function NoteList({ data }) {
  return (
    <ul>
      {data.map((note, index) => (
        <li key={index}>{note}</li>
      ))}
    </ul>
  );
}

And use it in App component.

import { useState } from "react";
import NewNoteForm from "./NewNoteForm";
import NoteList from "./NoteList";
function App() {
  const [notes, setNotes] = useState([]);
  function handleSave(newNote) {
    setNotes([newNote, ...notes]);
  }
  return (
    <>
      <NewNoteForm onSave={handleSave} />
      <NoteList data={notes} />
    </>
  );}
export default App;

Rerun the tests and they pass. So, that’s how we have developed a basic React app using TDD.

TDD image react

Why to use TDD.

  • Test coverage up to 100%. By writing only the minimum code needed to pass each error, this ensures we do not have any code that is not covered by a test.
  • Ability to refactor. Thanks to full testing coverage, we can modify our code to enhance its design and accommodate future needs.
  • Capacity for rapid delivery. We avoid investing time in developing code that  our users don’t need. When some old code hinders our progress, we can restructure it to increase efficiency. Our tests minimize the amount of manual testing we need to do before a release.

More Resources.

To learn more about TDD:

  • “Mastering React Test-Driven Development” – a book walking through this style of TDD in much more detail.
  • “Test-Driven Development in React”- a free video series of live stream recordings. From 2018 so technical details have changed, but the approach still applies.

React Testing Libraries:

Some interesting blog posts about React testing:

Ekaterina Sleptsova
Software Developer at Polarising