Unit Testing: A Hands-On Guide with Real Examples - React + Vitest (p.1)

Tássio - May 10 - - Dev Community

In the world of software development, unit testing is a vital practice that ensures our code behaves as expected. It's the safety net that catches bugs before they sneak into the final product. But understanding the theory is one thing, applying it is another. That's why in this article, we're going to roll up our sleeves and dive into the practical side of unit testing.

For this first article (yes, I'm thinking of creating more steps later 👀) we are going to see my EditableTypography component. Let's see it working to understand better:

EditableTypography workin

Cool, isn't it?! You can even put your hands on it by accessing my project

Stack and source code

  • React
  • Typescript
  • Vitest
  • testing-library

Great! Let's see the source code (by the way, the project is open source):



// ...more code
const EditableTypography = ({
  label,
  tag,
  className,
  updateText, // on update text function
}: EditableTypographyTypes): JSX.Element => {
  const [isEdit, setEdit] = useState(label === '');
  const [newText, setNewText] = useState(label);

  const handleResetState = () => {
    setEdit(false);
    setNewText(label);
  };

  const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
    const key = event.key;
    const isEsc = key === 'Escape';
    const isEnter = key === 'Enter';

    if (!newText) return;

    if (isEsc) {
      handleResetState();
      return;
    }

    if (!isEnter) {
      return;
    }

    updateText(newText);
    setEdit(false);
  };

  return (
    <>
      {isEdit ? (
        <Styles.TextInput // styling with styled-components
          // this will be used later to select the input on the test
          data-testid="editable__input"
          label=""
          value={newText}
          placeholder="Edit me"
          onKeyDown={onKeyDown}
          onChange={(event) => setNewText(event.target.value)}
          title="Press Esc or click outside to cancel"
        />
      ) : (
        <Styles.Typography
          onClick={() => setEdit(true)}
          // this will be used later to select the Typography block on the test
          // I'm using diff selectors to teaching purposes
          title="Click to edit"
        >
          <Typography
            tag={tag}
            label={label || 'editing...'}
            className={className}
          />
        </Styles.Typography>
      )}
    </>
  );
};



Enter fullscreen mode Exit fullscreen mode

OBS: I'm removing some pieces of the code to make it clear. It uses a Typography component and applies the Open-Closed Principle (from SOLID) by extending Typography functionalities instead of including the edit mode to Typography responsibilities.

Defining the test cases

There are two alternatives to define the cases we must test: imperatively and declaratively.

The first one is by detecting the conditions/logic in the code and the another is focused on the component behavior (usually defined with/by the UX team).

Both approaches have pros and cons and might complement each other depending on the case, but the declarative approach tends to create more descriptive tests in components case. In this article, let's go deeper into the declarative one, but also there is a section about imperative.

Imperative approach (short explanation)

On the imperative approach, I usually split out the source code to get what test. Generally, splitting the component code into two pieces before defining the tests:

  • nodes conditions* (every condition in the node block)
  • helpers/functions* conditions (every condition out of the node block)

*conditions are every logic in our code that can create different behaviors. It is represented by logic operators such as if, else if, &&, ||, >, <, ===, and so on. Once the idea of the unit test is to ensure our code behaves as expected, we must guarantee that the logic created is working as we want it to.

Imperative approach - Nodes conditions

Focusing on the node block, there are 2 operators:

  • ternary operator (?:): to validate which node is rendered by the isEdit flag;
  • OR operator (||): to show a fallback ("editing...") while the label is "" (it is used to handle async operations)


return (
    <>
      {isEdit ? ( // ternary operator
        <Styles.TextInput // styling with styled-components
          data-testid="editable__input"
          label=""
          value={newText}
          placeholder="Edit me"
          onKeyDown={onKeyDown}
          onChange={(event) => setNewText(event.target.value)}
          title="Press Esc or click outside to cancel"
        />
      ) : (
        <Styles.Typography onClick={() => setEdit(true)} title="Click to edit">
          <Typography
            tag={tag}
            label={label || 'editing...'} // OR operator
            className={className}
          />
        </Styles.Typography>
      )}
    </>
  );


Enter fullscreen mode Exit fullscreen mode

Even the ternary and OR operator have two cases for cover:

  • isEdit can be true or false
  • label can be "" or different of ""

So, in the code block, there are 4 cases to cover. Does it mean 4 test cases are required to cover the code block? Not necessarily, once more than one condition can be executed together. In this case, for example:

  • If the isEdit is true, the label is not rendered (1 condition);
  • if the isEdit is false, the label can be "" or diff of "" (mode 2 conditions, once they are executed together)

Resuming, we could create 3 test cases to cover the four conditions.

The same should be applied to the helper/function block, but now we will define the cases using the declarative approach and from there create the tests.


Enjoying it? If so, don't forget to give a ❤️ and follow me to keep updated. Then, I'll continue creating more content like this


Declarative approach

On the declarative approach, the test cases are created based on the component behaviors (usually, defined before it being created and with/by the Ux team). Those behaviors can be divided into 3 categories:

  • Empty state: does it have props that can start "empty" (without value)?
  • Failure state: can it get an error?
  • Happy state: how should it behave?

In the EditableTypography case, there is no Failure state, but there are even Empty and Happy states. So, let's define them:

Empty state behavior:

  • (case1) On the first render, the component should show a placeholder "Edit me" as the label is empty;

On the first render, the component should show a placeholder

  • (case2) On the first edit, the component should show a fallback text "editing..." while label is empty;

On the first edit, the component should show a fallback text

Happy state behavior:

  • (case3) When the label has a value and the component is not in the edit mode, the component should render the label value;

When the  raw `label` endraw  has a value and the component is not in the edit mode, the component should render the  raw `label` endraw  value

  • (case4) When it enters the edit mode, the component should close the edit mode if the user has clicked on the Esc button and not set the new value;

When it enters the edit mode, the component should close the edit mode if the user has clicked on the Esc button and not set the new value

  • (case5) When it enters the edit mode, the component should close the edit mode if the user has clicked on the enter button and has a value to be set;

When it enters the edit mode, the component should close the edit mode if the user has clicked on the enter button and has a value to be set

OBS: It could also be used to use the TDD approach: where the tests are created before the development has started.

Cool! Once defined the component behaviors, it is possible to create the test cases. First, let's create the titles. It is a convention to start with "should" (it is not required, obviously) and I'll use it. We are going to use the behaviors defined so far to create the test titles:



it.todo('Should show a placeholder as the label is empty - first render') // case1

it.todo('Should show a fallback text "editing..." while label is empty - first render') // case2 

it.todo('Should render label as it is not edit mode') // case3

it.todo('Should close the edit mode if the user has clicked on the Esc button and not set the new value') // case4

it.todo('Should close the edit mode if the user has clicked on the enter button and has a value to be set') // case5


Enter fullscreen mode Exit fullscreen mode

Cases defined, let's create the tests (finally!!)!

It starts by importing and setting which is required for all tests.



import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { vi } from 'vitest';

import EditableTypography from './EditableTypography';

const user = userEvent.setup(); // userEvent instance to simulate user events (such as clicks and input data)


Enter fullscreen mode Exit fullscreen mode

TIP: For the test creation, you can also think that once there is an input (WHEN) it should have an output (THEN/ASSERTION). For example:

WHEN label is empty and it is first render (user has not set data) THEN it should render the placeholder

Which comes after WHEN is the component's props and mocks, which comes after THEN is what ensures it has happened as expected


case1:



// ...more code

 it('Should show a placeholder as the label is empty - first render', () => {
    // WHEN label is empty and it is first render (user has not set data)
    const updateTextMock = vi.fn();
    render(
      <EditableTypography
        label="" // label is empty
        tag="h1"
        className="testClass"
        updateText={updateTextMock}
      />
    );

    // selecting the input on the page
    const input = screen.getByTestId('editable__input') as HTMLInputElement;

    // THEN it should render the placeholder (we ensure it by expecting input has the placeholder)
    expect(input).toBeInTheDocument();
    expect(input.placeholder).toBe('Edit me');
  });


Enter fullscreen mode Exit fullscreen mode

case2:



// ...more code

it('Should show a fallback text "editing..." while label is empty - first render', async () => {
    // WHEN label is empty and user has updated the value
    const updateTextMock = vi.fn();
    render(
      <EditableTypography
        label="" // label is also empty
        tag="h1"
        className="testClass"
        updateText={updateTextMock}
      />
    );

    const inputElement = screen.getByTestId('editable__input');

    //typing New title on the input and clicking on enter btn
    await user.type(inputElement, 'New Title{enter}');

    // THEN it should call the setter function (updateTextMock)
    // and show 'editing...' text on the scree
    expect(updateTextMock).toHaveBeenCalledWith('New Title');
    expect(screen.getByText('editing...')).toBeInTheDocument();

    // OBS: as updateText can be async, it might not update the label immediately.
    // Once the updateTextMock is only a mock without handling the state, it will not update the label,
    // which is totally fine to the test once updateText logic is outside EditableTypography responsibilities.
  });


Enter fullscreen mode Exit fullscreen mode

case3:



// ...more code

it('Should render label as it is not edit mode', () => {
    // WHEN label has value
    const updateTextMock = vi.fn();
    render(
      <EditableTypography
        label="Old title" // now it has a value
        tag="h1"
        className="testClass"
        updateText={updateTextMock}
      />
    );

    // it is using queryByTestId instead of getByTestId
    // because query* is recommend to case where the element is not in the page
    // (and also get* does not work in this cases xP)

    const inputElement = screen.queryByTestId('editable__input');

    // THEN show the label value and do not show the input
    expect(screen.getByText('Old title')).toBeInTheDocument();
    expect(inputElement).not.toBeInTheDocument();
  });


Enter fullscreen mode Exit fullscreen mode

case4:



// ...more code

it('Should close the edit mode if the user has clicked on the Esc button and not set the new value', async () => {
    // WHEN label has value and user has clicked on shift btn
    const updateTextMock = vi.fn();
    render(
      <EditableTypography
        label="Old title"
        tag="h1"
        className="testClass"
        updateText={updateTextMock}
      />
    );

    const typographyElement = screen.getByTitle('Click to edit');

    // it enters the edit mode
    await user.click(typographyElement);

    const inputElement = screen.getByTestId('editable__input');

    // it adds "New Title" and tries to click on shift (shift btn does not do anything - only enter)
    await user.type(inputElement, 'New Title{shift}');

    // THEN the edit mode keeps on
    expect(updateTextMock).not.toHaveBeenCalled();
    expect(inputElement).toBeInTheDocument();

    // WHEN label has value and user has clicked on esc btn
    await user.type(inputElement, '{escape}');

    // THEN the edit mode has closed and the old label value is showed
    expect(updateTextMock).not.toHaveBeenCalled();
    expect(inputElement).not.toBeInTheDocument();
    expect(screen.getByText('Old title')).toBeInTheDocument();
  });


Enter fullscreen mode Exit fullscreen mode

case5:



// ...more code

it('Should close the edit mode if the user has clicked on the enter button and has a value to be set', async () => {
    // WHEN label has value and user has updated the value
    const updateTextMock = vi.fn();
    render(
      <EditableTypography
        label="Old title"
        tag="h1"
        className="testClass"
        updateText={updateTextMock}
      />
    );

    const typographyElement = screen.getByTitle('Click to edit');

    // it enters the edit mode
    await user.click(typographyElement);

    const inputElement = screen.getByTestId('editable__input');

    // it clears the input (once the there is Old title label)
    await user.clear(inputElement);
    //  it adds "New Title" and enters
    await user.type(inputElement, 'New Title{enter}');

    // THEN the edit mode has closed and the new label value is shown
    expect(inputElement).not.toBeInTheDocument();
    expect(updateTextMock).toHaveBeenCalledWith('New Title');
  });



Enter fullscreen mode Exit fullscreen mode

Nice!! We did it!

Checking the local coverage report file

If the tester engine has coverage configured, it generates a report file with information regarding code coverage (it means, how much the test created validates that component's logic). It is helpful to ensure that the tests created are validating the logic in the component.

Generally, this file (there are different formats, such as .html) is in the ./coverage folder at the project's root.

coverage report

What a great! We have got 100% code coverage (I might create a article regarding coverage in the future 👀).


Buy me a coffee ☕. Hope I have helped you somehow. 🤗


Next steps

In the next article (in progress), let's explore more behaviors to the EditableTypography: click outside (clicking outside might close the edit mode) and focus/select(focus in the text field and select all label text when entered in the edit mode)

Follow me to let you know.

You can find the final file version here

See my other articles and my open source project

. . . . . . . . . .