.ts
/>
.tsx
.tsx
.tsx
/>
<>
.ts
<>
<>
.tsx
<>
<>
.ts
/>
/>
.ts
/>
.tsx
<>
.ts
.ts
.ts
.tsx
<>
<>
<>
.ts
.ts
.tsx
.ts
<>
<>
.ts
/>
.ts
<>
<>
<>
.ts
<>
<>
.tsx
<>
<>
.ts
.ts
/>
/>
.ts

Test-Driven Development (TDD) with React

In this article, we will learn how to apply TDD methodology in a simple To Do React application. We will initialize a new project using Vite, and then we will jump into the process of creating the To Do application following TDD methodology.

What is TDD?

TDD is a software development methodology where tests are written before the actual production code. The process of TDD is divided into three steps: "Red", "Green", and "Refactor".

The developer starts by writing a test that will fail, which is the "Red" step. Then, the developer writes the minimum amount of production code to pass the test, which is the "Green" step. Finally, the developer refactors the code to make it clean and maintainable, which is the "Refactor" step.

Adopting TDD in a project ensures that the code is well-tested from the start, leading to a more reliable, maintainable, and bug-free codebase. It also promotes a better understanding of the requirements and the design of the application.

If you want to read more about TDD, I recommend reading resources such as testdriven.io's article, or if you want to dive much deeper, then I recommend the book Test Driven Development: By Example by Kent Beck, who invented TDD.

Initializing the project

We will initialize the project with Vite, a build tool that aims to provide a faster and leaner development experience for modern web projects.

yarn create vite tdd-with-react

Follow the instructions to finish the project initialization. Once the project is initialized, we can install some dependencies that we will use for testing.

yarn add -D vitest @testing-library/react @testing-library/jest-dom

We can now remove the boilerplate code generated by Vite, I have only left the App.tsx file, where the To Do application will be implemented.

function App() {
  return (
    <div>
      <h1>To Do List</h1>
    </div>
  );
}

export default function App;

We will also need to update our testing configuration, so that certain testing API's are available to us. For this, we will create a setup.ts file inside the src/test folder:

import "@testing-library/jest-dom";

Then we will update our Vite config file (vite.config.ts):

/// <reference types="vitest" />
/// <reference types="vite/client" />

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: "./src/test/setup.ts",
  },
});

Finally, we will extend the scripts section of the package.json file to include a new script to run the tests:

{
  "scripts": {
    // ...
    "test": "vitest"
  }
}

Now we should have everything set up to start writing tests.

What do we want to build?

We will create a simple To Do application with the following features:

  • an input field and a button to add a new to do item
  • a list of to do items that also updates when a new item is added
  • a button to remove a to do item
  • the input field should be cleared after adding a new item

Here's a mockup of the application:

To Do List app UI mockup

What do we need to test?

We want to test all the features of the To Do application. We will start by testing the input field and the button to add a new to do item. Then, we will test the list of to do items and the button to remove a to do item.

Implementing the first feature

First, let's create a new file called App.test.tsx in a new folder called __tests__ inside the src folder. This is where we will write our tests.

import { describe, expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import App from "../App";

describe("To Do List", () => {
  test("renders the input field and the button to add a new to do item", () => {
    render(<App />);

    const input = screen.getByPlaceholderText("Add a new to do item");
    const button = screen.getByRole("button", { name: "Add" });

    expect(input).toBeInTheDocument();
    expect(button).toBeInTheDocument();
  });
});

In this test, we are rendering the App component and then checking if the input field and the button to add a new to do item are present in the document. Since we haven't implemented these features yet, the test fails as expected:

TestingLibraryElementError: Unable to find an element with the placeholder text of: Add a new to do item

Ignored nodes: comments, script, style
<body>
  <div>
    <h1>
      To Do List
    </h1>
  </div>
</body>

Now we can implement the features to make the test pass. We will start by adding the input field and the button to add a new to do item in the App component:

function App() {
  return (
    <div>
      <h1>To Do List</h1>
      <input type="text" placeholder="Add a new to do item" />
      <button>Add</button>
    </div>
  );
}

export default App;

Now the test is passing. Using vitest you don't even need to re-run the tests, as it is watching for changes and will re-run the tests automatically. This is a great feature that helps to keep the feedback loop short.

Implementing the remaining features

We will continue by implementing the remaining features of the To Do application. We will start by testing the list of to do items and the button to remove a to do item.

// App.test.tsx

describe("To Do List", () => {
  // ...

  test("renders the list of default to do items and the button to remove a to do item", () => {
    render(<App />);

    const listItem = screen.getByText("Walk the dog");
    const removeButton = screen.getByRole("button", { name: "X" });

    expect(listItem).toBeInTheDocument();
    expect(removeButton).toBeInTheDocument();
  });
});

This test is failing because we haven't implemented the list of to do items and the button to remove a to do item yet. Let's implement these features in the App component:

import { useState } from "react";

function App() {
  const [toDoItems, setToDoItems] = useState<string[]>(["Walk the dog"]);

  return (
    <div>
      <h1>To Do List</h1>
      <input type="text" placeholder="Add a new to do item" />
      <button>Add</button>
      <ul>
        {toDoItems.map((toDoItem, index) => (
          <div key={`${toDoItem}-${index}`}>
            <li>{toDoItem}</li>
            <button>X</button>
          </div>
        ))}
      </ul>
    </div>
  );
}

The test is now passing. So far, we have only added default items to the list. Let's add some interactivity to the application by allowing the user to add new items and remove existing items. But first, let's write the tests for these features.

// App.test.tsx
import { describe, expect, test } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import App from "../App";

describe("To Do List", () => {
  // ...

  test("adds a new to do item to the list", () => {
    render(<App />);

    const input = screen.getByPlaceholderText("Add a new to do item");
    const button = screen.getByRole("button", { name: "Add" });

    act(() => {
      fireEvent.input(input, { target: { value: "Check Chris's blog" } });
      fireEvent.click(button);
    });

    const listItem = screen.getByText("Check Chris's blog");

    expect(listItem).toBeInTheDocument();
  });
});

This test is failing because we haven't implemented the feature to add a new to do item yet. Let's implement this feature in the App component by adding a state to store the input value and a function to handle the input change and the addition of a new item.

function App() {
  const [toDoItems, setToDoItems] = useState<string[]>(["Walk the dog"]);
  const [inputValue, setInputValue] = useState<string>("");

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInputValue(e.target.value);
  };

  const handleAddItem = (e: React.MouseEvent<HTMLButtonElement>) => {
    e.preventDefault();

    if (inputValue) {
      setToDoItems([...toDoItems, inputValue]);
    }
  };

  return (
    <div>
      <h1>To Do List</h1>
      <input
        onChange={handleInputChange}
        placeholder="Add a new to do item"
        type="text"
        value={inputValue}
      />
      <button onClick={handleAddItem}>Add</button>
      <ul>
        {toDoItems.map((toDoItem, index) => (
          <div key={`${toDoItem}-${index}`}>
            <li>{toDoItem}</li>
            <button>X</button>
          </div>
        ))}
      </ul>
    </div>
  );
}

The test is now passing, but if we test this live, we will notice that the input field is not being cleared after adding a new item. Let's write a test for this feature and then implement it.

// App.test.tsx
describe("To Do List", () => {
  // ...

  test("clears the input field after adding a new to do item", () => {
    render(<App />);

    const input = screen.getByPlaceholderText("Add a new to do item");
    const button = screen.getByRole("button", { name: "Add" });

    act(() => {
      fireEvent.input(input, { target: { value: "Check Chris's blog" } });
      fireEvent.click(button);
    });

    expect(input).toHaveValue("");
  });
});

In order to make this test pass, we need to add a line of code to our handleAddItem function to clear the input field state after adding a new item.

const handleAddItem = (e: React.MouseEvent<HTMLButtonElement>) => {
  e.preventDefault();

  if (inputValue) {
    setToDoItems([...toDoItems, inputValue]);
    setInputValue("");
  }
};

Now the test is passing. We can now write the test for the feature to remove a to do item and then implement it.

// App.test.tsx
describe("To Do List", () => {
  // ...

  test("removes a to do item from the list", () => {
    render(<App />);

    const removeButton = screen.getByRole("button", { name: "X" });

    act(() => {
      fireEvent.click(removeButton);
    });

    const listItem = screen.queryByText("Walk the dog");

    expect(listItem).not.toBeInTheDocument();
  });
});

This test is failing because we haven't implemented the feature to remove a to do item yet. Let's implement this feature in the App component by adding a function to handle the removal of a to do item.

function App() {
  const [toDoItems, setToDoItems] = useState<string[]>(["Walk the dog"]);
  const [inputValue, setInputValue] = useState<string>("");

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInputValue(e.target.value);
  };

  const handleAddItem = (e: React.MouseEvent<HTMLButtonElement>) => {
    e.preventDefault();

    if (inputValue) {
      setToDoItems([...toDoItems, inputValue]);
      setInputValue("");
    }
  };

  const handleRemoveItem = (index: number) => {
    setToDoItems(toDoItems.filter((_, i) => i !== index));
  };

  return (
    <div>
      <h1>To Do List</h1>
      <input
        onChange={handleInputChange}
        placeholder="Add a new to do item"
        type="text"
        value={inputValue}
      />
      <button onClick={handleAddItem}>Add</button>
      <ul>
        {toDoItems.map((toDoItem, index) => (
          <div key={`${toDoItem}-${index}`}>
            <li>{toDoItem}</li>
            <button onClick={() => handleRemoveItem(index)}>X</button>
          </div>
        ))}
      </ul>
    </div>
  );
}

Now the test is passing. We have successfully implemented all the features of the To Do application following TDD methodology. But we can notice something in our test code that can be improved. We are repeating the rendering of the App component in each test. We can extract this logic from each test to a beforeEach function to avoid repetition.

// App.test.tsx
import { beforeEach, describe, expect, test } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import App from "../App";

describe("To Do List", () => {
  beforeEach(() => {
    render(<App />);
  });
});

There is always room for improvement in our code. In this case we could extract some of the common test queries to functions, or refactor our production code to respect the Single Responsibility Principle by extracting some of the logic to separate functions. But for the purpose of this article, I will leave the code as it is.

You can find the complete code of the application in my GitHub repository.

Conclusion

I have shown you how to apply TDD methodology in a simple React app. The article might seem long, but my goal was to show you the thought process behind TDD. We started by understanding the requirements of the application and then we wrote tests for each feature. We then implemented the features one-by-one, making sure that we added the minimum amount of production code to make the tests pass. We also refactored the code to make it clean and maintainable.

Obviously a To Do application is a very simple example, but the principles of TDD can be applied to any application, and the benefits of TDD are more evident in larger and more complex applications. It is a powerful methodology that can help you to write more reliable, maintainable, and bug-free code, but in order to fully benefit from it, you need to practice it regularly to change your mental model and the way you approach software development.

I hope you enjoyed this article and that it helps you to understand TDD better. If you have any questions or feedback, feel free to reach out to me; my contact details are available on my home page. Happy coding! 🚀