paint-brush
Test-Driven Development in React: Building Reliable Applications from Scratchby@edemagbenyo
397 reads
397 reads

Test-Driven Development in React: Building Reliable Applications from Scratch

by Edem AgbenyoOctober 26th, 2023
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Unlocking the Power of Test-Driven Development: Building Robust React Apps with Confidence
featured image - Test-Driven Development in React: Building Reliable Applications from Scratch
Edem Agbenyo HackerNoon profile picture


Imagine you've spent weeks building an application, and it's now live and working well. Users are happy with it. Then, a few months later, your boss asks for some new features. You dive into the project and start writing code, but suddenly, you begin to notice a lot of errors popping up. Fixing them takes a considerable amount of time because you have to remember what each piece of code does and where it fits. This is where tests in your code can make a big difference. Writing tests gives you the confidence to make changes to your code without worrying.


In this comprehensive guide, we'll explore Test-Driven Development (TDD) in the context of building a React.js application. We'll focus on the fundamentals of testing a React application. We'll create our own React application using the basic building blocks and even develop our own testing helper functions.

Building a React Application from Scratch

While create-react-app is great for quickly setting up a React project. In this case, we won't need all the boilerplate that comes with it. We want to stick to the core React functionalities. This follows the first TDD principle, YAGNI (You Ain’t Gonna Need It), which simply implies that you should only add libraries and code that you need to prevent technical debt in the future.

Installing Dependencies

Before we start, ensure that you have npm installed. If not, head to https://nodejs.org for installation instructions. Once that's done, create a folder for our application and follow these steps:


  1. Initialize the project with npm init -y.

  2. Open the package.json file and set the test property to use jest.

  3. Install Jest by running npm install --save-dev jest.

  4. Install React with npm install --save react react-dom.

  5. Install Babel for transpiling your code with the following commands:

    • npm install --save-dev @babel/preset-env @babel/preset-react
    • npm install --save-dev @babel/plugin-transform-runtime
    • npm install --save @babel/runtime.
  6. Configure Babel to use the plugins you just installed by creating a new file, .babelrc, with the following content:


{
  "presets": ["@babel/env", "@babel/react"],
  "plugins": ["@babel/transform-runtime"]
}

Creating Your First Test

Let's build an online clothing store that displays a list of products on its page. We'll start by creating the first page, the product page. It shows details about a product and will be a React component named Product. The first step in the TDD cycle is to write a failing test.

We create a test file in test/Product.test.js with the following content:

describe("Product", () => {
  it("renders the title", () => {
  });
})

In the above code, the describe keyword defines a test suite, which is simply a set of tests with a given name. The name could be a React component, a function, or a module. The it function defines a single test. The first argument is the description of the test and should start with a present-tense verb to make it read in plain English. For example, the test above reads as "Product renders the title."


Note: All Jest functions, such as describe and it, are required and available in the global namespace when you run npm run test, so there is no need to explicitly import them.

When we run npm run test, the test will pass because empty tests always pass. Let's add an expectation to our test:

describe("Product", () => {
  it("renders the title", () => {
    expect(document.body.textContent).toContain("iPhone 14 Pro")
  });
})

The expect function compares an expected value against a received value. In our case, the expected value is "iPhone 14 Pro," and the actual value is whatever is inside document.body.textContent. The expectation will pass only if document.body.textContent contains the text "iPhone 14 Pro."


When we run the test, we get an error message indicating that Jest is not able to access document and suggests installing jsdom for our test environment.

Note: A test environment is a piece of code that runs before and after your test suite. In this instance, the jsdom environment sets globals and document objects and turns Node.js into a browser-like environment.


Let's go ahead and install jsdom:

npm install --save-dev jest-environment-jsdom

One more thing: let's update package.json and tell it to use jsdom as our test environment:

{
   ...,
   "jest": {
     "testEnvironment": "jsdom"
   }
}

Now, run the test again, and you will see a different error, which is typical of a failing test. This error message can be divided into four parts:


  • The name of the failing test.

  • The expected answer.

  • The actual answer.

  • The location in the source where the error occurred.


This is expected since we haven't written any production code yet. To make this test pass, we need to render our Product component into a React container, just as React works in production.


To render a component in React, follow these steps:


  1. Create a container.
  2. Make the container the root.
  3. Attach our component to the root.

This can be done in one line as follows:

...
ReactDOM.createRoot(container).render(component)
...

To achieve the same in our test, do the following:


  1. Create the container:
const container = document.createElement('div');
document.body.appendChild(container);

We create the container element and append it to the document. This is necessary because some of the events are only accessible if the element is part of the document tree.


  1. Create the component:
...
const product = {
  title: "iPhone 14 Pro",
}
const component = <Product product={product} />
...

When you put it all together, we have:

describe("Product", () => {
  it("renders the title", () => {
    const container = document.createElement('div');
    document.body.appendChild(container);
    const product = {
        title: "iPhone 14 Pro",
    }
    const component = <Product product={product} />
    ReactDOM.createRoot(container).render(component)
    expect(document.body.textContent).toContain("iPhone 14 Pro")
  });
})

We need to include the two standard React imports at the top since we are using ReactDOM and JSX:

import React from "react";
import ReactDOM from "react-dom/client";

When we run the test as it is now, we get a ReferenceError because Product is not defined. To make this test pass, we need to create a Product component and import it into our test.

Create a Product.jsx file, and inside it, enter the following:

export const Product = () => {};

Run the test, and we get a new error

Test Error 2

This new error suggests that the string we expected is different from what we received. Let's update our component to fix the test error:

export const Product = () => "iPhone 14 Pro";

When we run the test, we get the same error, which is due to the asynchronous nature of React 18's render function. This causes the expectation to run before the DOM is modified.


We can fix this by using the helper function act provided by ReactDOM, which ensures the DOM is modified before other code is executed, making our expectation to execute only after the DOM is modified.


We import act as follows:

import { act } from "react-dom/test-utils";

We also have to update the jest property in the package.json:

{
   ...,
   "jest": {
     "testEnvironment": "jsdom",
     "globals": {
       "IS_REACT_ACT_ENVIRONMENT": true
     }
   }
}

Now, update the line that renders the component to be:

...
act(() =>
  ReactDOM.createRoot(container).render(component)
)
...

Run the test again, and you will finally see our test passing with no errors.

Expanding the Test

The above test will pass as long as we expect the content of the body to have "iPhone 14 Pro". However, this is not our desired outcome. To make the test pass for other title values, we introduce a prop to our component. Now, our component should look like this:

export const Product = ({ product }) => <p>{product.title}</p>;

Let's add another test to the suite:

it("renders another the title", () => {
    const container = document.createElement("div");
    document.body.appendChild(container);
    const product = {
      title: "Samsung",
    };
    const component = <Product product={product} />;
    act(() => ReactDOM.createRoot(container).render(component));
    console.log(document.body.textContent);
    expect(document.body.textContent).toContain("Samsung");
});

When we run our test, it should pass. However, there is a small issue. The test is passing because of the way the toContain works, but when we inspect the textContent in the second test, we see something strange.


When we run the code above, we get:

Passing test

This indicates that the components are not independent. The document is not being cleared between renders, and we don't want that to happen. We want each unit of the test to be separate from the other. To fix that, we replace appendChild with replaceChildren.

Run the test again, and we should be good to go.


Conclusion

In this article, we've explored the fundamental principles of Test-Driven Development (TDD) in the context of building a React.js application. We've gone through the process of setting up a basic React application, writing our first failing test, and making it pass by creating the necessary React component and utilizing the act function to ensure asynchronous code is handled correctly.

By following these TDD practices, you can build more reliable and maintainable React applications that are easier to modify and extend in the future. In the next post in this series, we will refactor the test by extracting the repetitive code.