DEV Community

Learcise
Learcise

Posted on

🧩 A Complete Guide to React Testing: From Unit Tests to E2E, Snapshots, and Test Doubles

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);
});

Enter fullscreen mode Exit fullscreen mode

🔹 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'));
});

Enter fullscreen mode Exit fullscreen mode

🔹 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');
});

Enter fullscreen mode Exit fullscreen mode

🧪 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();
});

Enter fullscreen mode Exit fullscreen mode

📸 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>;
};

Enter fullscreen mode Exit fullscreen mode
// 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();
});

Enter fullscreen mode Exit fullscreen mode

When you run the test for the first time, Jest stores a snapshot like this under __snapshots__:

exports[`Button snapshot 1`] = `
<button>
  Send
</button>
`;

Enter fullscreen mode Exit fullscreen mode

If the component’s output changes later, Jest will show a diff:

- <button>
+ <button className="primary">

Enter fullscreen mode Exit fullscreen mode

✅ 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();
});

Enter fullscreen mode Exit fullscreen mode

🧱 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>;
};

Enter fullscreen mode Exit fullscreen mode

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()
  );
});

Enter fullscreen mode Exit fullscreen mode

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(<UserCard user={null} onClick={() => {}} />);\n
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)

Enter fullscreen mode Exit fullscreen mode
  • 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.

Top comments (0)