Getting Started with React TDD - Unit Testing

Photo by Jexo on Unsplash

Getting Started with React TDD - Unit Testing

How to start implementing Test Driven Development in your React App

I have previously worked with React, but since I started using it at work, I have needed to learn how to write tests. I have already been writing tests in .NET, so it was quite easy to translate that to the frontend.

In this article, we will be using TDD (Test Driven Development) to build a ToDo app. The article would be way too long if we build the whole app, so I will guide you through the process of implementing a single component. Then I will provide a link to a repo where you can complete the whole project yourself.

Here is a link to the project if you want to view it completed.

Who is this article for?

I have written this article with the assumption that the reader has a good understanding of React already, but doesn't know where to start with testing.

What is TDD?

Test Driven Development is a common practice that focuses on creating unit tests before developing the actual code.

For any particular task or component, you must start with a set of requirements. Whatever the implementation of the component is, it needs to meet those requirements. I would then write tests that assert those requirements. I like to think of those tests as a contract. The implementation will either meet or fail to meet the contract.

At this point, I can very quickly build the component. It doesn't really matter how you implement it, because you will know if the tests pass, it does what was intended.

Why should I use TDD?

  • You will build more modular, clean code
  • You only write the code that is needed, leading to less development time
  • Documentation can easily be inferred from the tests
  • Easier to refactor, especially if working on a shared codebase written by other developers

Defining the requirements of our component

In this article, we will be building out a component that enabled a user to add tasks to the todo list.

image.png

The role of the component is straightforward, capture user input, validate any errors, and then provide that data back to its parent component.

Here are the acceptance criteria (ACs) for this component:

  • User can input a task into the input field, and submit it by pressing return or clicking the add button
  • When the form is submitted, a method parameter addTask will be called with the value as a parameter
  • When the form is submitted, the input field will be cleared
  • If the form is submitted when the test input is empty, display an alert message

Setting up the project

To get started quickly, I suggest cloning the react-tdd-todo repository and using the directory project_no_tests . I strongly suggest you clone the project, but if not, you can follow along by running the following commands.

  1. npx create-react-app project-name
  2. cd project-name
  3. Then either yarn add @testing-library/user-event@13.5.0 or npm install @testing-library/user-event@13.5.0
  4. Create the directory /src/components
  5. Create the directory /src/components/addTaskForm

Next, create the fileaddTaskForm.js

export default function AddTaskForm({}) { 
  return ( 
    <form className="addTask-form"> 
      <input 
        type="text" 
        name="addTaskInput" 
        aria-label="Task description" 
        className="addTask-input" 
        placeholder="Add a task" 
      /> 
      <button type="submit" className="addTask-button"> 
        Add 
      </button> 
    </form> 
  ); 
}

Finally, create the file addTaskForm.test.js

import { render } from "@testing-library/react"; 
import userEvent from "@testing-library/user-event"; 
import AddTaskForm from "./addTaskForm";

describe("AddTaskForm", () => { 
  it("can call the addTask method when the form is submitted", () => { 
    // Arrange 
    // Act 
    // Assert 
  });

  it("can clear the input field when the form has been submitted", () => { 
    // Arrange 
    // Act 
    // Assert 
  });

  it("displays an alert message when the form is submitted with an empty input", () => { 
    // Arrange 
    // Act 
    // Assert 
  })
});

Writing the first test

I have already scaffolded out the test file, so all we need to do is implement the test code.

For the tests, we will be using react-testing-library. If you are not familiar with the testing library, I recommend writing out the steps for each test. Once you have mapped out the logic, it's much easier to implement the code when reading the documentation. Here is a link to view the documentation.

Mapping out the first test

The first test is 'can call the addTask method when the form is submitted'.

Below I have mapped out each step. Since we are testing a UI component, we are essentially writing code to emulate what a human would do manually.

  it("can call the addTask method when the form is submitted", () => { 
    // Arrange 
        // 1. Setup mock variables 
        // 2. Render the component 
    // Act 
        // 3. Find the input field 
        // 4. Enter text into the input field 
        // 5. Find the add task button 
        // 6. Click the button  
    // Assert 
        // 7. Assert that the parameter 'AddTask' was called with the description of the task 
  });

Arrange

For the arrange part of the test, we need to render the component and set up any variables that we want to pass into the component. The variables could be literally anything, but in our case, we are passing the method addTask(). The method will be called in the parent component to add the task to the list.

// 1. Setup mock variables 
const addTaskMock = jest.fn();

const inputText = "some task name";

// 2. Render the component 
const { container } = render( 
    <AddTaskForm 
      addTask={addTaskMock} 
    /> 
  );

Act

For the act part of the test, we need to implement any actions performed against the component. In our case, we need to add some text into the input field, and then click the 'add task' button.

// 3. Find the input field 
const input = container.querySelector('[data-test="add-task-input"]');

// 4. Enter text into the input field 
userEvent.click(input); 
userEvent.keyboard(inputText);

// 5. Find the add task button 
const submitButton = container.querySelector( 
  '[data-test="add-task-button"]' 
);

// 6. Click the button 
userEvent.click(submitButton);

Notice how I have selected each element using data attributes, instead of using a class or id. I have done so because I don't want the test to break if a class or id on one of the elements is changed. Assigning a specific data attribute, or in my case, data-test is not going to get changed. It also indicates to any developer that the attribute is used in tests, and therefore shouldn't be modified.

Assert

Finally, I want to assert that the addTask() method was called.

// 7. Assert that the parameter 'AddTask' was called with the description of the task 
expect(addTaskMock).toHaveBeenCalledWith(inputText);

Commit the changes

Before you go ahead with building your component, you should commit your changes to git. You have written the contract for this feature, so it shouldn't change unless the requirements have changed.

Building the component

Firstly, you should open the test watcher. You can do so by running either yarn test or npm run test.

You should see 4 passing tests, and 1 failing test. Three of the passing tests are tests that we haven't implemented yet. The other is a default test generated when we ran create-react-app@. The failing test is what we have just written.

Adding the data attributes

Firstly, we need to add the data attributes to the elements we're referencing in the tests. Add data-test="add-task-input" to the input, and data-test="add-task-button" to the button.

Controlling the input

We can control the input field using useState.

import { useState } from 'react'
...

export default function AddTaskForm({}) {
  const [taskDescription, setDescription] = useState("");

For the state change to work, add the following attributes to the input field.

return ( 
    <form className="addTask-form" > 
      <input 
        onChange={e => setDescription(e.target.value)} 
        value={taskDescription}
        ... 
      /> 
      ... 
    </form> 
  );

Handling the event

First, we need to add the addTask method as one of the props.

The handleAddTask method will be called when the form is submitted. Notice that I don't check is the taskDescription is empty. In TDD, it is important that you only make the changes that allow the test to pass. If you want to add a new feature, you should complete the current changes, and add a new test later.

export default function AddTaskForm({ addTask }) { 

  ...

  const handleAddTask = e => { 
    e.preventDefault(); 
    addTask(taskDescription) 
  }

Then we must add the method to the form onSubmit attribute.

<form className="addTask-form" onSubmit={handleAddTask}>

The completed component

For those lazy people, here is the file addTaskForm.js with all the required changes.

import { useState } from 'react'

export default function AddTaskForm({ addTask }) {

  const [taskDescription, setDescription] = useState("");

  const handleAddTask = e => { 
    e.preventDefault(); 
    addTask(taskDescription) 
  }

  return ( 
    <form className="addTask-form" onSubmit={handleAddTask}> 
      <input 
        type="text" 
        name="addTaskInput" 
        aria-label="Task description" 
        className="addTask-input" 
        placeholder="Add a task" 
        data-test="add-task-input" 
        onChange={e => setDescription(e.target.value)} 
        value={taskDescription} 
      /> 
      <button 
        type="submit" 
        className="addTask-button" 
        data-test="add-task-button" 
      > 
        Add 
      </button> 
    </form> 
  ); 
}

The test should now be passing. Go ahead and commit your changes.

Writing the next test

The next test is 'can clear the input field when the form has been submitted'.

The test code will be almost exactly the same as the previous test, but we will need to assert that the input field has been cleared. We also don't need to check what parameters were passed into addTask(), we only care that it was called.

You can copy the test code in the Arrange and Act sections.

  it("can clear the input field when the form has been submitted", () => { 
    // Arrange

        ...

    // Assert
        // 7. Assert that the parameter 'AddTask' was called 
        expect(addTaskMock).toHaveBeenCalled(); 

        // 8. Assert that the input field is empty 
        expect(input.value).toEqual("");

  });

If you run the test, it should be failing. I get the following error message in the console.

  ● AddTaskForm › can clear the input field when the form has been submitted 
    expect(received).toEqual(expected) // deep equality 
    Expected: "" 
    Received: "some task name" 
      73 | 
      74 |     // 8. Assert that the input field is empty 
    > 75 |     expect(input.value).toEqual(""); 
         |                         ^ 
      76 |   }); 
      77 | 
      78 |   it("displays an alert message when the form is submitted with an empty input", () => { 
      at Object.<anonymous> (src/components/addTaskForm/addTaskForm.test.js:75:25)

You can see that the test fails because the input still contains what we originally typed. This is correct, so let's commit the changes.

Resolving the test errors

To get the tests to pass, we only need to add one line to the handleAddTask() method. After the task has been added, we need to set the description to an empty string.

  const handleAddTask = (e) => { 
    ...
    setDescription(""); 
  };

The test should now pass, and you can save the changes.

Writing the final test

The final test is 'displays an alert message when the form is submitted with an empty input'. In this test, we will need to click the add button without adding text to the input field. Then we need to assert that the alert window is displayed and that the addTask() parameter is never called.

Arrange

This time, we don't need to set up the input text. But we do need to add a Jest Spy to watch the window.alert method.

// 1. Setup mock variables 
const addTaskMock = jest.fn();

// 2. Setup a jest spy to watch window.alert  
const alertMock = jest.spyOn(window, "alert");

// 3. Render the component 
const { container } = render(<AddTaskForm addTask={addTaskMock} />);

Act

In the act section, we don't need to add any text to the input. Instead, we only need to submit the form.

// 4. Find the add task button 
const submitButton = container.querySelector( 
  '[data-test="add-task-button"]' 
);

// 5. Click the button 
userEvent.click(submitButton);

Assert

In the assert section, we can use the alertMock spy to assert that window.alert has been called. Then, we can assert that the addTask() parameter was not called.

// 6. Assert that window.alert was called
expect(alertMock).toHaveBeenCalled();

// 7. Assert that the parameter 'AddTask' was not called
expect(addTaskMock).toHaveBeenCalledTimes(0);

The completed component

Again, for those lazy people, here is the whole test. Go ahead and commit those test changes.

  it("displays an alert message when the form is submitted with an empty input", () => { 
    // Arrange

        // 1. Setup mock variables 
        const addTaskMock = jest.fn();

        // 2. Setup a jest spy to watch window.alert  
        const alertMock = jest.spyOn(window, "alert"); 

        // 3. Render the component 
        const { container } = render(<AddTaskForm addTask={addTaskMock} />);

    // Act

        // 4. Find the add task button 
        const submitButton = container.querySelector( 
          '[data-test="add-task-button"]' 
        );

        // 5. Click the button 
        userEvent.click(submitButton);

    // Assert

        // 6. Assert that window.alert was called 
        expect(alertMock).toHaveBeenCalled();

        // 7. Assert that the parameter 'AddTask' was not called 
        expect(addTaskMock).toHaveBeenCalledTimes(0);

  });

Passing the test

To pass the test, we simply need to modify the handleAddTask method to check if the taskDescription is empty. If it is empty, display an alert message and return.

Add these changes, and the test should now pass.

  const handleAddTask = (e) => { 
    e.preventDefault(); 
    if (taskDescription === "") { 
      window.alert("You cannot create a task with an empty description") 
      return 
    } 
    addTask(taskDescription); 
    setDescription(""); 
  };

Next steps

I have just run you through the whole process of developing a single component/feature with TDD. I would have gone through the whole todo app, but that would have made this article too long.

If you haven't downloaded the repo yet, you can download it here. In the directory project_no_tests, I have set up all of the base files. I would recommend that you implement the remaining component tests. If you get stuck, you can refer to the project_with_unit_tests directory.

You should now have a decent understanding of how to implement TDD in React. Your next steps should be to build your project and use what you have learned. In my experience, it is when I get stuck that I make the most progress.

This has been my first blog post. Please let me know if you have any feedback or suggestions.