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 Fundamentals
- Component Testing
- Advanced Testing Patterns
- Testing Best Practices
- Common Testing Pitfalls
- Real-World Examples
- Testing Tools and Setup
- Next Steps
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
- Visual Regression Testing with Storybook
- E2E Testing with Playwright
- Performance Testing with Lighthouse CI
2. Testing Libraries
- MSW (Mock Service Worker) for API mocking
- React Hook Form Testing for form testing
- Testing Library User Events for user interactions
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
4. Related Learning Resources
- React Hooks Testing - Test custom hooks
- TypeScript Testing - Type-safe testing
- React Performance - Performance testing
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:
- Test behavior, not implementation - Focus on what users see and do
- Use semantic queries - Prefer
getByRole
overgetByTestId
- Write accessible tests - Ensure your components work for all users
- Test error states - Don’t just test the happy path
- 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:
- React Hooks Testing - Test custom hooks
- TypeScript Testing - Type-safe testing
- React Performance - Performance testing
Start writing better tests today! 🚀