SeongKuk Han
Posted on June 17, 2023
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
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
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
},
});
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"
},
...
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.
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
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
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);
});
});
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
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();
});
});
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
Once the installation is complete, we need to import the package to make use of it in our tests.
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';
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',
},
});
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;
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();
});
});
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;
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
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;
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);
});
});
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();
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;
All tests have passed!
Result
import TodoList from "./components/TodoList";
function App() {
return <TodoList />;
}
export default App;
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!
Posted on June 17, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.