When developing with React, have you ever wondered:
“Where should I even start with testing?”
This article walks you through unit tests, integration tests, end-to-end (E2E) tests, snapshot tests, and test doubles,
focusing on tools like Jest, React Testing Library, and Playwright.
🧠 Why Write Tests?
Writing tests isn’t just about reducing bugs.
It brings lasting benefits to your development team:
- ✅ Enables safe refactoring
- ✅ Serves as living documentation for expected behavior
- ✅ Helps maintain code quality over time
React apps often deal with complex state and side effects (like useEffect
), making them prone to hidden bugs.
That’s why having a solid testing strategy is crucial.
⚙️ The Three Layers of Testing: Unit, Integration, and E2E
🔹 Unit Tests
Unit tests focus on the smallest pieces of your application — functions, components, or custom hooks.
They check whether a given input produces the expected output.
// useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';
test('increments the count by 1', () => {
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
🔹 Integration Tests
Integration tests verify how multiple components or functions work together.
For example: form input → validation → API call.
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import Form from './Form';
test('calls API on form submit', async () => {
const mockSubmit = jest.fn();
render(<Form onSubmit={mockSubmit} />);
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'Taro' } });
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => expect(mockSubmit).toHaveBeenCalledWith('Taro'));
});
🔹 End-to-End (E2E) Tests
E2E tests simulate real user interactions in the browser, covering the entire app flow.
Common tools include Playwright and Cypress.
// login.e2e.ts
import { test, expect } from '@playwright/test';
test('logs in and redirects to the dashboard', async ({ page }) => {
await page.goto('/login');
await page.fill('#email', 'test@example.com');
await page.fill('#password', 'password');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
});
🧪 The Role of Jest and React Testing Library
✅ What is Jest?
Jest is a testing framework developed by Facebook.
It provides everything you need for testing JavaScript apps:
- Test runner
- Mocks and spies
- Snapshot testing
It’s commonly used together with React Testing Library.
✅ What is React Testing Library?
Instead of directly manipulating the DOM, React Testing Library focuses on testing the app from the user’s perspective — by simulating clicks, typing, and other interactions.
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';
test('increments the counter when button is clicked', () => {
render(<Counter />);
fireEvent.click(screen.getByText('+'));
expect(screen.getByText('1')).toBeInTheDocument();
});
📸 What Is a Snapshot Test?
A snapshot test saves a “picture” of a component’s rendered output and compares it on future test runs to detect unexpected UI changes.
It’s powered by Jest’s toMatchSnapshot()
function.
✅ Example
// Button.tsx
export const Button = ({ label }: { label: string }) => {
return <button>{label}button>;
};
// Button.test.tsx
import renderer from 'react-test-renderer';
import { Button } from './Button';
test('matches the snapshot', () => {
const tree = renderer.create(<Button label="Send" />).toJSON();
expect(tree).toMatchSnapshot();
});
When you run the test for the first time, Jest stores a snapshot like this under __snapshots__
:
exports[`Button snapshot 1`] = `
`;
If the component’s output changes later, Jest will show a diff:
-
✅ If the change is intentional, update the snapshot with npm test — -u.
⚖️ Pros and Cons
Type | Description |
---|---|
✅ Pros | Catches unintended UI changes, easy to set up |
⚠️ Cons | Can produce noisy diffs, not suitable for dynamic elements |
💡 Best Practice | Use for small, stable UI parts only |
🧩 Using React Testing Library for Snapshots
import { render } from '@testing-library/react';
import Button from './Button';
test('Button matches snapshot', () => {
const { asFragment } = render(<Button label="Send" />);
expect(asFragment()).toMatchSnapshot();
});
🧱 Understanding Test Doubles: Mocks, Stubs, Fakes, and Dummies
While snapshot tests detect visual changes,
test doubles help you “recreate and control” external behavior to make your tests safe and reliable.
🔸 What Is a Test Double?
In React testing, components often depend on external systems — APIs, databases, or browser APIs.
Using real ones makes tests slow and flaky.
A test double is a fake replacement for those dependencies that lets you simulate external behavior safely.
🧩 Example: When API Responses Affect the UI
Let’s say you have a component that fetches user data and displays it.
💡 Original Code
// UserInfo.tsx
import { useEffect, useState } from 'react';
import { fetchUser } from './api';
export const UserInfo = () => {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser().then(setUser);
}, []);
if (!user) return <p>Loading...p>;
return <p>Hello, {user.name}!p>;
};
If you test this with a real API:
- The test fails when the API is down
- It takes time due to network latency
- Results vary depending on live data
✅ Using a Test Double
import { render, screen, waitFor } from '@testing-library/react';
import { UserInfo } from './UserInfo';
import { fetchUser } from './api';
jest.mock('./api'); // Mock the API module
test('displays the user name', async () => {
fetchUser.mockResolvedValue({ name: 'Hanako' });
render(<UserInfo />);
await waitFor(() =>
expect(screen.getByText('Hello, Hanako!')).toBeInTheDocument()
);
});
Here, the API response is simulated inside the test.
No real network requests are made, and results are always consistent.
🧭 Why Use Test Doubles?
Goal | Benefit |
---|---|
Isolate external dependencies | No need for real APIs or databases |
Speed up tests | Responses are instant |
Ensure consistency | Same result every time |
Reproduce special cases | e.g. simulate API errors easily |
🎯 Key idea: Test doubles let you mimic real-world behavior and take full control of the testing environment.
🧩 Types of Test Doubles and Examples
Type | Definition | Use Case | Example (React/Jest) |
---|---|---|---|
Mock | A fake object that tracks how it’s used (calls, args) | Verify function calls | js\nconst mockFn = jest.fn();\nfireEvent.click(button);\nexpect(mockFn).toHaveBeenCalledTimes(1);\n |
Stub | Returns fixed values; doesn’t record history | Simulate API responses | js\njest.spyOn(api, 'fetchUser').mockResolvedValue({ name: 'Taro' });\n |
Fake | A lightweight working version of a dependency | Replace local DB or storage | js\nclass FakeLocalStorage {\n setItem(k,v){this.store[k]=v;}\n getItem(k){return this.store[k];}\n}\n |
Dummy | Placeholder objects that aren’t actually used | Required arguments | js\nrender( |
Spy | Observes calls to real functions | Monitor logs or side effects | js\nconst spy = jest.spyOn(console, 'log');\nlogMessage('Hello');\nexpect(spy).toHaveBeenCalledWith('Hello');\n |
🧩 Designing a Testing Strategy
🔺 The Testing Pyramid
▲ E2E Tests (few)
▲▲ Integration Tests (some)
▲▲▲ Unit Tests (many)
- Unit tests: small, fast, and plentiful
- Integration tests: verify key workflows
- E2E tests: ensure the entire user experience
🧠 Continuous Testing
- Automate tests in CI/CD (e.g., GitHub Actions)
- Use snapshot tests to catch unintended UI changes early
🏁 Summary
Test Type | Purpose | Tools |
---|---|---|
Unit Test | Verify functions/components individually | Jest / React Testing Library |
Integration Test | Test multiple units together | Jest / RTL |
E2E Test | Simulate real user flows | Playwright / Cypress |
Snapshot Test | Detect unintended UI changes | Jest |
Test Double | Simulate and control external dependencies | Jest.mock / Spy / Stub |
🧑💻 Final Thoughts
Testing doesn’t slow development down —
it’s a tool for building safely and refactoring with confidence.
Start small with unit tests, then add snapshots and test doubles as your project grows.