React Development · Lesson 10 of 15

Testing Guide

The Testing Philosophy That Changes Everything

Most React testing tutorials teach you to test implementation details — checking state values, verifying function calls, testing internal component behavior. These tests break when you refactor, even when behavior is unchanged.

The React Testing Library mantra:

"The more your tests resemble the way your software is used, the more confidence they can give you." — Kent C. Dodds

Test what the user sees and does. Not what's in the component's state.

❌ Test: "When addItem is called, state.items.length increases by 1"
✅ Test: "When user clicks Add to Cart, the cart badge shows 1"

Setup

Bash
# CRA, Next.js: RTL comes pre-installed
# Vite projects:
npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom vitest jsdom
TSX
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: ["./src/test/setup.ts"],
  },
});

// src/test/setup.ts
import "@testing-library/jest-dom";

RTL Core: Queries

Queries find elements in the rendered output. Choose them in this priority order:

1. getByRole       — matches ARIA roles (button, textbox, heading, etc.)
2. getByLabelText  — form inputs associated with a label
3. getByPlaceholderText
4. getByText       — visible text
5. getByDisplayValue
6. getByAltText    — images
7. getByTitle
8. getByTestId     — last resort, use sparingly
TSX
import { render, screen } from "@testing-library/react";

test("query examples", () => {
  render(
    <div>
      <h1>Dashboard</h1>
      <label htmlFor="search">Search</label>
      <input id="search" placeholder="Type to search..." />
      <button type="submit">Search</button>
      <img src="/logo.png" alt="Company Logo" />
    </div>
  );

  // getBy* — throws if not found (fail fast)
  screen.getByRole("heading", { name: "Dashboard" });
  screen.getByRole("button", { name: "Search" });
  screen.getByLabelText("Search");
  screen.getByAltText("Company Logo");

  // queryBy* — returns null if not found (good for asserting absence)
  expect(screen.queryByText("Loading...")).not.toBeInTheDocument();

  // findBy* — returns Promise, waits for element (async)
  // await screen.findByText("Data loaded");

  // AllBy* — returns array of all matches
  const buttons = screen.getAllByRole("button");
  expect(buttons).toHaveLength(1);
});

Testing User Interactions

Use userEvent over fireEvent — it simulates realistic browser behavior (typing character by character, triggering all related events).

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

// Component under test
function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c - 1)}>Decrement</button>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
      <button onClick={() => setCount(0)} disabled={count === 0}>
        Reset
      </button>
    </div>
  );
}

describe("Counter", () => {
  // Always set up userEvent with setup()
  const user = userEvent.setup();

  it("starts at 0", () => {
    render(<Counter />);
    expect(screen.getByText("Count: 0")).toBeInTheDocument();
  });

  it("increments when user clicks Increment", async () => {
    render(<Counter />);
    await user.click(screen.getByRole("button", { name: "Increment" }));
    expect(screen.getByText("Count: 1")).toBeInTheDocument();
  });

  it("decrements when user clicks Decrement", async () => {
    render(<Counter />);
    await user.click(screen.getByRole("button", { name: "Increment" }));
    await user.click(screen.getByRole("button", { name: "Decrement" }));
    expect(screen.getByText("Count: 0")).toBeInTheDocument();
  });

  it("Reset button is disabled when count is 0", () => {
    render(<Counter />);
    expect(screen.getByRole("button", { name: "Reset" })).toBeDisabled();
  });

  it("resets to 0 after multiple increments", async () => {
    render(<Counter />);
    await user.click(screen.getByRole("button", { name: "Increment" }));
    await user.click(screen.getByRole("button", { name: "Increment" }));
    await user.click(screen.getByRole("button", { name: "Reset" }));
    expect(screen.getByText("Count: 0")).toBeInTheDocument();
  });
});

Testing Forms

TSX
function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");

  const handleSubmit = async (e) => {
    e.preventDefault();
    if (!email || !password) {
      setError("All fields required");
      return;
    }
    await onSubmit({ email, password });
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <label htmlFor="password">Password</label>
      <input
        id="password"
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      {error && <p role="alert">{error}</p>}
      <button type="submit">Sign In</button>
    </form>
  );
}

describe("LoginForm", () => {
  const user = userEvent.setup();

  it("submits with valid credentials", async () => {
    const handleSubmit = vi.fn();
    render(<LoginForm onSubmit={handleSubmit} />);

    await user.type(screen.getByLabelText("Email"), "alice@example.com");
    await user.type(screen.getByLabelText("Password"), "password123");
    await user.click(screen.getByRole("button", { name: "Sign In" }));

    expect(handleSubmit).toHaveBeenCalledWith({
      email: "alice@example.com",
      password: "password123",
    });
  });

  it("shows validation error when fields are empty", async () => {
    render(<LoginForm onSubmit={vi.fn()} />);

    await user.click(screen.getByRole("button", { name: "Sign In" }));

    expect(screen.getByRole("alert")).toHaveTextContent("All fields required");
  });

  it("clears the form after successful submit", async () => {
    const handleSubmit = vi.fn().mockResolvedValue(undefined);
    render(<LoginForm onSubmit={handleSubmit} />);

    const emailInput = screen.getByLabelText("Email");
    await user.type(emailInput, "alice@example.com");
    await user.type(screen.getByLabelText("Password"), "pass123");
    await user.click(screen.getByRole("button", { name: "Sign In" }));

    // Form should reset (if component implements it)
    expect(emailInput).toHaveValue("alice@example.com"); // or expect("")
  });
});

Testing Async Components

TSX
// Component that fetches data
function UserList() {
  const { data: users, isLoading, isError } = useFetch("/api/users");

  if (isLoading) return <div>Loading users...</div>;
  if (isError) return <div role="alert">Failed to load users</div>;

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

// Mocking fetch
describe("UserList", () => {
  beforeEach(() => {
    vi.resetAllMocks();
  });

  it("shows loading state initially", () => {
    global.fetch = vi.fn().mockReturnValue(new Promise(() => {})); // Never resolves

    render(<UserList />);
    expect(screen.getByText("Loading users...")).toBeInTheDocument();
  });

  it("renders users after loading", async () => {
    const mockUsers = [
      { id: 1, name: "Alice" },
      { id: 2, name: "Bob" },
    ];

    global.fetch = vi.fn().mockResolvedValue({
      ok: true,
      json: async () => mockUsers,
    });

    render(<UserList />);

    // findBy* waits for the element to appear
    expect(await screen.findByText("Alice")).toBeInTheDocument();
    expect(screen.getByText("Bob")).toBeInTheDocument();
    expect(screen.queryByText("Loading users...")).not.toBeInTheDocument();
  });

  it("shows error state on fetch failure", async () => {
    global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });

    render(<UserList />);

    expect(await screen.findByRole("alert")).toHaveTextContent(
      "Failed to load users"
    );
  });
});

Using MSW (Mock Service Worker) — The Right Way to Mock APIs

For integration tests, MSW intercepts actual network requests at the service worker level — your component code doesn't change.

TSX
// src/test/server.ts
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";

export const handlers = [
  http.get("/api/users", () => {
    return HttpResponse.json([
      { id: 1, name: "Alice", email: "alice@example.com", role: "admin" },
      { id: 2, name: "Bob", email: "bob@example.com", role: "user" },
    ]);
  }),

  http.post("/api/auth/login", async ({ request }) => {
    const body = await request.json();
    if (body.email === "alice@example.com" && body.password === "correct") {
      return HttpResponse.json({ token: "fake-token", user: { id: 1, name: "Alice" } });
    }
    return HttpResponse.json({ error: "Invalid credentials" }, { status: 401 });
  }),

  http.delete("/api/users/:id", ({ params }) => {
    return HttpResponse.json({ deleted: params.id });
  }),
];

export const server = setupServer(...handlers);

// src/test/setup.ts
import { server } from "./server";
beforeAll(() => server.listen({ onUnhandledRequest: "warn" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
TSX
// Integration test with MSW — no mocks needed in the test
describe("UserManagement", () => {
  const user = userEvent.setup();

  it("loads and displays users", async () => {
    render(<UserManagement />);

    await screen.findByText("Alice");
    expect(screen.getByText("Bob")).toBeInTheDocument();
  });

  it("can delete a user", async () => {
    render(<UserManagement />);
    await screen.findByText("Alice");

    await user.click(screen.getAllByRole("button", { name: "Delete" })[0]);
    await user.click(screen.getByRole("button", { name: "Confirm" }));

    // Server returns success, UI updates
    await waitForElementToBeRemoved(() => screen.queryByText("Alice"));
  });

  it("handles API errors gracefully", async () => {
    // Override for this specific test
    server.use(
      http.get("/api/users", () => {
        return HttpResponse.json({ error: "Server error" }, { status: 500 });
      })
    );

    render(<UserManagement />);
    expect(await screen.findByRole("alert")).toBeInTheDocument();
  });
});

Testing with Redux

TSX
// Test utility: render with Redux store
function renderWithStore(ui, { preloadedState, store: customStore, ...options } = {}) {
  const store = customStore ?? configureStore({
    reducer: rootReducer,
    preloadedState,
  });

  function Wrapper({ children }) {
    return <Provider store={store}>{children}</Provider>;
  }

  return {
    store,
    ...render(ui, { wrapper: Wrapper, ...options }),
  };
}

describe("CartIcon", () => {
  it("shows count from Redux store", () => {
    renderWithStore(<CartIcon />, {
      preloadedState: {
        cart: {
          items: [
            { id: "1", name: "Widget", price: 10, qty: 3 },
          ],
        },
      },
    });

    expect(screen.getByText("3")).toBeInTheDocument();
  });

  it("dispatches addItem when Add to Cart is clicked", async () => {
    const { store } = renderWithStore(<ProductCard product={mockProduct} />);
    const user = userEvent.setup();

    await user.click(screen.getByRole("button", { name: "Add to Cart" }));

    const state = store.getState();
    expect(state.cart.items).toHaveLength(1);
    expect(state.cart.items[0].id).toBe(mockProduct.id);
  });
});

Testing Custom Hooks

Use renderHook to test hooks in isolation:

TSX
import { renderHook, act } from "@testing-library/react";

describe("useCounter", () => {
  it("initializes with the given value", () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  it("increments the count", () => {
    const { result } = renderHook(() => useCounter(0));

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

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

  it("resets to initial value", () => {
    const { result } = renderHook(() => useCounter(5));

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

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

// Testing a hook that uses context
describe("useAuth", () => {
  it("returns the current user", () => {
    const mockUser = { id: "1", name: "Alice" };

    const wrapper = ({ children }) => (
      <AuthContext.Provider value={{ user: mockUser, login: vi.fn(), logout: vi.fn() }}>
        {children}
      </AuthContext.Provider>
    );

    const { result } = renderHook(() => useAuth(), { wrapper });
    expect(result.current.user).toEqual(mockUser);
  });

  it("throws when used outside AuthProvider", () => {
    // Suppress console.error for this test
    const spy = vi.spyOn(console, "error").mockImplementation(() => {});
    expect(() => renderHook(() => useAuth())).toThrow(
      "useAuth must be used within AuthProvider"
    );
    spy.mockRestore();
  });
});

Snapshot Testing — Used Sparingly

Snapshots catch unintended UI changes but are noisy and often useless. Use them only for stable, complex outputs.

TSX
it("renders correctly", () => {
  const { container } = render(<Badge count={5} status="active" />);
  expect(container.firstChild).toMatchSnapshot();
});

// Update snapshots when you intentionally change the component:
// npx vitest --update-snapshots

Better alternative for most cases: Test the behavior, not the markup.

Interview Questions Answered

Q: What is the difference between shallow rendering and full rendering?

Shallow rendering (Enzyme) renders only one component deep, mocking children. Full rendering (RTL default) renders the full component tree. RTL's philosophy: always full render, because that's what users see. Shallow rendering was popular with Enzyme but leads to implementation-coupled tests.

Q: What does act() do in React testing?

act() ensures that state updates and effects are processed before you make assertions. RTL's render, fireEvent, and userEvent all wrap their operations in act() automatically. You only need to call act() manually when testing hooks directly or triggering state updates outside of RTL helpers.

Q: What is the difference between getBy, queryBy, and findBy?

  • getBy* — synchronous, throws an error if element is not found (preferred for elements that should exist)
  • queryBy* — synchronous, returns null if not found (preferred for asserting absence: expect(queryBy...).not.toBeInTheDocument())
  • findBy* — asynchronous (returns Promise), waits up to 1 second for element to appear (required for async rendering)

Q: How do you test a component that makes API calls?

Preferred approach: Mock Service Worker (MSW) — intercepts at the network level, doesn't require changing component code. Alternative: mock fetch or axios with vi.mock()/jest.mock(). Avoid mocking the custom hook that makes the call — test the full integration.