The Programmer's Guide To Applying the TDD Methodology in React Applications

Written by antonkalik | Published 2023/05/11
Tech Story Tags: tdd | test | javascript | react | programming | coding | software-engineering | software-testing

TLDRThe TDD methodology is used to develop React applications. TDD believes that you should write tests before writing the code, so you can see if it will fail. Here you will learn how to use tests for React applications and testing behavior in general. You will see the difference between types of tests and where to use them.via the TL;DR App

The detailed guide on how to apply the TDD methodology in react applications

Testing is a very important part of our life. Testing gives you confidence about things. And now you are in the kitchen making a dressing for a salad. You mix up all of the ingredients, and you know their taste more or less. The result of your process will be a salad with souse. Before putting souse into the salad, you possibly want to check — is it salty, sweet, or spicy enough? And in the end, you spice up a salad with dressing.

Trying to understand how it is before accomplishing the end of the process is testing. We test food before serving and the strength of the components before building the bridge. We test the medications before sending them to pharmacies for buyers. NASA made many tests before launching the rocket. A dozen examples could be given here, but the idea of testing is everywhere. With coding, it’s the same.

Writing the code in our projects is like a snowball as it grows — the chance of getting an error and being crushed in the end is very high. Also, other developers get onto the project, and the chance of breaking something is higher if there are no tests. When something in the service goes wrong — the business spends time and money fixing it. So, it’s better to have a prediction of potential mistakes by tests.

Reading “Clean Code” by Robert C. Martin, I thought it is very difficult to think about tests first and then create a component. But after some training and the techniques described below, I feel more confident with TDD than ever.

Here you will learn how to use tests for React applications and testing behavior in general. You will see the difference between types of tests, where to use them, and when. Be ready for a lot of coding.

Before starting to write tests, I’d like to highlight three important concepts:

  • DDD — Domain-Driven Development
  • BDD — Behaviour-Driven Development
  • TDD — Test-Driven Development

What Is DD?

DD means Driven Development. This means development is based on some behavior. Take a look at the diagram below:

DDD

This is the correct interpretation of a business idea in code. It’s like the skill of writing enterprise code; the code produced is based on the business idea. It includes such crucial aspects as ubiquitous language, which is a unique communication language between the business and the development sides. On the other hand, Model-Driven Design is based on a model and some patterns for development.

BDD

This means improving communication between the development team and the business. This concept branched from TDD; it’s like a variant or extension of it. BDD is a description of the behavior, and it’s good for integration testing and e2e.

TDD

It’s a development based on testing. TDD believes that you should write tests before writing the code, so you can see if it will fail. And only after the written test do you start writing the functionality for the written test and figuring out the components to satisfy the tests. It’s very good for unit testing.

Tests

  • Unit testing — it’s a type of testing when you test one element (or entity), without any binding (wiring) with other elements.
  • Integration tests — it’s about rendering logic when React rendered something to the DOM. It looks at interrelated functions, enumeration, some logic dependency functions, and so on.
  • E2E testing — when we test how users interact with interfaces. It focuses on the user's behavior with the interface at the end of rendering. There is the browser and created user who walks through an interface.

Application

On the schema, you can see that the application is very simple. In the end, you can find the link to the GitHub repository. But for now, let’s focus on building the application following the TDD methodology. We’re not gonna implement all of the components. It’s important to understand only how to use TDD in React.

In the beginning, it is very important to understand what we’re gonna build and determine what components we need to implement this application. In my explanation, we’re gonna start with the simple ones.

The idea of the application is to render the grid of cards requested from a fake API. Also, we have to be able to add a new card by clicking on the Add button, and the Clear button has to remove all of the cards from the grid. And finally, in the Footer, we show the total number of cards. Each card we have to be able to be opened or removed. I was using create-react-app.

"dependencies": {
    "axios": "^0.27.2",
    "jest-styled-components": "^7.1.1",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router": "^6.4.0",
    "react-router-dom": "^6.4.0",
    "react-scripts": "5.0.1",
    "styled-components": "^5.3.5",
    "web-vitals": "^2.1.4"
  },
  "devDependencies": {
    "@testing-library/jest-dom": "^5.16.5",
    "@testing-library/react": "^13.4.0",
    "@testing-library/react-hooks": "^8.0.1",
    "@testing-library/user-event": "^13.5.0",
    "babel-eslint": "^10.1.0",
    "eslint": "^8.23.0",
    "prettier": "^2.7.1",
    "react-test-renderer": "^18.2.0"
  }

After installing packages, let’s struct our folders. Each component and view folder has to have index.js as a file.

└── src
    ├── App
    ├── Routes
    ├── __mocks__
    ├── components
    │   ├── Card
    │   ├── Cards
    │   ├── Footer
    │   ├── Header
    │   └── MainLayout
    ├── context
    ├── hooks
    ├── index.js
    └── views
        ├── AboutView
        ├── CardView
        ├── CardsView
        └── HomeView

Routing in the application has to have the following routes:

/ -> Home Page
/cards -> Cards Grid Page
/card/:id -> Card View
/about -> About View
/* -> Not Found View

Tests

While we described the application in technical language, we already called some components. Each component has to be independent of tests. We have to be able to test components without side dependencies. We will start writing our components from views. We need them to render the App itself and test routing.

└── views
        ├── AboutView
        ├── CardView
        ├── CardsView
        └── HomeView

In the folder, src/views/, let’s put index.js. <VIEW_NAME>.spec.js in each file. For example, AboutView.spec.jsand then open it.

describe("<AboutView />", () => {
  it("should render view", () => {
    throw Error("Not implemented");
  });
});

Yes, we’re throwing the Error('Not implemented') and you have to get used to it because the most important part is the description. We have to describe what we gonna test in each it and what we expect from each test for that component. Now add the following to index.js:

export const AboutView = () => <div />

And back to your test file. We gonna write the test exactly to our expectations from this component. What are we expecting? After rendering, we’re gonna save a snapshot and About View title.

import { render, screen } from "@testing-library/react";
import { AboutView } from "./index";

describe("<AboutView />", () => {
  it("should render view", () => {
    render(<AboutView />);
    expect(screen.getByTestId("about-view")).toMatchSnapshot();
    expect(screen.getByTestId("about-view")).toHaveTextContent("About View");
  });
});

Jest helps us save a snapshot of our structure of components to disk and, on subsequent test runs, compare new snapshots with previously saved ones. A snapshot, in this case, is just a textual representation of the data structure. The first time a test snapshot fires, it will write the result of the component’s textual representation to disk. The test will pass and be recorded as a snapshot.

The next time it manipulates the component, the test will fail because there will be a difference in the data written to the snapshot. Why it’s important? To compare and prevent unwanted elements in the component. It is very important to check the difference in snapshots. The snapshot test will be crushed by default if an undefined value is inside. Back to index.js and adapt our view component exactly for the test.

export const AboutView = () => {
  return (
    <div data-testid="about-view">
      <h1>About View</h1>
    </div>
  );
};

Do the same for all other views. Write a test and then implementation for your component, function, util, hook, etc. This is the main concept of TDD.

Routes

As you know, we have Routes in our app that will render our views. In the folder, src/Routes, create index.js and Routes.spec.js and then add this to the file:

describe("<Routes />", () => {
  it("should show home view", () => {
    throw Error("Not implemented");
  });

  it("should show cards page", () => {
    throw Error("Not implemented");
  });

  it("should show card view", () => {
    throw Error("Not implemented");
  });

  it("should show about view", () => {
    throw Error("Not implemented");
  });

  it("should show not found view", () => {
    throw Error("Not implemented");
  });
});

By it, we described exactly what we expected from the Routes. Let’s add to first in our HomeView expectations. It’s important to understand what we are expecting from this component.

import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { Routes } from "./index";

jest.mock("src/views/HomeView", () => ({
  HomeView: () => <div data-testid="home-view">HomeView</div>,
}));

const renderRoutes = (route = "/") => {
  return render(
    <MemoryRouter initialEntries={[route]}>
      <Routes />
    </MemoryRouter>
  );
};

describe("<Routes />", () => {
  it("should show home view", () => {
    renderRoutes();
    expect(screen.getByTestId("home-view")).toBeInTheDocument();
  });

  it("should show cards page", () => {
    throw new Error("Not implemented");
  });
  // ...
});

The first test tells us that HomeView has to be by route /. As you can see, we mocked it just to avoid unexpected imports from view components. We isolated by our test implementation. Let’s adapt Routes to that test. No more, no less.

import { Routes as ReactRoutes, Route, Outlet } from "react-router-dom";
import { HomeView } from "src/views/HomeView";

export const Routes = () => {
  return (
    <ReactRoutes>
      <Route path="/" element={<Outlet />}>
        <Route index element={<HomeView />} />
      </Route>
    </ReactRoutes>
  );
};

Run your test. The result has to be with fallen tests except only the first one related to HomeView.

One by one, it adds tests for other views.

jest.mock("src/views/HomeView", () => ({
  HomeView: () => <div data-testid="home-view">HomeView</div>,
}));

jest.mock("src/views/CardsView", () => ({
  CardsView: () => <div data-testid="cards-view">CardsView</div>,
}));

jest.mock("src/views/CardView", () => ({
  CardView: () => <div data-testid="card-view">CardView</div>,
}));

jest.mock("src/views/AboutView", () => ({
  AboutView: () => <div data-testid="about-view">AboutView</div>,
}));

describe("<Routes />", () => {
  it("should show home view", () => {
    renderRoutes();
    expect(screen.getByTestId("home-view")).toBeInTheDocument();
  });

  it("should show cards page", () => {
    renderRoutes("/cards");
    expect(screen.getByTestId("cards-view")).toBeInTheDocument();
  });

  it("should show card view", () => {
    renderRoutes("/cards/9");
    expect(screen.getByTestId("card-view")).toBeInTheDocument();
  });

  it("should show about view", () => {
    renderRoutes("/about");
    expect(screen.getByTestId("about-view")).toBeInTheDocument();
  });

  it("should show not found view", () => {
    renderRoutes("/someroute");
    expect(screen.getByText("404 - Not Found")).toBeInTheDocument();
  });
});

And adapt the component for each described test.

import { Routes as ReactRoutes, Route, Outlet } from "react-router-dom";
import { HomeView } from "src/views/HomeView";
import { CardsView } from "src/views/CardsView";
import { CardView } from "src/views/CardView";
import { AboutView } from "src/views/AboutView";

export const Routes = () => {
  return (
    <ReactRoutes>
      <Route path="/" element={<Outlet />}>
        <Route index element={<HomeView />} />
        <Route path="/cards" element={<Outlet />}>
          <Route index element={<CardsView />} />
          <Route path=":id" element={<CardView />} />
        </Route>
        <Route path="/about" element={<AboutView />} />
      </Route>
      <Route path="*" element={<h1>404 - Not Found</h1>} />
    </ReactRoutes>
  );
};

App Component

Time to bind it with the main App component. Go to the App folder and create files: index.js, App.spec.js. Remember that App has to have Routes. Let’s test it.

Open App.spec.js, and describe the app test as we did before with throw Error("Not implemented");.

import { render, screen } from "@testing-library/react";
import { App } from "./index";

jest.mock("src/Routes", () => {
  return {
    Routes: () => <div data-testid="routes" />,
  };
});

describe("<App />", () => {
  it("should render app", () => {
    render(<App />);
    expect(screen.getByTestId("app")).toMatchSnapshot();
  });
});

Of course, we don’t have any App component yet, but we already understand that in the component, we’re gonna have Routes. Let’s create the Appcomponent. I am using styled-components.

import React from "react";
import { Routes } from "src/Routes";
import { StyledApp } from "./style";

export function App() {
  return (
    <StyledApp data-testid="app">
      <Routes />
    </StyledApp>
  );
}

Now, let’s run our test for App. You will see that the snapshot will be created and the test passed.

Header

Time to have some components for our MainLayoutHeader has title and the two buttons Add and Clear. Let’s start with tests. As usual, with Test not implemented. Only the first test will be ready for our component.

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import { Header } from "./index";
import { MemoryRouter } from "react-router-dom";

const header = (
  <MemoryRouter>
    <Header />
  </MemoryRouter>
);

describe("<Header />", () => {
  it("should render Header", () => {
    render(header);
    expect(screen.getByTestId("header")).toMatchSnapshot();
  });

  it("should have title", () => {
    const { container } = render(header);
    expect(container).toHaveTextContent("Logo Title");
  });

  it("should render controls in correct view", () => {
    throw new Error("Test not implemented");
  });

  it("should call onReset function", () => {
    throw new Error("Test not implemented");
  });

  it("should call onAdd function", () => {
    throw new Error("Test not implemented");
  });
  
  it("should not have buttons", () => {
    throw new Error("Test not implemented");
  });
});

And then let’s create our Header exact for the written test. Only satisfy the test; nothing else.

import { StyledHeader } from "./style";

export const Header = ({ onReset, onAdd }) => {
  return (
    <StyledHeader data-testid="header">
        <h1>Logo Title</h1>
    </StyledHeader>
  );
};

Then run tests. Two first test has to be passed. OK, let’s add additionally other tests to Header one by one and improve for each test component.

it("should render controls in correct view", () => {
  render(
    <MemoryRouter initialEntries={["/cards"]}>
      <Header />
    </MemoryRouter>
  );
  const resetButton = screen.getByRole("button", { name: "Reset" });
  const addButton = screen.getByRole("button", { name: "Add" });
  expect(resetButton).toBeInTheDocument();
  expect(addButton).toBeInTheDocument();
});

And after adding buttons to the Header component — update your snapshot.

<StyledHeader data-testid="header">
  <h1>Logo Title</h1>
  <div className="buttons">
    <button onClick={onAdd}>Add</button>
    <button onClick={onReset}>Reset</button>
  </div>
</StyledHeader>

As you can see, we are going step by step. The test is first, and then we do the implementation to satisfy that test. Let’s add two additional tests for the Header, as shown below:

it("should render controls in correct view", () => {
  render(
    <MemoryRouter initialEntries={["/cards"]}>
      <Header />
    </MemoryRouter>
  );
  const resetButton = screen.getByRole("button", { name: "Reset" });
  const addButton = screen.getByRole("button", { name: "Add" });
  const buttons = screen.getByTestId("header-buttons");

  expect(resetButton).toBeInTheDocument();
  expect(addButton).toBeInTheDocument();
  expect(buttons).toBeInTheDocument();
});

it("should call onReset function", () => {
  const onReset = jest.fn();
  render(
    <MemoryRouter initialEntries={["/cards"]}>
      <Header onReset={onReset} />
    </MemoryRouter>
  );
  const element = screen.getByRole("button", { name: "Reset" });
  userEvent.click(element);
  expect(onReset).toHaveBeenCalled();
});

it("should call onAdd function", () => {
  const onAdd = jest.fn();
  render(
    <MemoryRouter initialEntries={["/cards"]}>
      <Header onAdd={onAdd} />
    </MemoryRouter>
  );
  const element = screen.getByRole("button", { name: "Add" });
  userEvent.click(element);
  expect(onAdd).toHaveBeenCalled();
});

it("should not have buttons", () => {
  render(
    <MemoryRouter initialEntries={["/"]}>
      <Header />
    </MemoryRouter>
  );
  const element = screen.queryByTestId("header-buttons");
  expect(element).not.toBeInTheDocument();
});

And, of course, adapt the component for those tests. We have to be sure that our functions have been called.

import { StyledHeader, StyledButtons } from "./style";
import { Link, useLocation } from "react-router-dom";

export const Header = ({ onReset, onAdd }) => {
  const location = useLocation();
  const isCardsView = location.pathname === "/cards";

  return (
    <StyledHeader data-testid="header">
      <Link to="/">
        <h1>Logo Title</h1>
      </Link>
      {isCardsView && (
        <StyledButtons data-testid="header-buttons">
          <button onClick={onAdd}>Add</button>
          <button onClick={onReset}>Reset</button>
        </StyledButtons>
      )}
    </StyledHeader>
  );
};

Footer

This component is much simpler, but we gonna render some cards in it. You can see what it looks like above. First, create a test called Footer.spec.js.

import { render, screen } from "@testing-library/react";
import { Footer } from "./index";

describe("<Footer />", () => {
  it("should render footer", () => {
    render(<Footer totalCards={9} />);
    expect(screen.getByTestId("footer")).toMatchSnapshot();
  });

  it("should have description and amount of cards", () => {
    render(<Footer totalCards={9} />);
    const footerData = screen.getByTestId("footer");
    expect(footerData).toHaveTextContent("footer description");
    expect(footerData).toHaveTextContent("Total Cards: 9");
  });

  it("should render 0 amount of cards", () => {
    render(<Footer />);
    const footerData = screen.getByTestId("footer");
    expect(footerData).toHaveTextContent("Total Cards: 0");
  });
});

And adapt for each case of testing the component. Almost the same as in the header but much simpler.

import { StyledFooter } from "./style";

export const Footer = ({ totalCards }) => {
  return (
    <StyledFooter data-testid="footer">
      <p>footer description</p>
      <p>Total Cards: {totalCards || 0}</p>
    </StyledFooter>
  );
};

Hooks

Yes, we have to test hooks as well in the same approach. Let’s assume we will have some queries to request cards. We have a link for that request, and the expected result in data has to be an array with id and title.

For HTTP requests, we’re gonna use axios. First of all, we have to mock all of this. Let’s go to src/hooks and create a folder useApi. There is going to be index.js and useApi.spec.js, and in the beginning, let’s add these mocks there:

import { renderHook } from "@testing-library/react-hooks";
import { useApi } from "./index";
import axios from "axios";

jest.mock("axios");

axios.get = jest.fn(() => Promise.resolve({ data: [] }));

We can describe what we expect from our hook.

describe("useApi", () => {
  it("should return loading", () => {
    const { result } = renderHook(() => useApi("/testf"));

    expect(result.current.loading).toBe(true);
    expect(result.current.data).toBe(null);
    expect(result.current.error).toBe(null);
  });

  it("should return data", async () => {
    throw new Error("Not implemented");
  });

  it("should return error", async () => {
    throw new Error("Not implemented");
  });
});

This part is already familiar, and we must adapt our hook for that test.

import axios from "axios";

export const useApi = (url) => {
  return { data: null, loading: true, error: null };
};

Let’s describe in the test the expectation of behavior for the hook. In this case, mockImplementation helps to return the promised fake data in each test independently.

it("should return data", async () => {
  const data = [1, 2, 3];

  axios.get.mockImplementation(() => Promise.resolve({ data }));

  const { result, waitForValueToChange } = renderHook(() => useApi("/test"));

  await waitForValueToChange(() => result.current.data);

  expect(result.current.error).toBe(null);
  expect(result.current.loading).toBe(false);
  expect(result.current.data).toStrictEqual(data);
});

it("should return error", async () => {
  const error = new Error("test error message");
  axios.get.mockImplementation(() => Promise.reject(error));

  const { result, waitForValueToChange } = renderHook(() => useApi("/test"));
  await waitForValueToChange(() => result.current.error);

  expect(result.current.error).toBe(error);
});

We are waiting for changes in a specific field in each of those tests. Why do we have it? Because the hook will be used in a component with a life circle.

import { useCallback, useEffect, useState } from "react";
import axios from "axios";

export const useApi = (url) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  const fetchData = useCallback(async () => {
    setLoading(true);
    setError(null);

    try {
      const response = await axios.get(url);

      setData(response.data.slice(0, 50));
    } catch (error) {
      setError(error);
    } finally {
      setLoading(false);
    }
  }, [url]);

  useEffect(() => {
    if (url) {
      fetchData();
    }
  }, [url]);

  return { data, loading, error };
};

Reducer

Absolutely the same approach could be for reducer in context. Let’s imagine that our application has a context, and we’re gonna use some actions right from there with the help of a reducer. Remember that we use reducer with initState within useReducer hook:

export const initialState = { cards: [] };
const [state, dispatch] = useReducer(reducer, initialState);

So, let’s describe our expectations from that reducer. Here’s the code:

import { reducer, initialState } from "./index";

describe("reducer", () => {
  it("should return the initial state", () => {
    expect(reducer(undefined, {})).toEqual(initialState);
  });

  it("should handle ADD_CARD", () => {
    expect(
      reducer(initialState, {
        type: "ADD_CARD",
      })
    ).toEqual({
      cards: [{ id: 1, title: "Title Card 1" }],
    });
  });

  it("should handle SET_CARDS", () => {
    throw new Error("not implemented");
  });

  it("should handle REMOVE_CARD", () => {
    throw new Error("not implemented");
  });

  it("should handle RESET", () => {
    throw new Error("not implemented");
  });
});

And, of course, adapt our reducer for that tests one by one.

export function reducer(state, action) {
  switch (action.type) {
    case "ADD_CARD":
      return {
        cards: [
          ...state.cards,
          {
            ...action.payload,
            title: `Title Card ${state.cards.length + 1}`,
            id: state.cards.length + 1,
          },
        ],
      };
    case "RESET":
      return initialState;
    default:
      return state || initialState;
  }
}

Now, update the test for an additional description of reducer behavior.

it("should handle SET_CARDS", () => {
  const cards = [
    { id: 1, title: "Test One" },
    { id: 2, title: "Test Two" },
  ];

  expect(
    reducer(initialState, {
      type: "SET_CARDS",
      payload: {
        cards,
      },
    })
  ).toEqual({
    cards,
  });
});

it("should handle REMOVE_CARD", () => {
  expect(
    reducer(
      {
        cards: [{ id: 1, title: "test" }],
      },
      {
        type: "REMOVE_CARD",
        payload: { id: 1 },
      }
    )
  ).toEqual({
    cards: [],
  });
});

it("should handle RESET", () => {
  expect(
    reducer(
      {
        cards: [{ id: 1, title: "test" }],
      },
      {
        type: "RESET",
      }
    )
  ).toEqual({
    cards: [],
  });
});

And again, adapt reducer for all written tests.

case "SET_CARDS":
  return {
    cards: action.payload.cards.map((card) => ({
      id: card.id,
      title: card.title,
    })),
  };
case "REMOVE_CARD":
  return {
    cards: state.cards.filter((card) => card.id !== action.payload.id),
  };

Other Components

Now, try to check other components from the repository and write your own tests. There is CardCardsMainLayout with Header and Footer. First, create tests and then component implementation.

Conclusion

I just want to show you how to use TDD for your applications. In the beginning, it feels annoying, but you save a lot of time in the end. As a result, you’ll receive the fully tested independent component, which will be clear for other developers if they need to do some updates there.

And in general, TDD provides stability, solidity, and proven solutions that have to be a cornerstone for any enterprise application**.**

Resources

GitHub repository: https://github.com/antonkalik/tdd-react-example

It's always a pleasure to receive any suggestions and comments related to the topic. Feel free to ask any questions. Thank you!

Also published here.


Written by antonkalik | Senior Software Engineer @ Amenitiz / Node JS / React
Published by HackerNoon on 2023/05/11