DEV Community

SeongKuk Han
SeongKuk Han

Posted on

React & Vitest Tutorial: Set Up and Test Examples with Todo App

React & Vitest Tutorial: Set Up and Test Examples with Todo App

I recently had an idea for a toy project that involved several number calculation functions. To ensure the accuracy and reliability of these functions, I knew it would be important to write test code. During my search for a suitable testing package for React, I came across Vitest and I decided to give it a try. However, I encountered a few challenges while setting it up.

In this post, I will guide you through the step-by-step process of setting up Vitest for a React project. Along the way, I will also share the issues I faced and how I resolved them. Additionally, we will explore writing test code by creating a simple Todo app.

Let's dive in and learn how to set up and test examples using React and Vitest.


Set Up Vitest

create_a_react_project_with_Vite

remove_unnecessary_files_in_the_project

run

To start off, I created a React project using Vite with Typescript and SWC. After the project was set up, I removed any unnecessary files and code, to focus on the essentials.

> pnpm install -D vitest jsdom
Enter fullscreen mode Exit fullscreen mode

install packages

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
  },
});
Enter fullscreen mode Exit fullscreen mode

Image description

Next, I installed two packages vitest and jsdom and I created a configuration file named vitest.config.ts in the project's root directory.

Within the configuration file, I specified the environment as json.

Even if you don't install jsdom, vitest let you know that you should install jsdom when you run your test code with the option jsdom.

...you can use browser-like environment through either jsdom or happy-dom instead.... Document

...
  "scripts": {
    "dev": "vite",
    "test": "vitest",
    "build": "tsc && vite build",
    "lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview"
  },
...
Enter fullscreen mode Exit fullscreen mode

package.json

To run our test code conveniently, I added a new script called test to the package.json file. This script allows us to execute our tests with the test command.

Let's write some test code to ensure our testing setup is functioning correctly.

display warning to the describe function in a test file

describe is not defined

During the test code writing process, you might encounter an error "describe is not defined", this issue can be resolved by adding the globals option to the configuration file.

... By default, vitest does not provide global APIs for explicitness... Document

describe is not defined error has gone

Although the previous error "describe is not defined" has been resolved, you may still encounter a linting error that says "Cannot find name 'describe'".

By installing the package @types/testing-library__jest-dom, we can ensure that the necessary type declarations for the testing library are available. To Install this package, run the following command:

> pnpm install -D @types/testing-library__jest-dom
Enter fullscreen mode Exit fullscreen mode

install the package

Once the package is installed, the linting error should disappear. Let's move forward and complete the test code.

describe('test code', () => {
  it('3 + 5 should be 8', () => {
    expect(3 + 5).toBe(8);
  });
});
Enter fullscreen mode Exit fullscreen mode

Test Success

The test has passed!
Now, let's move on to testing the App component.

In order to test React components, we will install the @testing-library/react package.

To install @testing-library/react, run the following command:

> pnpm install -D @testing-library/react
Enter fullscreen mode Exit fullscreen mode

install the package

We will write the following code and will test.

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

describe('test code', () => {
  it('3 + 5 should be 8', () => {
    expect(3 + 5).toBe(8);
  });

  it('App should be rendered', async () => {
    render(<App />);

    expect(await screen.findByText('App')).toBeInTheDocument();
  });
});
Enter fullscreen mode Exit fullscreen mode

test code

fail test

You might encountered the error message "Invalid Chai property: toBeInTheDocument". To address this error, we will install the @testing-library/jest-dom package, which provides addtional matchers for the Jest testing framework.

To install the package, run the following command:

> pnpm install -D @testing-library/jest-dom
Enter fullscreen mode Exit fullscreen mode

install the package

Once the installation is complete, we need to import the package to make use of it in our tests.

test passed

The test has passed!

Now, we're ready to write test code for components.
But there is one thing more we need to do. Importing the @testing-library/jest-dom package in every test file can be a tedious task. To simplify this, we will create a file named setup-test.ts in the root directory.

In setup-test.ts, import the @testing-library/jest-dom package like this:

import '@testing-library/jest-dom';
Enter fullscreen mode Exit fullscreen mode

import

And then, Add an option setupFiles in the configuration file.

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './setup-test.ts',
  },
});
Enter fullscreen mode Exit fullscreen mode

setupFiles option config

setup-test.ts

This setup file will automatically import the package before running each test file, simplifying out test code.

Path to setup files. They will be run before each test file. Document


Test Examples with Todo

In this section, we will create two components, Todo and TodoList, and write test code to verify their functionality. Let's begin with Todo.

Todo

import { ChangeEventHandler } from 'react';

interface TodoProps {
  todo?: string;
  checked?: boolean;
  onToggle?: ChangeEventHandler<HTMLInputElement>;
  onDelete?: VoidFunction;
}

const Todo = ({ todo, checked, onToggle, onDelete }: TodoProps) => {
  return <div>todo</div>;
};

export default Todo;
Enter fullscreen mode Exit fullscreen mode

Todo Component

I have defined the properties that I expected to need.

import { vi } from "vitest";
import { render, screen } from "@testing-library/react";
import Todo from ".";

describe("UI", () => {
  it("todo prop should be displayed", async () => {
    render(<Todo todo="hello" />);
    expect(await screen.findByText(/hello/)).toBeInTheDocument();
  });

  it("checked prop should be applied to the checkbox", async () => {
    let todo = render(<Todo checked={true} />);
    let input = await todo.container.querySelector<HTMLInputElement>("input");
    expect(input?.checked).toBe(true);

    todo = render(<Todo checked={false} />);
    input = await todo.container.querySelector<HTMLInputElement>("input");
    expect(input?.checked).toBe(false);
  });

  it("onToggle method should be called when click the input", async () => {
    const handleToggle = vi.fn();
    const todo = render(<Todo onToggle={handleToggle} />);

    (
      await todo.container.querySelector<HTMLButtonElement>(
        "input[type=checkbox]"
      )
    )?.click();
    expect(handleToggle).toBeCalled();
  });

  it("onDelete method should be called when click the delete", async () => {
    const handleDelete = vi.fn();
    const todo = render(<Todo onDelete={handleDelete} />);

    await (await todo.findByText(/delete/i)).click();
    expect(handleDelete).toBeCalled();
  });
});

Enter fullscreen mode Exit fullscreen mode

test failed

I wrote some test code to verify the expected behaviors.
If you check all the fails, let's complete the component code.

import { ChangeEventHandler } from 'react';

interface TodoProps {
  todo?: string;
  checked?: boolean;
  onToggle?: ChangeEventHandler<HTMLInputElement>;
  onDelete?: VoidFunction;
}

const Todo = ({ todo, checked, onToggle, onDelete }: TodoProps) => {
  return (
    <div>
      <p>{todo}</p>
      <input type="checkbox" checked={checked} onChange={onToggle} />
      <button onClick={onDelete}>Delete</button>
    </div>
  );
};

export default Todo;
Enter fullscreen mode Exit fullscreen mode

Test Passed

All the tests for the Todo component have passed. Now, let's move on to the TodoList component.


TodoList

We will create some functions as well as the TodoList component. In this component, I will generate a unique ID for each Todo Item using the uuid package.

To Install the package, you can use the following command:

> pnpm install uuid
> pnpm install -D @types/uuid
Enter fullscreen mode Exit fullscreen mode
export type TodoItem = {
  id: string;
  todo: string;
  checked: boolean;
};

const TodoList = () => {
  return <>TodoList</>;
};

// eslint-disable-next-line react-refresh/only-export-components
export function addTodo(todoList: TodoItem[], todoText: string): TodoItem[] {
  return [];
}

// eslint-disable-next-line react-refresh/only-export-components
export function deleteTodo(todoList: TodoItem[], id: string): TodoItem[] {
  return [];
}

// eslint-disable-next-line react-refresh/only-export-components
export function changeTodoChecked(
  todoList: TodoItem[],
  id: string,
  checked: boolean
): TodoItem[] {
  return [];
}

export default TodoList;
Enter fullscreen mode Exit fullscreen mode

TodoList code

Just like with Todo, I have defined types and functions that I expect to need for TodoList component.

import { RenderResult, render } from '@testing-library/react';
import TodoList, { TodoItem, addTodo, changeTodoChecked, deleteTodo } from '.';

const testAddTodo = async (todoList: RenderResult, text: string) => {
  const addBtn = await todoList.findByTestId('add');
  const addText = await todoList.findByTestId('newTodoInput');

  (addText as HTMLInputElement).value = text;
  addBtn.click();
};

describe('UI', () => {
  it('todos should be empty', async () => {
    const todoList = render(<TodoList />);
    const todoElmts = await todoList.findByTestId('todos');

    expect(todoElmts.querySelectorAll('div').length).toBe(0);
  });

  it('A todo should be created', async () => {
    const todoList = render(<TodoList />);

    await testAddTodo(todoList, 'new');

    const todosElmt = await todoList.findByTestId('todos');

    expect(todosElmt.querySelectorAll('div').length).toBe(1);

    const newTodo = await todosElmt.querySelector<HTMLElement>('div');
    const checkbox = await newTodo?.querySelector<HTMLInputElement>('input');

    expect(checkbox?.checked).toBe(false);
    expect(await todoList.findByText('new')).toBeInTheDocument();
  });

  it("A todo's checked should be toggled when the checkbox is clicked", async () => {
    const todoList = render(<TodoList />);

    await testAddTodo(todoList, 'new');

    const checkbox = await todoList.container.querySelector<HTMLInputElement>(
      'input[type=checkbox]'
    );

    await checkbox?.click();
    expect(checkbox?.checked).toBe(true);

    await checkbox?.click();
    expect(checkbox?.checked).toBe(false);
  });

  it('A todo should be deleted when the delete button is clicked', async () => {
    const todoList = render(<TodoList />);

    await testAddTodo(todoList, 'new1');
    await testAddTodo(todoList, 'new2');
    await testAddTodo(todoList, 'new3');

    const todosElmt = await todoList.findByTestId('todos');
    const todoElmtList = await todosElmt.querySelectorAll<HTMLDivElement>(
      'div'
    );

    expect(todoElmtList.length).toBe(3);
    expect(await todoList.findByText('new2')).toBeInTheDocument();
    await todoElmtList[1].querySelector('button')?.click();
    expect(await todoList.findByText('new1')).toBeInTheDocument();
    expect(await todoList.queryByText('new2')).not.toBeInTheDocument();
    expect(await todoList.findByText('new3')).toBeInTheDocument();
  });
});

describe('Functions', () => {
  it('addTodo should return a new todo list with a new item', () => {
    const todo = 'new';
    const newTodoList = addTodo([] as TodoItem[], todo);

    expect(newTodoList.length).toBe(1);
    expect(newTodoList[0].checked).toBe(false);
    expect(newTodoList[0].todo).toBe(todo);
    expect(newTodoList[0].id).toBeDefined();
  });

  it('deleteTodo should return a new todo without deleted the target item', () => {
    const todo = 'new';
    const oldTodoList = addTodo([] as TodoItem[], todo);

    expect(oldTodoList.length).toBe(1);

    const newTodoList = deleteTodo(oldTodoList, oldTodoList[0].id);

    expect(newTodoList.length).toBe(0);
    expect(oldTodoList).not.toEqual(newTodoList);
  });

  it('changeTodoChecked should return a new todo list with the checked property of the target item changed', () => {
    const todo = 'new';
    const oldTodoList = addTodo([] as TodoItem[], todo);

    expect(oldTodoList.length).toBe(1);

    const newTodoList = changeTodoChecked(oldTodoList, oldTodoList[0].id, true);

    expect(newTodoList[0].checked).toBe(true);
    expect(oldTodoList).not.toEqual(newTodoList);
  });
});
Enter fullscreen mode Exit fullscreen mode

Test Failed

In the test code, I divided it into two sections UI and Functions. Addtionally, I created a function called 'testAddTodo' to simplify simulating the process of adding a new item.

Note: The names UI and Functions are not following any rules. It's just what I made up.

expect(await todoList.queryByText("new2")).not.toBeInTheDocument();
Enter fullscreen mode Exit fullscreen mode

In the part of the code, I used queryByText instead of findByText. By using queryByTest, if the element is not found, it will return null instead of throwing an error. This eliminates the need to catch the error.

... Returns the matching node for a query, and return null if no elements match. This is useful for asserting an element that is not present... Document

Now, let's finish up the code for the TodoList component.

import { ChangeEventHandler, useRef, useState } from "react";
import { v4 as uuidV4 } from "uuid";
import Todo from "../Todo";

export type TodoItem = {
  id: string;
  todo: string;
  checked: boolean;
};

const TodoList = () => {
  const inputRef = useRef<HTMLInputElement>(null);
  const [todos, setTodos] = useState<TodoItem[]>([]);

  const handleTodoAdd = () => {
    const todoText = inputRef.current?.value ?? "";

    setTodos((prevTodos) => addTodo(prevTodos, todoText));
    if (inputRef.current) {
      inputRef.current.value = "";
    }
  };

  const handleTodoDelete = (id: string) => () => {
    setTodos((prevTodos) => deleteTodo(prevTodos, id));
  };

  const handleTodoToggle =
    (id: string): ChangeEventHandler<HTMLInputElement> =>
    (e) => {
      setTodos((prevTodos) =>
        changeTodoChecked(prevTodos, id, e.target.checked)
      );
    };

  return (
    <>
      <button data-testid="add" onClick={handleTodoAdd}>
        Add New Todo
      </button>
      <input data-testid="newTodoInput" type="text" ref={inputRef} />
      <hr />
      <div data-testid="todos">
        {todos.map(({ id, ...todo }) => (
          <Todo
            key={id}
            {...todo}
            onDelete={handleTodoDelete(id)}
            onToggle={handleTodoToggle(id)}
          />
        ))}
      </div>
    </>
  );
};

// eslint-disable-next-line react-refresh/only-export-components
export function addTodo(todoList: TodoItem[], todoText: string): TodoItem[] {
  return todoList.concat({
    id: uuidV4(),
    todo: todoText,
    checked: false,
  });
}

// eslint-disable-next-line react-refresh/only-export-components
export function deleteTodo(todoList: TodoItem[], id: string): TodoItem[] {
  return todoList.filter((todo) => todo.id !== id);
}

// eslint-disable-next-line react-refresh/only-export-components
export function changeTodoChecked(
  todoList: TodoItem[],
  id: string,
  checked: boolean
): TodoItem[] {
  return todoList.map((todo) => {
    const newTodo = { ...todo };
    if (newTodo.id === id) {
      newTodo.checked = checked;
    }

    return newTodo;
  });
}

export default TodoList;
Enter fullscreen mode Exit fullscreen mode

All test passed

All tests have passed!


Result

import TodoList from "./components/TodoList";

function App() {
  return <TodoList />;
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Result with dev server


Conclusion

While setting up the test environment, I came across several articles. It was not easy to find all the information I needed in one place, which is why I decided to write this post.

While there are many other aspects to consider in a real project, I hope you found this post helpful.

Happy Coding!

Top comments (3)

Collapse
 
tejasgk profile image
Tejas

Hey , is there any way I can test my APIs using vitest and RTL

Collapse
 
lico profile image
SeongKuk Han

I don't have experiences for that yet, so I need to research. Thanks for the idea, I will write a post about testing APIs using RTL. Sorry, I can't give you any ideas now.

Collapse
 
qleoz12 profile image
qleoz12

how about you research?, im loking for something similar and api calls,I found your post, it was usefull