Skip to main content
website logo savvydev
Testing React Applications: A Comprehensive Guide for Junior Developers

Testing React Applications: A Comprehensive Guide for Junior Developers

Master React testing with Jest, React Testing Library, and modern testing patterns. Learn how to write maintainable tests that give you confidence in your code.

React Testing Jest React Testing Library Frontend Quality

Testing is often the most overlooked skill in React development, yet it’s what separates junior developers from seniors. As a senior developer, I’ve seen teams struggle with testing that could dramatically improve their code quality and confidence. Let’s explore the testing patterns that will make you a better React developer.

Table of Contents

Why Testing Matters

Testing is not just about finding bugs—it’s about:

  • Confidence in refactoring - Make changes without fear
  • Documentation - Tests show how code should work
  • Design feedback - Tests reveal design problems early
  • Regression prevention - Catch breaking changes automatically
  • Better code quality - Writing testable code improves architecture

The difference between a junior and senior developer often comes down to their testing practices.

Testing Fundamentals

Testing Pyramid

// Unit Tests (Most tests) - Test individual functions/components
test("adds two numbers", () => {
  expect(add(2, 3)).toBe(5);
});

// Integration Tests (Some tests) - Test component interactions
test("user can submit form", async () => {
  render(<UserForm onSubmit={mockSubmit} />);
  await user.type(screen.getByLabelText("Name"), "John");
  await user.click(screen.getByRole("button", { name: "Submit" }));
  expect(mockSubmit).toHaveBeenCalledWith({ name: "John" });
});

// E2E Tests (Few tests) - Test complete user workflows
test("user can complete checkout", async () => {
  await page.goto("/products");
  await page.click('[data-testid="add-to-cart"]');
  await page.click('[data-testid="checkout"]');
  await page.fill('[data-testid="email"]', "user@example.com");
  await page.click('[data-testid="place-order"]');
  await expect(page).toHaveText("Order confirmed");
});

Testing Library Philosophy

// ❌ BAD: Testing implementation details
test("renders with correct className", () => {
  const { container } = render(<Button>Click me</Button>);
  expect(container.firstChild).toHaveClass("btn-primary");
});

// ✅ GOOD: Testing user behavior
test("calls onClick when clicked", async () => {
  const handleClick = jest.fn();
  render(<Button onClick={handleClick}>Click me</Button>);

  await user.click(screen.getByRole("button", { name: "Click me" }));
  expect(handleClick).toHaveBeenCalledTimes(1);
});

Component Testing

Basic Component Test

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button } from "./Button";

describe("Button", () => {
  test("renders with correct text", () => {
    render(<Button>Click me</Button>);
    expect(
      screen.getByRole("button", { name: "Click me" }),
    ).toBeInTheDocument();
  });

  test("calls onClick when clicked", async () => {
    const user = userEvent.setup();
    const handleClick = jest.fn();

    render(<Button onClick={handleClick}>Click me</Button>);

    await user.click(screen.getByRole("button", { name: "Click me" }));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  test("is disabled when disabled prop is true", () => {
    render(<Button disabled>Click me</Button>);
    expect(screen.getByRole("button", { name: "Click me" })).toBeDisabled();
  });
});

Form Testing

import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./LoginForm";

describe("LoginForm", () => {
  test("submits form with correct data", async () => {
    const user = userEvent.setup();
    const onSubmit = jest.fn();

    render(<LoginForm onSubmit={onSubmit} />);

    // Fill out the form
    await user.type(screen.getByLabelText(/email/i), "user@example.com");
    await user.type(screen.getByLabelText(/password/i), "password123");

    // Submit the form
    await user.click(screen.getByRole("button", { name: /sign in/i }));

    // Verify the form was submitted with correct data
    expect(onSubmit).toHaveBeenCalledWith({
      email: "user@example.com",
      password: "password123",
    });
  });

  test("shows validation errors for invalid email", async () => {
    const user = userEvent.setup();

    render(<LoginForm onSubmit={jest.fn()} />);

    await user.type(screen.getByLabelText(/email/i), "invalid-email");
    await user.tab(); // Trigger blur event

    expect(screen.getByText(/please enter a valid email/i)).toBeInTheDocument();
  });

  test("shows loading state during submission", async () => {
    const user = userEvent.setup();
    const onSubmit = jest.fn(
      () => new Promise((resolve) => setTimeout(resolve, 100)),
    );

    render(<LoginForm onSubmit={onSubmit} />);

    await user.type(screen.getByLabelText(/email/i), "user@example.com");
    await user.type(screen.getByLabelText(/password/i), "password123");
    await user.click(screen.getByRole("button", { name: /sign in/i }));

    expect(screen.getByText(/signing in/i)).toBeInTheDocument();
    expect(screen.getByRole("button", { name: /signing in/i })).toBeDisabled();
  });
});

Async Component Testing

import { render, screen, waitFor } from "@testing-library/react";
import { UserProfile } from "./UserProfile";

// Mock the API
jest.mock("./api", () => ({
  fetchUser: jest.fn(),
}));

import { fetchUser } from "./api";

describe("UserProfile", () => {
  test("renders user data after successful fetch", async () => {
    const mockUser = { id: 1, name: "John Doe", email: "john@example.com" };
    fetchUser.mockResolvedValue(mockUser);

    render(<UserProfile userId={1} />);

    // Show loading state initially
    expect(screen.getByText(/loading/i)).toBeInTheDocument();

    // Wait for user data to load
    await waitFor(() => {
      expect(screen.getByText("John Doe")).toBeInTheDocument();
    });

    expect(screen.getByText("john@example.com")).toBeInTheDocument();
    expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
  });

  test("shows error message when fetch fails", async () => {
    fetchUser.mockRejectedValue(new Error("Failed to fetch"));

    render(<UserProfile userId={1} />);

    await waitFor(() => {
      expect(screen.getByText(/failed to fetch/i)).toBeInTheDocument();
    });
  });
});

Advanced Testing Patterns

Custom Hooks 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("initializes with custom value", () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  test("increments counter", () => {
    const { result } = renderHook(() => useCounter(0));

    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 counter", () => {
    const { result } = renderHook(() => useCounter(10));

    act(() => {
      result.current.increment();
      result.current.increment();
      result.current.reset();
    });

    expect(result.current.count).toBe(0);
  });
});

Integration Testing

import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { App } from "./App";

// Mock external dependencies
jest.mock("./api", () => ({
  fetchUsers: jest.fn(),
  createUser: jest.fn(),
}));

import { fetchUsers, createUser } from "./api";

describe("User Management Integration", () => {
  test("user can add a new user to the list", async () => {
    const user = userEvent.setup();

    // Setup initial data
    const initialUsers = [
      { id: 1, name: "John Doe", email: "john@example.com" },
    ];
    fetchUsers.mockResolvedValue(initialUsers);
    createUser.mockResolvedValue({
      id: 2,
      name: "Jane Smith",
      email: "jane@example.com",
    });

    render(<App />);

    // Wait for initial users to load
    await waitFor(() => {
      expect(screen.getByText("John Doe")).toBeInTheDocument();
    });

    // Open add user form
    await user.click(screen.getByRole("button", { name: /add user/i }));

    // Fill out the form
    await user.type(screen.getByLabelText(/name/i), "Jane Smith");
    await user.type(screen.getByLabelText(/email/i), "jane@example.com");

    // Submit the form
    await user.click(screen.getByRole("button", { name: /save/i }));

    // Verify new user appears in the list
    await waitFor(() => {
      expect(screen.getByText("Jane Smith")).toBeInTheDocument();
    });

    // Verify API was called
    expect(createUser).toHaveBeenCalledWith({
      name: "Jane Smith",
      email: "jane@example.com",
    });
  });
});

Mocking and Stubbing

// Mocking modules
jest.mock("axios", () => ({
  get: jest.fn(),
  post: jest.fn(),
  put: jest.fn(),
  delete: jest.fn(),
}));

// Mocking functions
const mockLocalStorage = {
  getItem: jest.fn(),
  setItem: jest.fn(),
  removeItem: jest.fn(),
  clear: jest.fn(),
};

Object.defineProperty(window, "localStorage", {
  value: mockLocalStorage,
});

// Mocking timers
jest.useFakeTimers();

test("debounced search", async () => {
  const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
  const onSearch = jest.fn();

  render(<SearchInput onSearch={onSearch} />);

  await user.type(screen.getByRole("textbox"), "react");

  // Fast-forward time to trigger debounce
  jest.advanceTimersByTime(300);

  expect(onSearch).toHaveBeenCalledWith("react");
});

// Clean up
afterEach(() => {
  jest.useRealTimers();
});

Testing Best Practices

1. Test Behavior, Not Implementation

// ❌ BAD: Testing implementation details
test("uses correct CSS class", () => {
  const { container } = render(<Button variant="primary">Click me</Button>);
  expect(container.firstChild).toHaveClass("btn-primary");
});

// ✅ GOOD: Testing user behavior
test("appears as primary button to user", () => {
  render(<Button variant="primary">Click me</Button>);
  const button = screen.getByRole("button", { name: "Click me" });
  expect(button).toHaveClass("btn-primary");
});

2. Use Semantic Queries

// Priority order for queries:
// 1. getByRole (most semantic)
// 2. getByLabelText (for form elements)
// 3. getByPlaceholderText (for inputs)
// 4. getByText (for content)
// 5. getByDisplayValue (for form values)
// 6. getByTestId (last resort)

// ✅ GOOD: Using semantic queries
test("user can submit form", async () => {
  const user = userEvent.setup();
  render(<LoginForm />);

  await user.type(
    screen.getByRole("textbox", { name: /email/i }),
    "user@example.com",
  );
  await user.type(screen.getByLabelText(/password/i), "password123");
  await user.click(screen.getByRole("button", { name: /sign in/i }));

  // Test the behavior, not the implementation
});

3. Write Accessible Tests

// ✅ GOOD: Tests that ensure accessibility
test("has proper ARIA attributes", () => {
  render(<Modal isOpen={true} title="Test Modal" />);

  expect(screen.getByRole("dialog")).toBeInTheDocument();
  expect(screen.getByRole("dialog")).toHaveAttribute("aria-labelledby");
  expect(screen.getByText("Test Modal")).toHaveAttribute("id");
});

test("supports keyboard navigation", async () => {
  const user = userEvent.setup();
  render(<Dropdown options={["Option 1", "Option 2"]} />);

  const trigger = screen.getByRole("button", { name: /select option/i });
  await user.click(trigger);

  await user.keyboard("{ArrowDown}");
  expect(screen.getByRole("option", { name: "Option 1" })).toHaveAttribute(
    "aria-selected",
    "true",
  );
});

4. Test Error States

test("handles network errors gracefully", async () => {
  fetchUser.mockRejectedValue(new Error("Network error"));

  render(<UserProfile userId={1} />);

  await waitFor(() => {
    expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
  });

  expect(
    screen.getByRole("button", { name: /try again/i }),
  ).toBeInTheDocument();
});

Common Testing Pitfalls

1. Testing Implementation Details

// ❌ BAD: Testing internal state
test("sets loading state to true", () => {
  const { result } = renderHook(() => useUser());
  act(() => {
    result.current.fetchUser(1);
  });
  expect(result.current.loading).toBe(true);
});

// ✅ GOOD: Testing observable behavior
test("shows loading indicator while fetching user", async () => {
  fetchUser.mockImplementation(
    () => new Promise((resolve) => setTimeout(resolve, 100)),
  );

  render(<UserProfile userId={1} />);

  expect(screen.getByText(/loading/i)).toBeInTheDocument();
});

2. Over-Mocking

// ❌ BAD: Mocking everything
jest.mock("./utils", () => ({
  formatName: jest.fn(() => "Mocked Name"),
  validateEmail: jest.fn(() => true),
}));

// ✅ GOOD: Only mock external dependencies
jest.mock("./api", () => ({
  fetchUser: jest.fn(),
}));

3. Not Testing Edge Cases

// ✅ GOOD: Testing edge cases
test("handles empty user list", () => {
  render(<UserList users={[]} />);
  expect(screen.getByText(/no users found/i)).toBeInTheDocument();
});

test("handles null user data", () => {
  render(<UserProfile user={null} />);
  expect(screen.getByText(/user not found/i)).toBeInTheDocument();
});

test("handles malformed user data", () => {
  render(<UserProfile user={{}} />);
  expect(screen.getByText(/invalid user data/i)).toBeInTheDocument();
});

Real-World Examples

E-commerce Product Component

import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ProductCard } from "./ProductCard";

describe("ProductCard", () => {
  const mockProduct = {
    id: 1,
    name: "Test Product",
    price: 29.99,
    image: "/test-image.jpg",
    inStock: true,
  };

  test("renders product information correctly", () => {
    render(<ProductCard product={mockProduct} onAddToCart={jest.fn()} />);

    expect(screen.getByText("Test Product")).toBeInTheDocument();
    expect(screen.getByText("$29.99")).toBeInTheDocument();
    expect(screen.getByRole("img", { name: "Test Product" })).toHaveAttribute(
      "src",
      "/test-image.jpg",
    );
  });

  test("calls onAddToCart when add to cart button is clicked", async () => {
    const user = userEvent.setup();
    const onAddToCart = jest.fn();

    render(<ProductCard product={mockProduct} onAddToCart={onAddToCart} />);

    await user.click(screen.getByRole("button", { name: /add to cart/i }));

    expect(onAddToCart).toHaveBeenCalledWith(mockProduct);
  });

  test("shows out of stock message when product is not in stock", () => {
    const outOfStockProduct = { ...mockProduct, inStock: false };

    render(<ProductCard product={outOfStockProduct} onAddToCart={jest.fn()} />);

    expect(screen.getByText(/out of stock/i)).toBeInTheDocument();
    expect(screen.getByRole("button", { name: /add to cart/i })).toBeDisabled();
  });

  test("shows loading state when adding to cart", async () => {
    const user = userEvent.setup();
    const onAddToCart = jest.fn(
      () => new Promise((resolve) => setTimeout(resolve, 100)),
    );

    render(<ProductCard product={mockProduct} onAddToCart={onAddToCart} />);

    await user.click(screen.getByRole("button", { name: /add to cart/i }));

    expect(screen.getByText(/adding/i)).toBeInTheDocument();
    expect(screen.getByRole("button", { name: /adding/i })).toBeDisabled();
  });
});

Form Validation Testing

import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ContactForm } from "./ContactForm";

describe("ContactForm", () => {
  test("validates required fields", async () => {
    const user = userEvent.setup();
    render(<ContactForm onSubmit={jest.fn()} />);

    // Try to submit without filling required fields
    await user.click(screen.getByRole("button", { name: /send message/i }));

    expect(screen.getByText(/name is required/i)).toBeInTheDocument();
    expect(screen.getByText(/email is required/i)).toBeInTheDocument();
  });

  test("validates email format", async () => {
    const user = userEvent.setup();
    render(<ContactForm onSubmit={jest.fn()} />);

    await user.type(screen.getByLabelText(/email/i), "invalid-email");
    await user.tab(); // Trigger blur validation

    expect(screen.getByText(/please enter a valid email/i)).toBeInTheDocument();
  });

  test("submits form with valid data", async () => {
    const user = userEvent.setup();
    const onSubmit = jest.fn();

    render(<ContactForm onSubmit={onSubmit} />);

    await user.type(screen.getByLabelText(/name/i), "John Doe");
    await user.type(screen.getByLabelText(/email/i), "john@example.com");
    await user.type(
      screen.getByLabelText(/message/i),
      "Hello, this is a test message",
    );

    await user.click(screen.getByRole("button", { name: /send message/i }));

    expect(onSubmit).toHaveBeenCalledWith({
      name: "John Doe",
      email: "john@example.com",
      message: "Hello, this is a test message",
    });
  });

  test("shows success message after successful submission", async () => {
    const user = userEvent.setup();
    const onSubmit = jest.fn().mockResolvedValue();

    render(<ContactForm onSubmit={onSubmit} />);

    await user.type(screen.getByLabelText(/name/i), "John Doe");
    await user.type(screen.getByLabelText(/email/i), "john@example.com");
    await user.type(screen.getByLabelText(/message/i), "Test message");

    await user.click(screen.getByRole("button", { name: /send message/i }));

    await waitFor(() => {
      expect(
        screen.getByText(/message sent successfully/i),
      ).toBeInTheDocument();
    });
  });
});

Testing Tools and Setup

Jest Configuration

// jest.config.js
module.exports = {
  testEnvironment: "jsdom",
  setupFilesAfterEnv: ["<rootDir>/src/setupTests.js"],
  moduleNameMapping: {
    "^@/(.*)$": "<rootDir>/src/$1",
    "\\.(css|less|scss|sass)$": "identity-obj-proxy",
  },
  collectCoverageFrom: [
    "src/**/*.{js,jsx,ts,tsx}",
    "!src/**/*.d.ts",
    "!src/index.js",
    "!src/reportWebVitals.js",
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

Testing Library Setup

// src/setupTests.js
import "@testing-library/jest-dom";
import { server } from "./mocks/server";

// Establish API mocking before all tests
beforeAll(() => server.listen());

// Reset any request handlers that we may add during the tests
afterEach(() => server.resetHandlers());

// Clean up after the tests are finished
afterAll(() => server.close());

// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
  constructor() {}
  observe() {}
  unobserve() {}
  disconnect() {}
};

Next Steps

1. Advanced Testing Patterns

2. Testing Libraries

3. Continuous Integration

# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: "18"
      - run: npm ci
      - run: npm test -- --coverage --watchAll=false
      - run: npm run test:e2e

Conclusion

Testing is a fundamental skill that separates junior developers from seniors. The key is writing tests that give you confidence in your code while maintaining them over time.

Key Takeaways for Junior Developers:

  1. Test behavior, not implementation - Focus on what users see and do
  2. Use semantic queries - Prefer getByRole over getByTestId
  3. Write accessible tests - Ensure your components work for all users
  4. Test error states - Don’t just test the happy path
  5. Keep tests maintainable - Tests should be easy to understand and update

Remember: Good tests are like good documentation. They show how your code should work and catch problems before they reach users.

Related Articles:

Start writing better tests today! 🚀