Introduction

In this tutorial, we shall setup Jest and React Testing Library to perform integration test. We will be testing React.Js components, so this will not be an end to test, but we will be testing the individual coponents involved in the Todo App. At each stage, we will render the components invovled using the library functions provided by React Testing Library. We shall pass in properties to each component and test their behavious and visualisation by simulating clicks and text typing into textboxes using the appropriate functions. Jest will be used as our test runner. These two libraries are independent but work very well together. So let’s get started.

Initializing Vite Project

First, let’s initialize a new Vite project with React and TypeScript templates.

npm create vite@latest jest-todo-app-test --template react-ts
cd jest-todo-app-test
npm install

Setting Up Jest

Next, we need to install Jest and its related dependencies:

npm install --save-dev jest @testing-library/react @testing-library/jest-dom @types/jest ts-jest babel-jest jest-environment-jsdom @babel/preset-env @babel/preset-react @babel/preset-typescript
  • jest: Jest testing framework.
  • @testing-library/react: React Testing Library for testing React components.
  • @testing-library/jest-dom: Custom Jest matchers for testing DOM nodes.
  • @types/jest: TypeScript definitions for Jest.
  • ts-jest: TypeScript preprocessor for Jest.
  • babel-jest: Babel transformer for Jest.

To run the test globally install it globally like:

npm install jest --global

Create a Jest configuration file (jest.config.js) in the root of your project:

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
  moduleNameMapper: {
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
  },
  setupFilesAfterEnv: ['./src/setupTests.ts'],
  transform: {
    '^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
  },
};
  • preset: 'ts-jest': Use ts-jest preset for TypeScript.
  • testEnvironment: 'jsdom': Use jsdom environment for simulating browser behavior in tests.
  • moduleNameMapper: Map CSS imports to identity-obj-proxy to handle CSS modules in tests.
  • setupFilesAfterEnv: Specify setup files to be run after the test framework is installed.
  • transform: Use babel-jest to transform JavaScript and TypeScript files.

Create a Babel configuration file (babel.config.js) in the root of your project:

module.exports = {
  presets: [
    '@babel/preset-env',
    '@babel/preset-react',
    '@babel/preset-typescript',
  ],
};
  • @babel/preset-env: Transpile modern JavaScript to be compatible with older environments.
  • @babel/preset-react: Transpile JSX to JavaScript.
  • @babel/preset-typescript: Transpile/converts TypeScript to JavaScript.

Create a setupTests.ts file in the src directory:

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

Writing the Todo App

Let’s create a simple Todo app. First, create the necessary components and types.

src/types.ts

export interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

This is a typescript interface to represent a single todo

Create a todo item in src/components/TodoItem.tsx

import React, { useState } from "react";
import { Todo } from "../types";

interface TodoItemProps {
  todo: Todo;
  toggleTodo: (id: number) => void;
  deleteTodo: (id: number) => void;
  editTodo: (id: number, text: string) => void;
}

const TodoItem: React.FC<TodoItemProps> = ({
  todo,
  toggleTodo,
  deleteTodo,
  editTodo,
}) => {
  const [isEditing, setIsEditing] = useState(false);
  const [newText, setNewText] = useState(todo.text);

  const handleEdit = () => {
    setIsEditing(true);
  };

  const handleSave = () => {
    editTodo(todo.id, newText);
    setIsEditing(false);
  };

  return (
    <li>
      {isEditing ? (
        <input
          type="text"
          value={newText}
          onChange={(e) => setNewText(e.target.value)}
        />
      ) : (
        <span
          style={{ textDecoration: todo.completed ? "line-through" : "none" }}
          onClick={() => toggleTodo(todo.id)}
        >
          {todo.text}
        </span>
      )}
      {isEditing ? (
        <button onClick={handleSave}>Save</button>
      ) : (
        <button onClick={handleEdit}>Edit</button>
      )}
      <button onClick={() => deleteTodo(todo.id)}>Delete</button>
    </li>
  );
};

export default TodoItem;

Then create a todos list component in src/components/TodoList.tsx

import React from "react";
import { Todo } from "../types";
import TodoItem from "./TodoItem";

interface TodoListProps {
  todos: Todo[];
  toggleTodo: (id: number) => void;
  deleteTodo: (id: number) => void;
  editTodo: (id: number, text: string) => void;
}

const TodoList: React.FC<TodoListProps> = ({
  todos,
  toggleTodo,
  deleteTodo,
  editTodo,
}) => {
  return (
    <ul>
      {todos.map((todo) => (
        <TodoItem
          key={todo.id}
          todo={todo}
          toggleTodo={toggleTodo}
          deleteTodo={deleteTodo}
          editTodo={editTodo}
        />
      ))}
    </ul>
  );
};

export default TodoList;

Create a component for testing src/components/AddTodo.tsx

import React, { useState } from "react";

interface AddTodoProps {
  addTodo: (text: string) => void;
}

const AddTodo: React.FC<AddTodoProps> = ({ addTodo }) => {
  const [text, setText] = useState("");

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!text.trim()) return;
    addTodo(text);
    setText("");
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
      />
      <button type="submit">Add Todo</button>
    </form>
  );
};

export default AddTodo;

Now modify the main app file in src/App.tsx

import React, { useState } from "react";
import { Todo } from "./types";
import TodoList from "./components/TodoList";
import AddTodo from "./components/AddTodo";

let nextId = 0;

const App: React.FC = () => {
  const [todos, setTodos] = useState<Todo[]>([]);

  const addTodo = (text: string) => {
    setTodos([...todos, { id: nextId++, text, completed: false }]);
  };

  const toggleTodo = (id: number) => {
    setTodos(
      todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };

  const deleteTodo = (id: number) => {
    setTodos(todos.filter((todo) => todo.id !== id));
  };

  const editTodo = (id: number, text: string) => {
    setTodos(todos.map((todo) => (todo.id === id ? { ...todo, text } : todo)));
  };

  return (
    <div>
      <h1>Todo List</h1>
      <AddTodo addTodo={addTodo} />
      <TodoList
        todos={todos}
        toggleTodo={toggleTodo}
        deleteTodo={deleteTodo}
        editTodo={editTodo}
      />
    </div>
  );
};

export default App;

Writing Tests

Now let’s write tests for our components and logic.

src/components/TodoItem.test.tsx

import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import TodoItem from './TodoItem';
import { Todo } from '../types';

test('renders a todo item and toggles it', () => {
  const todo: Todo = { id: 1, text: 'Learn Jest', completed: false };
  const toggleTodo = jest.fn();

  render(<TodoItem todo={todo} toggleTodo={toggleTodo} />);
  const todoItem = screen.getByText(/learn jest/i);
  expect(todoItem).toBeInTheDocument();
  expect(todoItem).toHaveStyle('text-decoration: none');

  fireEvent.click(todoItem);
  expect(toggleTodo).toHaveBeenCalledWith(todo.id);
});
  • render: Render the component.
  • screen.getByText: Select an element by its text content.
  • expect: Assertion library provided by Jest.
  • fireEvent.click: Simulate a click event.

src/components/TodoList.test.tsx

import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import TodoList from "./TodoList";
import { Todo } from "../types";

test("renders a list of todos and toggles them", () => {
  //Here, in the todoslist, we have two todos, we expect that the functions passed in to the component,
  // gets called with the correct id which corresponds to the id of the clicked todo.
  const todos: Todo[] = [
    { id: 1, text: "Learn Jest", completed: false },
    { id: 2, text: "Learn React Testing Library", completed: false },
  ];
  const toggleTodo = jest.fn();
  const deleteTodo = jest.fn();
  const editTodo = jest.fn();

  render(<TodoList todos={todos} toggleTodo={toggleTodo} deleteTodo={deleteTodo} editTodo={editTodo} />);
  const todoItems1 = screen.getByText(todos[0].text);
  const todoItems2 = screen.getByText(todos[1].text);
  const todoItems = screen.getAllByRole("listitem");
  expect(todoItems).toHaveLength(2);

  fireEvent.click(todoItems1);
  expect(toggleTodo).toHaveBeenCalledWith(1);

  fireEvent.click(todoItems2);
  expect(toggleTodo).toHaveBeenCalledWith(2);
});

src/components/AddTodo.test.tsx

import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import AddTodo from "./AddTodo";

test("adds a new todo item", () => {
  // Here in the AddTodo component,
  // we simulate etering text into the input field
  // Then we simulate a click on the add todo button
  // After that, we will assert/check if the addTodo function we
  // passed a a prop to the component is called with the
  // argument text containing the text which was entered into the text input
  const addTodo = jest.fn();

  render(<AddTodo addTodo={addTodo} />);
  const input = screen.getByRole("textbox");
  const button = screen.getByRole("button", { name: /add todo/i });

  fireEvent.change(input, { target: { value: "Learn Jest" } });
  fireEvent.click(button);

  expect(addTodo).toHaveBeenCalledWith("Learn Jest");
  expect(input).toHaveValue("");
});

src/App.test.tsx

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

// Testing the App Component
// This tests simulates entering text in the add text box
// then trigger a click on the add todo button
// We then expect a new todo to appear with the text we used
//to type into the textbox
test("adds and toggles todo items", () => {
  render(<App />);
  const input = screen.getByRole("textbox");
  const button = screen.getByRole("button", { name: /add todo/i });

  fireEvent.change(input, { target: { value: "Learn Jest" } });
  fireEvent.click(button);

  const todoItem = screen.getByText(/learn jest/i);
  expect(todoItem).toBeInTheDocument();
  expect(todoItem).toHaveStyle("text-decoration: none");

  fireEvent.click(todoItem);
  expect(todoItem).toHaveStyle("text-decoration: line-through");
});

// Here, we create a todo in the list using the simulation as done above,
// But instead of just check ing for its existence, we will trigger a
// click on it's delete button
// Afterwards, we will assert that the todo no longer exists in the document
test('deletes a todo item', () => {
  render(<App />);
  const input = screen.getByRole('textbox');
  const button = screen.getByRole('button', { name: /add todo/i });

  fireEvent.change(input, { target: { value: 'Learn Jest' } });
  fireEvent.click(button);

  const deleteButton = screen.getByText(/delete/i);
  fireEvent.click(deleteButton);

  expect(screen.queryByText(/learn jest/i)).not.toBeInTheDocument();
});

// Here, we also simulate creating a todo
// then we wll change the text using the change function
// Then we will click on the edit button, which will move the todo
// into edit mode
// Upon changing the text, we will trigger the save button which should
// update the todos text in the list
// We will then confirm/assert that the newly entered text is in the document
test('edits a todo item', () => {
  render(<App />);
  const input = screen.getByRole('textbox');
  const button = screen.getByRole('button', { name: /add todo/i });

  fireEvent.change(input, { target: { value: 'Learn Jest' } });
  fireEvent.click(button);

  const editButton = screen.getByText(/edit/i);
  fireEvent.click(editButton);

  const editInput = screen.getByDisplayValue(/learn jest/i);
  fireEvent.change(editInput, { target: { value: 'Learn React Testing' } });
  const saveButton = screen.getByText(/save/i);
  fireEvent.click(saveButton);

  expect(screen.getByText(/learn react testing/i)).toBeInTheDocument();
});
  • screen.getByRole: Select an element by its role, for example button or checkbox.
  • screen.queryByText: Select an element by its text content, returning null if not found.

Running Tests

To run the tests, add a script to your package.json:

"scripts": {
  "test": "jest"
}

Now you can run your tests using:

npm test

This runs all tests

To run an individual test use the globally installed one on the commandline like:

jest src/components/TodoItem.test.tsx

But replace the file above with our choice of test file

Possible Error

If you get errors about babel.config.js and or jest.config.js, rename both files and change their file endings to babel.config.cjs and jest.config.cjs respectfully.

Conclusion

This tutorial covers setting up Jest for a React.js application using TypeScript and Vite, along with writing tests for various components and the main application logic. This setup ensures that your application is well-tested and reliable.