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: https://github.com/esau-morais/testing-irl

Source: https://emmorais.hashnode.dev/