Search⌘ K
AI Features

TDD Lifecycle Revisited

Explore writing your first unit tests for React components with Jest and @testing-library. Understand how to render components, simulate user events, use mock functions, and verify component behavior to confidently build testable React apps.

Our first unit test

Once we have our first integration test failing, it is time to write our first unit tests.

Unit tests are designed to test system components in isolation.

To make isolation of system components possible, we will create multiple components: TaskInput to input new tasks and TaskList to display a list of tasks.

Create a folder called components in src and create a file called TaskInput.test.js. This file will hold tests for the TaskInput component, which we have yet have to create.

Now, we will learn some more Jest functions. If you recall, we defined our integration tests with the test function. This is also possible for unit tests. But we are going to learn one more approach, and then you can decide whichever approach you can prefer.

Another way of organizing tests is by using the describe and it functions. describe is a function to group multiple tests together, and it is the same as the test function. Here is how they could be used:

describe('<SomeComponent />', () => {
  it('renders', () => {...});
  it('shows label', () => {...});
  it('handles input', () => {...});
});

The big advantage of this approach is readability. This snippet is very clear and easy to read. And when any of these tests fail, Jest will provide a helpful message, letting you know exactly what went wrong.

Now, you can write the structure for your first test. First, we will test that TaskInput renders an input element:

describe('<TaskInput />', () => {
    it('renders an input', () => {

    });
});

Now, how do we actually test that input is rendered? For this, @testing-library is used.

@testing-library

This is a family of libraries used to unit test web apps. The React version of it is called @testing-library/react. This is included by default in apps created with create-react-app, so there is no additional setup necessary.

This library exports many things, but we are interested mostly in render and screen.

  • render is a function that renders. You use it like this: render(<div />).
  • screen is an object that represents a browser screen. Whatever you render with render is made available from screen. You can query stuff from it like this: screen.queryByText(...).

To use these, you must import them. We will also import React as it is needed for the JSX syntax:

import React from 'react'

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

Now we can fill in the test body:

describe('<TaskInput />', () => {
    it('renders an input', () => {
        render(<TaskInput />);
        expect(screen.queryByPlaceholderText('Enter new task')).toBeInTheDocument();
    });
});

First, we render the component. It does not have props so far, so do not worry about them. Next, we try to query ByPlaceholderText to get the input element. Lastly, we assert that it is inTheDocument. Here is the entire project so far:

import {getDriver} from './helpers';
import {until, By, Key} from 'selenium-webdriver';

let driver;

beforeAll(() => {
    driver = getDriver();
});

afterAll(async () => {
    await driver.quit();
});

test('should create tasks', async  () => {
    await driver.get('http://localhost:3000');
    await driver.wait(until.elementLocated(By.xpath("//input[@placeholder='Enter new task']")), 1000);
    await driver.findElement(By.xpath("//input[@placeholder='Enter new task']"))
        .sendKeys('new todo!' + Key.ENTER);

    await driver.wait(until.elementLocated(By.xpath("//*[text()='new todo!']")), 1000);
});

Try running it, and see the error message from unit tests. It will let you know that the test failed due to TaskInput.js not being found, which is precisely what we are looking for. Now we reached step 2 of the TDD process; we have a failing unit test that needs fixing.

To fix it, create a file TaskInput.js with an empty component:

import React from 'react';

const TaskInput = () => null;

export default TaskInput;

This component does nothing except fix the import error. If you try running the tests now, you will get a different error:

<TaskInput /> › renders an input

    expect(received).toBeInTheDocument()

    received value must be an HTMLElement or an SVGElement.
    Received has value: null

We can see that now @testing-library can successfully render our component, but does not seem to find the input on it. Let’s fix that:

const TaskInput = () => <input placeholder={'Enter new task'}/> ;

Lastly, the test is fully passing!

Write more tests

Of course, this component is still useless. One of the ways to get the submitted value from it would be a callback function. Let’s write one more test. It will enter some text into the input, and submit it. Then, it will validate that TaskInput fires the appropriate callback. We will be dealing with user-generated events here as well as waiting for a re-render, so add these to your import:

import {fireEvent, render, screen, wait} from '@testing-library/react'

In the code, we first define a mock callback:

const mockOnSubmit = jest.fn();

jest.fn() returns a special mock function. It doesn’t do anything, but it remembers every invocation and lets us validate them later. Next, we render the component:

render(<TaskInput onSubmit={mockOnSubmit} />);

We supply the mock function to the onSubmit prop (not yet created). To make use of it, we must find the input element, and send some text to it:

const inputNode = screen.getByPlaceholderText('Enter new task');
fireEvent.change(inputNode, {target: {value: 'new task!'}});
fireEvent.submit(inputNode);

fireEvent is the object we imported earlier. It lets us apply custom events (such as onChange and onSubmit) to components. We first enter text for a new task and then submit (press enter). Lastly, we validate that the callback function was called correctly:

await wait(() => expect(mockOnSubmit).toHaveBeenCalledWith('new task!'));

This line waits until the supplied assertion goes through. Waiting is required because React will do a few re-renders while processing the input and we may get a race condition.

Our second test is done! Here is the project so far:

import {getDriver} from './helpers';
import {until, By, Key} from 'selenium-webdriver';

let driver;

beforeAll(() => {
    driver = getDriver();
});

afterAll(async () => {
    await driver.quit();
});

test('should create tasks', async  () => {
    await driver.get('http://localhost:3000');
    await driver.wait(until.elementLocated(By.xpath("//input[@placeholder='Enter new task']")), 1000);
    await driver.findElement(By.xpath("//input[@placeholder='Enter new task']"))
        .sendKeys('new todo!' + Key.ENTER);

    await driver.wait(until.elementLocated(By.xpath("//*[text()='new todo!']")), 1000);
});

Now time for your first real exercise. In the environment above (or your local PC), try to fix the test on your own.

The solution will be in the next lesson, but it’s important to try this on your own first.

Hint: you will need to use useState, and a form tag.

Quick recap

In this lesson, we wrote two unit tests. The first one rendered the TaskInput component and checked that it renders an input. The second one sent some input to it and checked if the callback was firing. We used the following concepts from Jest and testing-library:

  • describe and it to group tests.
  • render to render components.
  • screen to access rendered components.
  • fireEvent to fire user events.
  • jest.fn() to create a mock function.