🧪 React Testing In Real Life

🧪 React Testing In Real Life

When we are building an application, it's always necessary to ensure that our app is working the way it should. So, in this article, I'm going to show you how to test your React Apps in the right way with a real-world example.

The big reason

Besides making sure it's everything working well, with good tests in place, you can refactor your code like crazy and have the assurance that you didn’t break anything. Moreover, you can add new features or improve existing ones with the same level of confidence.

What's worth testing?

Probably it already happened to you, and I can say that when it comes time to actually write the tests, we frequently get stuck on it. So, what exactly should and it's worth to test? Based on the app we're going to build/test today, at least, you'll be able to...

  • Test if it is rendering

  • Test events

  • Test edge cases

  • Test states

🧪 Setting up our React App for tests

npx create-react-app testing-irl

Feel free to give a different name to the app

📐 The project structure

There are many ways to structure your files and folders for tests, but let's just focus on these ones:

  • The folder

__tests__ -> This one always stays on the source folder (src) and here you can just add your .js files (as usual).

  • The files

Otherwise, if you don't add the test files to the __tests__ folder, it's necessary to add one of these suffixes:

  • .spec.js or spec.ts

  • .test or spect.ts

We recommend to put the test files (or tests folders) next to the code they are testing so that relative imports appear shorter. For example, if App.test.js and App.js are in the same folder, the test only needs to import App from './App' instead of a long relative path. Collocation also helps find tests more quickly in larger projects. — Running tests, create-react-app

👩‍🏫 Understanding the it, describe, and expect

Before start building like crazy our app, it's necessary to understand the main concepts of test implementation: global functions.

The it and describe receive a string and function as parameters.

  • Test Suite
describe('App.js', () => {})
// or
describe('Testing the App.js file', () => {})

describe has a description of the test and the callback function which contains the implementation of each test.

Hint: Describe is useful for separating a group of tests in the archive.

  • Test Case
it('should list 3 <li> elements', () => {})

it receives the description of what will be tested and the test implementation as a second parameter.

Hint: In general, by convention, we write the descriptions in English, according to the it pronoun syntax. e.g.: it should be able to show the h1 element.

  • expect

And finally, this is a method to make an assertion where we compare what is expected as the result. Within the expect function, firstly comes the expected result, then we'll be able to use some assertiveness methods (e.g.: expect().ToBe(null))

describe('Checkbox.js', () => {
  test('true is truthy', () => {
    expect(true).toBe(true);
  });

  test('false is falsy', () => {
    expect(false).toBe(false);
  });
});

🎨 Final outlook

Basically, what we're gonna build is an app where the user can add technologies and also delete them. Also, if the user tries to a user that already exists in the list, it'll not work. Although it looks pretty simple, the test's complexity can require much more. Let's start with the components!

The components

App.js

import Technologies from 'pages/Technologies';

function App() {
  return (
    <>
      <h1>Hello, testing world</h1>
      <Technologies />
    </>
  );
}

export default App;

Techologies.js

import { useState } from 'react';

function Technologies() {
  const [technologies, setTechnologies] = useState(['React']);
  const [newTech, setNewTech] = useState('');

  const handleSubmit = e => {
    e.preventDefault();

    if (!newTech || technologies.includes(newTech)) return;

    setTechnologies([...technologies, newTech]);
    setNewTech('');
  }

  const handleDelete = (tech) => {
    setTechnologies(technologies.filter((techItem) => techItem !== tech));
  }

  return (
    <>
      <ul data-testid="ul-techs">
        {technologies.map(tech => (
          <li data-testid={tech} key={tech}>
            {tech}
            {' '}
            <button
              disabled={tech === 'React'}
              data-testid={`${tech}-btn-delete`}
              type="button"
              onClick={() => handleDelete(tech)}
            >
              ❌
            </button>
          </li>
        ))}
      </ul>

      <form data-testid="form-add-tech" onSubmit={handleSubmit}>
        <input
          data-testid="input-add-tech"
          type="text"
          value={newTech}
          onChange={(e) => setNewTech(e.target.value)}
        />
        <button type="submit">Save</button>
      </form>
    </>
  );
};

export default Technologies;
  • technologies -> Which is a string array that stores the names of the technologies and that already comes with the "React" technology filled in by default.

  • newTech - A state that stores a string with the name of the technology that the user will type.

  • handleSubmit (event) - When it is fired, add the technology to the technologies array and clear the field. First, it makes sure that the newTech is not empty and if the array already contains the technology that is being inserted. If it already exists, do not allow to continue the operation.

  • handleDelete (technology) - It receives a string with the technology, and using the concept of immutability, recreates an array of technologies except with the tech that was passed as a parameter.

🍏 Testing our app

App.spec.js

import { render, screen } from '@testing-library/react';
import App from 'App';

describe('App.js', () => {
  it('should be able to show the h1 element', () => {
    render(<App />);
    const titleEl = screen.getByText(/hello testing world/i); // Hello, testing world

    expect(titleEl).toBeInTheDocument();
  });
});

The test on the App.spec.js is quite simple:

  • First I render the <App />
  • I assign in the constant titleEl the return of the query by text.
  • I hope that titleEl is in the DOM (toBeInTheDocument). If it is true, the test succeeds. Otherwise, it fails.
  • And also, to run the test, you just need to use this command at the root of the project:

    • npm run test or yarn test
  • The render method on the @testing-library is responsible for rendering the component. It takes the element as a parameter and returns a RenderResult that has several utility methods and properties such as a container.

  • The screen object is used to query and debug the DOM. It is more recommended and you don't need to be disrupting the render return to get every single function that makes the queries.

// ❌
const { getByRole } = render(<App />);

// ✅
render(<App />);
const errorMessage = screen.getByRole('alert');

Tecnologies.spec.js

import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Technologies from 'pages/Techonologies';

fireEvent -> It fires click events (.click), text changes (.change), and also form submissions.

For most cases, you will need to use the userEvent (that was created under the fireEvent package). It provides several methods that are closer to the reality of the user's interaction with the screen. It has the type method to trigger the click, keyDown, keyPress, and keyUp events.

Hint: Whenever possible, use @testing-library/user-event instead of fireEvent.

  • 🔬 1st test
it("should be able to add new technology", () => {
    render(<Technologies />);

    const input = screen.getByTestId("input-add-tech");
    const form = screen.getByTestId("form-add-tech");

    userEvent.type(input, "React Native");
    fireEvent.submit(form);

    expect(screen.getByTestId("React Native")).toBeTruthy();
  });

With the input and the form in hands, we can perform actions on these elements via programming.

userEvent.type(input, "React Native");

The userEvent is used to fill the input with React Native as a value. Also, I use the fireEvent to trigger the event and submit it to the form.

I expect that the React Native exists, which means that the result of the query screen.getByTestId("React Native") is true.

It'll return a string or not. If returns a string, the truthy value of a string is true. On the other side, null, undefined are false: falsy.

With that, we could implement:

expect(!!screen.getByTestId('React Native')).toBe(true);

The result would be the same, but the assertion with toBeTruthy() is more semantic and improves the readability of the code ;)

  • 🔬 2nd test
it("should be able to list three techs", () => {

    const { getByTestId } = render(<Technologies />);

    const input = getByTestId("input-add-tech");
    const form = getByTestId("form-add-tech");

    fireEvent.change(input, { target: { value: "React Native" } });
    fireEvent.submit(form);

    fireEvent.change(input, { target: { value: "Flutter" } });
    fireEvent.submit(form);

    const techList = getByTestId("ul-techs");
    expect(techList.children.length).toBe(3);
  });
  • In this one, I used the render breakdown to get the getByTestId method. Also, the fireEvent.change simulates the user typing something.

    • Note: Just to demonstrate that it is possible and exists in the API, but userEvent.type is more recommended to simulate a user events.
  • Also, I expect that after adding 2 elements (the first was already added when I declared the technologies array with a default value: React) the list length must be 3.

  • techList is a ul tag, and its children, a li.

  • If you have 3 children, the result is true and the test passes.

  • 🔬 3rd test

  it("should be able to delete one tech", () => {
    render(<Technologies />);

    const input = screen.getByTestId("input-add-tech");
    const form = screen.getByTestId("form-add-tech");

    userEvent.type(input, "React Native");
    fireEvent.submit(form);

    expect(screen.getByTestId("React Native")).toBeTruthy();

    const itemButton = screen.getByTestId("React Native-btn-delete");
    userEvent.click(itemButton);

    expect(screen.queryByTestId("React Native")).toBeNull();
  });
  • Render the Technologies component, take the input and use the data-testid.
  • I use userEvent.type to simulate the user filling the input with React Native and submit it using the fireEvent.submit.
    • Also, I check if it has been added and finally assign the item deletion button in the list item to the const itemButton.
    • The event is fired with the userEvent.click to the button.
    • I expect that the return of React Native is null:
      • <li data-testid = {tech} key = {tech}>
    • For each item rendered, the id will be the name of the technology.

Note: Ideally, it would be the technology id, but with this example, it's ok.

  • 🔬 4th test
it("button delete should be disabled only for React technology", () => {
    render(<Technologies />);

    const button = screen.getByTestId("React-btn-delete");
    expect(button).toBeDisabled();
  });

And finally, last and not least, we have the test that checks whether the button with the React item is disabled.

The news here is the toBeDisabled() method -> it checks if the button is disabled to pass the test.

Full code

🧑‍🎓 Conclusion

It is always good to know more about the methods, as well, the API to be able to implement them correctly. Kent Dodds, one of the maintainers of the RTL, wrote this article that talks about common mistakes when implementing and I heavily recommend to read it.