React & Vitest Tutorial: Set Up and Test Examples with Todo App
Tags: react, tutorial, beginners, vitest
Published: 2023-06-17 21:55:35

> 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/qzr5kmxwc4ggrqlnwbpc.png)


![remove_unnecessary_files_in_the_project](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ksvjk5p3m706o6l0lmgg.png)


![run](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/un2pb2umeqql9yoqrgba.png)

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.

```console
> pnpm install -D vitest jsdom
```

![install packages](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vei7xk2ob0luyevh7jty.png)

```typescript
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
  },
});
```

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tbjwt4vc08007cbuoz7b.png)

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](https://vitest.dev/config/#environment)

```typescript
...
  "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"
  },
...
```

![package.json](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/w8jxooaxylzlfh8uw45n.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/asewx9awtbjc6xn35yqa.png)

![describe is not defined](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/exte3fi3k6cskywqwzjy.png)

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](https://vitest.dev/config/#globals)

![describe is not defined error has gone](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sgujhakn39mdgx25gcjy.png)

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:

```console
> pnpm install -D @types/testing-library__jest-dom
```

![install the package](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5laqjlu0fkny5n3gwrex.png)

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

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

![Test Success](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6ifzd9ne6zhqchcwvv5b.png)

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:

```console
> pnpm install -D @testing-library/react
```

![install the package](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/75b2uaddzh2tmny49o55.png)

We will write the following code and will test.

```typescript
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();
  });
});
```

![test code](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/agkwv3yjb7uqo0knzp0b.png)

![fail test](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/k10w4vc3pkw0p9pm0ajt.png)

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:

```console
> pnpm install -D @testing-library/jest-dom
```

![install the package](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/s6vbglxn2la8lq2zddf8.png)

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

![test passed](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ocq5j8r299qsxl5f16ns.png)

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:

```typescript
import '@testing-library/jest-dom';
```

![import](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ohonyo06xun22suumphb.png)

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

```typescript
import { defineConfig } from 'vitest/config';

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

![setupFiles option config](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jv5n3co7h1qy244ftm5s.png)

![setup-test.ts](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/03sfye2pvd34rhl9cn0z.png)

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](https://vitest.dev/config/#setupfiles)

---

## 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

```typescript
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;
```

![Todo Component](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/phgfa64z5dmou6s2f3pa.png)

I have defined the properties that I expected to need.

```typescript
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();
  });
});

```

![test failed](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9y2zdz49ub2vnxwbs718.png)

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

```typescript
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;
```

![Test Passed](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vpc3itcw8f2y4sttpsur.png)

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:

```console
> pnpm install uuid
> pnpm install -D @types/uuid
```

```typescript
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;
```

![TodoList code](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/kyt4ku7hs8wp6zwr9yeo.png)

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

```typescript
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);
  });
});
```

![Test Failed](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/i8t4w2hm1f1lbockmcca.png)

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.*

```typescript
expect(await todoList.queryByText("new2")).not.toBeInTheDocument();
```

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](https://testing-library.com/docs/queries/about/#types-of-queries)

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

```typescript
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;
```

![All test passed](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/k8t6plkjn6kjjconijm0.png)

All tests have passed!

---

### Result

```typescript
import TodoList from "./components/TodoList";

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

export default App;
```

![Result with dev server](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/b5bp685fjwwvdqtly69k.png)

---

## 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!