João Vinezof
testing

Dec 28, 2024

React Unit Testing: A Complete Guide with Jest & Testing Library - Part 3: Testing Components with Jest

React Unit Testing: A Complete Guide with Jest & Testing Library - Part 3: Testing Components with Jest
— scroll down — read more

React Unit Testing: A Complete Guide with Jest & Testing Library - Part 3: Testing Components with Jest

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

📄 The Component: 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:

  1. Task Management: Add, check/uncheck, and delete tasks.
  2. Persistence: Automatically saves and loads tasks from localStorage.
  3. Error Handling: Prevents adding empty tasks and handles invalid JSON data in localStorage.
Demonstrating how the task manager works, adding two tasks, then selecting them and finally deleting the two tasks.

The rendered TaskManager component

🔬 Writing Tests for the 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.

  1. Create the test file 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. Testing Component Rendering
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. Testing User Interactions
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. Testing Error States
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. Testing Async Operations
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. Testing Edge Cases
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. Testing Component Integration
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

Jest showing successful test results

🤖 Automated Test Integration with GitHub Actions

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

Jest showing successful test results

📂 GitHub Repository

The full code, including the TaskManager component, test files, and GitHub workflows, is available in the following GitHub repository:

👉 TaskManager Testing Project Repository

Feel free to explore, clone, and contribute!

📝 Conclusion

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!


Share this post