Testing in React: A Complete Guide to Best Practices

October 27, 2024 (1y ago)

Testing is crucial for building robust React applications. Let's explore the best practices and tools for testing React components effectively.

Why Testing Matters

Testing Tools Overview

Jest

The most popular JavaScript testing framework with built-in features:

React Testing Library

Focuses on testing components from a user's perspective, not implementation details.

# Install testing dependencies
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event

1. The Testing Philosophy

Test behavior, not implementation.

// ❌ BAD - Testing implementation details
test("counter increments state", () => {
  const { result } = renderHook(() => useState(0));
  expect(result.current[0]).toBe(0);
});
 
// ✅ GOOD - Testing user behavior
test("counter increments when button is clicked", () => {
  render(<Counter />);
  const button = screen.getByRole("button", { name: /increment/i });
  const count = screen.getByText(/count: 0/i);
 
  fireEvent.click(button);
 
  expect(screen.getByText(/count: 1/i)).toBeInTheDocument();
});

2. Component Testing Best Practices

Basic Component Test

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Button from "./Button";
 
describe("Button Component", () => {
  test("renders with correct text", () => {
    render(<Button>Click me</Button>);
    expect(
      screen.getByRole("button", { name: /click me/i })
    ).toBeInTheDocument();
  });
 
  test("calls onClick handler when clicked", async () => {
    const handleClick = jest.fn();
    const user = userEvent.setup();
 
    render(<Button onClick={handleClick}>Click me</Button>);
 
    await user.click(screen.getByRole("button"));
 
    expect(handleClick).toHaveBeenCalledTimes(1);
  });
 
  test("is disabled when disabled prop is true", () => {
    render(<Button disabled>Click me</Button>);
    expect(screen.getByRole("button")).toBeDisabled();
  });
});

Testing Forms

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import LoginForm from "./LoginForm";
 
describe("LoginForm", () => {
  test("submits form with email and password", async () => {
    const handleSubmit = jest.fn();
    const user = userEvent.setup();
 
    render(<LoginForm onSubmit={handleSubmit} />);
 
    // Fill out the form
    await user.type(screen.getByLabelText(/email/i), "test@example.com");
    await user.type(screen.getByLabelText(/password/i), "password123");
 
    // Submit
    await user.click(screen.getByRole("button", { name: /log in/i }));
 
    // Verify
    expect(handleSubmit).toHaveBeenCalledWith({
      email: "test@example.com",
      password: "password123",
    });
  });
 
  test("shows validation error for invalid email", async () => {
    const user = userEvent.setup();
    render(<LoginForm />);
 
    await user.type(screen.getByLabelText(/email/i), "invalid-email");
    await user.click(screen.getByRole("button", { name: /log in/i }));
 
    expect(screen.getByText(/invalid email address/i)).toBeInTheDocument();
  });
});

3. Testing Asynchronous Code

API Calls with MSW (Mock Service Worker)

import { rest } from "msw";
import { setupServer } from "msw/node";
import { render, screen, waitFor } from "@testing-library/react";
import UserProfile from "./UserProfile";
 
// Setup mock server
const server = setupServer(
  rest.get("/api/user/:id", (req, res, ctx) => {
    return res(
      ctx.json({
        id: "1",
        name: "John Doe",
        email: "john@example.com",
      })
    );
  })
);
 
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
 
test("loads and displays user data", async () => {
  render(<UserProfile userId="1" />);
 
  // Loading state
  expect(screen.getByText(/loading/i)).toBeInTheDocument();
 
  // Wait for data to load
  await waitFor(() => {
    expect(screen.getByText("John Doe")).toBeInTheDocument();
  });
 
  expect(screen.getByText("john@example.com")).toBeInTheDocument();
});
 
test("handles error state", async () => {
  server.use(
    rest.get("/api/user/:id", (req, res, ctx) => {
      return res(ctx.status(500));
    })
  );
 
  render(<UserProfile userId="1" />);
 
  await waitFor(() => {
    expect(screen.getByText(/error loading user/i)).toBeInTheDocument();
  });
});

4. Testing Hooks

Custom Hook Testing

import { renderHook, act } from "@testing-library/react";
import useCounter from "./useCounter";
 
describe("useCounter", () => {
  test("initializes with default value", () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });
 
  test("increments counter", () => {
    const { result } = renderHook(() => useCounter());
 
    act(() => {
      result.current.increment();
    });
 
    expect(result.current.count).toBe(1);
  });
 
  test("decrements counter", () => {
    const { result } = renderHook(() => useCounter(5));
 
    act(() => {
      result.current.decrement();
    });
 
    expect(result.current.count).toBe(4);
  });
 
  test("resets to initial value", () => {
    const { result } = renderHook(() => useCounter(10));
 
    act(() => {
      result.current.increment();
      result.current.increment();
      result.current.reset();
    });
 
    expect(result.current.count).toBe(10);
  });
});

5. Testing Context

import { render, screen } from "@testing-library/react";
import { ThemeProvider } from "./ThemeContext";
import ThemedButton from "./ThemedButton";
 
test("renders with theme from context", () => {
  render(
    <ThemeProvider value={{ theme: "dark" }}>
      <ThemedButton>Click me</ThemedButton>
    </ThemeProvider>
  );
 
  const button = screen.getByRole("button");
  expect(button).toHaveClass("dark-theme");
});
 
// Create a custom render function with providers
function renderWithProviders(ui, { theme = "light", ...options } = {}) {
  return render(<ThemeProvider value={{ theme }}>{ui}</ThemeProvider>, options);
}
 
test("uses custom render with providers", () => {
  renderWithProviders(<ThemedButton>Click me</ThemedButton>, { theme: "dark" });
  expect(screen.getByRole("button")).toHaveClass("dark-theme");
});

6. Best Practices Summary

✅ DO:

1. Use semantic queries

// Prefer accessible queries
screen.getByRole("button", { name: /submit/i });
screen.getByLabelText(/email/i);
screen.getByText(/welcome/i);
 
// Avoid
screen.getByTestId("submit-button");

2. Test user interactions

import userEvent from "@testing-library/user-event";
 
test("user can type in input", async () => {
  const user = userEvent.setup();
  render(<SearchInput />);
 
  await user.type(screen.getByRole("textbox"), "React Testing");
  expect(screen.getByRole("textbox")).toHaveValue("React Testing");
});

3. Use waitFor for async operations

await waitFor(() => {
  expect(screen.getByText(/data loaded/i)).toBeInTheDocument();
});

4. Test accessibility

import { axe, toHaveNoViolations } from "jest-axe";
expect.extend(toHaveNoViolations);
 
test("should not have accessibility violations", async () => {
  const { container } = render(<MyComponent />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

❌ DON'T:

1. Don't test implementation details

// ❌ BAD
expect(component.state.count).toBe(1);
expect(wrapper.find(".counter").exists()).toBe(true);
 
// ✅ GOOD
expect(screen.getByText("Count: 1")).toBeInTheDocument();

2. Don't use random data in tests

// ❌ BAD
const randomEmail = `test${Math.random()}@example.com`;
 
// ✅ GOOD
const email = "test@example.com";

3. Don't test external libraries

// ❌ BAD - Testing React Router
test("router navigates correctly", () => {
  // Don't test react-router's functionality
});
 
// ✅ GOOD - Test your component's behavior
test("shows correct page after navigation", () => {
  // Test what your component does with routing
});

7. Test Coverage Tips

# Run tests with coverage
npm test -- --coverage
 
# Coverage thresholds in package.json
{
  "jest": {
    "coverageThreshold": {
      "global": {
        "branches": 80,
        "functions": 80,
        "lines": 80,
        "statements": 80
      }
    }
  }
}

8. Snapshot Testing (Use Sparingly)

test("renders correctly", () => {
  const { container } = render(<UserCard user={mockUser} />);
  expect(container).toMatchSnapshot();
});
 
// Better: Snapshot specific parts
test("renders user info correctly", () => {
  render(<UserCard user={mockUser} />);
  expect(screen.getByText(mockUser.name)).toMatchInlineSnapshot(`
    <h2>John Doe</h2>
  `);
});

9. Integration Tests

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import App from "./App";
 
test("complete user flow: add item to cart and checkout", async () => {
  const user = userEvent.setup();
  render(<App />);
 
  // Browse products
  expect(screen.getByText(/product catalog/i)).toBeInTheDocument();
 
  // Add to cart
  await user.click(screen.getByRole("button", { name: /add to cart/i }));
  expect(screen.getByText(/1 item in cart/i)).toBeInTheDocument();
 
  // Go to checkout
  await user.click(screen.getByRole("link", { name: /checkout/i }));
  expect(screen.getByText(/checkout/i)).toBeInTheDocument();
 
  // Fill form and submit
  await user.type(screen.getByLabelText(/email/i), "test@example.com");
  await user.click(screen.getByRole("button", { name: /place order/i }));
 
  // Verify success
  await waitFor(() => {
    expect(screen.getByText(/order confirmed/i)).toBeInTheDocument();
  });
});

Key Takeaways

  1. Focus on user behavior, not implementation
  2. Use React Testing Library for component testing
  3. Test accessibility to ensure your app works for everyone
  4. Mock API calls with MSW for reliable tests
  5. Keep tests simple and readable - they're documentation
  6. Don't chase 100% coverage - focus on critical paths
  7. Write integration tests for user flows
  8. Use semantic queries (getByRole, getByLabelText)
  9. Test error states and edge cases
  10. Make tests fast - mock heavy dependencies

Useful Resources

// Happy testing! 🧪
test("your code works", () => {
  expect(true).toBe(true);
});