Testing is crucial for building robust React applications. Let's explore the best practices and tools for testing React components effectively.
Why Testing Matters
- Catch bugs early before they reach production
- Confidence in refactoring without breaking existing features
- Documentation - tests serve as living documentation
- Better code design - testable code is usually well-structured code
Testing Tools Overview
Jest
The most popular JavaScript testing framework with built-in features:
- Test runner
- Assertion library
- Mocking capabilities
- Code coverage
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-event1. 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
- Focus on user behavior, not implementation
- Use React Testing Library for component testing
- Test accessibility to ensure your app works for everyone
- Mock API calls with MSW for reliable tests
- Keep tests simple and readable - they're documentation
- Don't chase 100% coverage - focus on critical paths
- Write integration tests for user flows
- Use semantic queries (getByRole, getByLabelText)
- Test error states and edge cases
- Make tests fast - mock heavy dependencies
Useful Resources
// Happy testing! 🧪
test("your code works", () => {
expect(true).toBe(true);
});