React Unit Testing: A Complete Guide with Jest & Testing Library - Part 2: Configuring Your React Project for Unit Testing
João
- •
- 08 MIN TO READ
React Unit Testing: A Complete Guide with Jest & Testing Library - Part 3: Testing Components with Jest
📖 10 min read | 💻 Intermediate | ⚡ With Code Examples
This guide assumes you've followed Part 2 for setting up your testing environment React Unit Testing: A Complete Guide with Jest & Testing Library - Part 2: Configuring Your React Project for Unit Testing.
In this third and final part, we'll dive deeper into writing tests for components with Jest and Testing Library. We'll cover the TaskManager
component, user interactions, error handling, edge cases, and even GitHub workflow for automated tests.
Before jumping into the test files, let's take a closer look at the React component we'll be testing.
TaskManager
The following TaskManager
React component is a functional task management tool, with the ability to add, mark, and delete tasks. The component uses useState
and useEffect
for state management and persistence with localStorage
.
Here's the full implementation of the component:
1// src/components/TaskManager/TaskManager.jsx
2import { useState, useEffect } from "react";
3
4function TaskManager() {
5 const [tasks, setTasks] = useState([]);
6 const [newTask, setNewTask] = useState("");
7 const [error, setError] = useState("");
8
9 useEffect(() => {
10 // Load tasks from localStorage on mount
11 const savedTasks = localStorage.getItem("tasks");
12
13 if (savedTasks) {
14 try {
15 setTasks(JSON.parse(savedTasks));
16 } catch (e) {
17 console.error("Error loading tasks:", e);
18 }
19 }
20 }, []);
21
22 useEffect(() => {
23 // Save tasks to localStorage when they change
24 localStorage.setItem("tasks", JSON.stringify(tasks));
25 }, [tasks]);
26
27 const addTask = async (e) => {
28 e.preventDefault();
29 setError("");
30
31 if (!newTask.trim()) {
32 setError("Task cannot be empty");
33 return;
34 }
35
36 const task = {
37 id: Date.now(),
38 text: newTask.trim(),
39 completed: false,
40 createdAt: new Date().toISOString(),
41 };
42
43 setTasks([...tasks, task]);
44 setNewTask("");
45 };
46
47 const toggleTask = (taskId) => {
48 setTasks(
49 tasks.map((task) =>
50 task.id === taskId ? { ...task, completed: !task.completed } : task
51 )
52 );
53 };
54
55 const deleteTask = (taskId) => {
56 setTasks(tasks.filter((task) => task.id !== taskId));
57 };
58
59 return (
60 <div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-lg">
61 <h1 className="text-2xl font-bold mb-4">Task Manager</h1>
62
63 <form onSubmit={addTask} className="mb-6">
64 <div className="flex flex-col space-y-2">
65 <input
66 type="text"
67 value={newTask}
68 onChange={(e) => setNewTask(e.target.value)}
69 placeholder="Add a new task"
70 className="px-4 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
71 aria-label="New task input"
72 />
73 {error && (
74 <p role="alert" className="text-red-500 text-sm">
75 {error}
76 </p>
77 )}
78 <button
79 type="submit"
80 className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors"
81 >
82 Add Task
83 </button>
84 </div>
85 </form>
86
87 {tasks.length === 0 ? (
88 <p className="text-gray-500 text-center">
89 No tasks yet. Add one above!
90 </p>
91 ) : (
92 <ul className="space-y-3">
93 {tasks.map((task) => (
94 <li
95 key={task.id}
96 className="flex items-center justify-between p-3 bg-gray-50 rounded"
97 >
98 <div className="flex items-center space-x-3">
99 <input
100 type="checkbox"
101 checked={task.completed}
102 onChange={() => toggleTask(task.id)}
103 className="w-4 h-4 text-blue-500"
104 aria-label={`Mark "${task.text}" as ${
105 task.completed ? "incomplete" : "complete"
106 }`}
107 />
108 <span
109 className={`${
110 task.completed ? "line-through text-gray-400" : ""
111 }`}
112 >
113 {task.text}
114 </span>
115 </div>
116 <button
117 onClick={() => deleteTask(task.id)}
118 className="text-red-500 hover:text-red-700 transition-colors"
119 aria-label={`Delete task "${task.text}"`}
120 >
121 Delete
122 </button>
123 </li>
124 ))}
125 </ul>
126 )}
127
128 {tasks.length > 0 && (
129 <div className="mt-4 text-sm text-gray-500">
130 {tasks.filter((t) => t.completed).length} of {tasks.length} tasks
131 completed
132 </div>
133 )}
134 </div>
135 );
136}
137
138export default TaskManager;
139
Functionality of TaskManager:
The rendered TaskManager component
Now that we've seen the functionality, let's proceed to the test files. We assume the component is inside a src/components/TaskManager
folder.
TaskManager.test.jsx
in the same directory as TaskManager.jsx
.1// src/components/TaskManager/TaskManager.test.jsx
2import { render, screen } from "@testing-library/react";
3import userEvent from "@testing-library/user-event";
4import TaskManager from "./TaskManager";
5
1// ...imports above
2
3describe("TaskManager", () => {
4 test("renders initial state correctly", () => {
5 render(<TaskManager />);
6
7 // Check if main elements are present
8 expect(screen.getByText("Task Manager")).toBeInTheDocument();
9 expect(screen.getByPlaceholderText("Add a new task")).toBeInTheDocument();
10 expect(
11 screen.getByRole("button", { name: /add task/i })
12 ).toBeInTheDocument();
13 });
14});
15
1// ...others tests
2
3describe("TaskManager - User Interactions", () => {
4 test("adds a new task when form is submitted", async () => {
5 const user = userEvent.setup();
6 render(<TaskManager />);
7
8 // Find form elements
9 const input = screen.getByPlaceholderText("Add a new task");
10 const addButton = screen.getByRole("button", { name: /add task/i });
11
12 // Simulate user typing and submitting
13 await user.type(input, "New test task");
14 await user.click(addButton);
15
16 // Verify task was added
17 expect(screen.getByText("New test task")).toBeInTheDocument();
18 expect(input).toHaveValue(""); // Input should be cleared
19 });
20
21 test("can mark task as completed", async () => {
22 const user = userEvent.setup();
23 render(<TaskManager />);
24
25 // Add a task first
26 await user.type(screen.getByPlaceholderText("Add a new task"), "Test task");
27 await user.click(screen.getByRole("button", { name: /add task/i }));
28
29 // Find and click the checkbox
30 const checkbox = screen.getByLabelText('Mark "Test task" as complete');
31 await user.click(checkbox);
32
33 // Verify task is marked as completed
34 expect(checkbox).toBeChecked();
35 expect(screen.getByText("Test task")).toHaveClass("line-through");
36 });
37});
38
1// ...others tests
2
3describe("TaskManager - Error States", () => {
4 test("shows error when adding empty task", async () => {
5 const user = userEvent.setup();
6 render(<TaskManager />);
7
8 // Try to add empty task
9 await user.click(screen.getByRole("button", { name: /add task/i }));
10
11 // Verify no task was added
12 expect(screen.queryByRole("listitem")).not.toBeInTheDocument();
13 });
14});
15
1// ...others tests
2
3describe("TaskManager - Async Operations", () => {
4 test("loads saved tasks on mount", async () => {
5 // Mock localStorage
6 const mockTasks = [{ id: 1, text: "Saved task", completed: false }];
7
8 const localStorageOriginalMock = window.localStorage;
9
10 Object.defineProperty(window, "localStorage", {
11 value: {
12 ...window.localStorage,
13 getItem: jest.fn().mockReturnValue(JSON.stringify(mockTasks)),
14 },
15 });
16
17 render(<TaskManager />);
18
19 // Wait for tasks to load
20 expect(await screen.findByText("Saved task")).toBeInTheDocument();
21
22 // Clean up
23 Object.defineProperty(window, "localStorage", {
24 value: localStorageOriginalMock,
25 });
26 });
27
28 test("loads invalid json on mount", async () => {
29 // the test will trigger an error because it will fall into the catch,
30 // inside the catch there is a console.error that will appear in the console during the tests causing a false sense of error.
31 // Let's mock console.error to prevent this behavior
32 const consoleSpy = jest
33 .spyOn(console, "error")
34 .mockImplementation(() => {});
35
36 const localStorageOriginalMock = window.localStorage;
37
38 // Mock localStorage
39 Object.defineProperty(window, "localStorage", {
40 value: {
41 ...window.localStorage,
42 getItem: jest.fn().mockReturnValue("invalid json"),
43 },
44 });
45
46 render(<TaskManager />);
47
48 // Wait for tasks to load
49 expect(
50 await screen.findByText("No tasks yet. Add one above!")
51 ).toBeInTheDocument();
52
53 expect(consoleSpy).toHaveBeenCalledWith(
54 "Error loading tasks:",
55 expect.any(Error)
56 );
57
58 // Clean up
59 consoleSpy.mockRestore();
60
61 Object.defineProperty(window, "localStorage", {
62 value: localStorageOriginalMock,
63 });
64 });
65});
66
1// ...others tests
2
3describe("TaskManager - Edge Cases", () => {
4 test("handles special characters in task text", async () => {
5 const user = userEvent.setup();
6 render(<TaskManager />);
7
8 const specialText = "!@#$%^&*()";
9 await user.type(screen.getByPlaceholderText("Add a new task"), specialText);
10 await user.click(screen.getByRole("button", { name: /add task/i }));
11
12 expect(screen.getByText(specialText)).toBeInTheDocument();
13 });
14
15 test("handles long task text", async () => {
16 const user = userEvent.setup();
17 render(<TaskManager />);
18
19 const longText = "a".repeat(100);
20 await user.type(screen.getByPlaceholderText("Add a new task"), longText);
21 await user.click(screen.getByRole("button", { name: /add task/i }));
22
23 expect(screen.getByText(longText)).toBeInTheDocument();
24 });
25});
26
1// ...others tests
2
3describe("TaskManager - Integration", () => {
4 test("complete flow: add, complete, and delete task", async () => {
5 const user = userEvent.setup();
6 render(<TaskManager />);
7
8 const addButton = screen.getByRole("button", { name: /add task/i });
9
10 // Add task
11 await user.type(
12 screen.getByPlaceholderText("Add a new task"),
13 "Integration test task"
14 );
15 await user.click(addButton);
16
17 // Verify task added
18 const taskElement = screen.getByText("Integration test task");
19 expect(taskElement).toBeInTheDocument();
20
21 // Add another task
22 await user.type(
23 screen.getByPlaceholderText("Add a new task"),
24 "second integration test task"
25 );
26 await user.click(addButton);
27
28 // Complete task
29 await user.click(
30 screen.getByLabelText('Mark "Integration test task" as complete')
31 );
32 expect(taskElement).toHaveClass("line-through");
33
34 // Uncheck task
35 await user.click(
36 screen.getByLabelText('Mark "Integration test task" as incomplete')
37 );
38 expect(taskElement).not.toHaveClass("line-through");
39
40 // Delete task
41 await user.click(
42 screen.getByLabelText('Delete task "Integration test task"')
43 );
44 expect(taskElement).not.toBeInTheDocument();
45 });
46});
47
Test Execution Example:
1npm run test:coverage
2
Jest showing successful test results
For continuous integration, include a GitHub workflow file as shown in Part 2, configured to run these tests on every push or pull request. Here's an example workflow file:
1# .github/workflows/test.yml
2name: Tests
3on: [push, pull_request]
4
5jobs:
6 test:
7 runs-on: ubuntu-latest
8 steps:
9 - uses: actions/checkout@v4
10 - uses: actions/setup-node@v4
11 with:
12 node-version: '20'
13 cache: 'npm'
14
15 - name: Install dependencies
16 run: npm ci
17
18 - name: Run tests
19 run: npm run test:ci
20
GitHub Actions Example Result:
Jest showing successful test results
The full code, including the TaskManager
component, test files, and GitHub workflows, is available in the following GitHub repository:
Feel free to explore, clone, and contribute!
In this guide, we explored testing for React components using Jest and Testing Library, emphasizing different scenarios like rendering, user interactions, error states, and edge cases. We also demonstrated how to automate tests via CI pipelines in GitHub Actions.
🚀 Seeking more? Let me know your thoughts or what topics you’d like next!