React Component Testing — Vitest and React Testing Library

Vitest is Vite’s native test runner — it uses the same configuration as Vite (no separate babel or webpack setup), runs tests with the same module resolution, and is dramatically faster than Jest for Vite projects. React Testing Library (RTL) provides utilities for rendering React components and querying the DOM in a way that mirrors how users interact — by accessible role, text, label — rather than by CSS class or component internals. Together they enable component tests that are fast, close to the user’s experience, and resilient to implementation changes.

Vitest + RTL Setup

npm install -D vitest @vitest/ui jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom
// vite.config.js — add test configuration
import { defineConfig } from "vite";
import react            from "@vitejs/plugin-react";
import path             from "path";

export default defineConfig({
    plugins: [react()],
    resolve: { alias: { "@": path.resolve(__dirname, "src") } },
    test: {
        environment: "jsdom",          // simulate browser DOM
        globals:     true,             // describe, it, expect without imports
        setupFiles:  ["./src/test/setup.ts"],
        coverage: {
            provider: "v8",
            include:  ["src/**/*.{js,jsx}"],
            exclude:  ["src/main.jsx", "src/test/**"],
        },
    },
});

// src/test/setup.ts — global test setup
import "@testing-library/jest-dom";   // adds toBeInTheDocument(), toHaveText(), etc.
import { server } from "./mocks/server";  // MSW mock server (Lesson 3)

beforeAll(()  => server.listen());
afterEach(()  => server.resetHandlers());
afterAll(()   => server.close());
Note: React Testing Library’s philosophy is “test behaviour, not implementation.” Query elements by their accessible role (getByRole("button", { name: "Publish" })) rather than by class name (querySelector(".publish-btn")) or component props. Role-based queries reflect how screen readers and assistive technologies navigate the page — if the accessible role works correctly in a test, it works correctly for keyboard and screen reader users too. The secondary benefit is that tests survive visual redesigns as long as the behaviour stays the same.
Tip: Use @testing-library/user-event over fireEvent for simulating user interactions. userEvent.type(input, "hello") fires all the real events a browser would fire when typing (keydown, keypress, input, keyup), including focus and blur. fireEvent.change(input, { target: { value: "hello" } }) fires only the change event — close enough for most tests, but misses focus-related validation logic (like showing errors on blur). userEvent produces more realistic tests at only a minor additional complexity cost.
Warning: Components that use React Router hooks (useNavigate, useParams) will throw if rendered without a router context. Wrap them in <MemoryRouter> or use RTL’s render with a custom wrapper that includes all required providers (Router, Redux Provider, AuthProvider). Create a reusable renderWithProviders helper that wraps every render call with the necessary context, so test files do not repeat provider boilerplate.

Testing a React Component

// src/test/helpers/renderWithProviders.jsx
import { render }        from "@testing-library/react";
import { Provider }      from "react-redux";
import { BrowserRouter } from "react-router-dom";
import { store }         from "@/store";

export function renderWithProviders(ui, options = {}) {
    function Wrapper({ children }) {
        return (
            <Provider store={store}>
                <BrowserRouter>
                    {children}
                </BrowserRouter>
            </Provider>
        );
    }
    return render(ui, { wrapper: Wrapper, ...options });
}

// src/components/post/__tests__/PostCard.test.jsx
import { screen }            from "@testing-library/react";
import userEvent             from "@testing-library/user-event";
import { renderWithProviders } from "@/test/helpers/renderWithProviders";
import PostCard               from "@/components/post/PostCard";

const mockPost = {
    id:         1,
    title:      "Hello World",
    body:       "This is the body of the post.",
    status:     "published",
    like_count: 5,
    view_count: 100,
    created_at: "2025-06-01T12:00:00Z",
    author:     { id: 10, name: "Alice", avatar_url: null },
    tags:       [{ id: 1, name: "python" }, { id: 2, name: "fastapi" }],
};

describe("PostCard", () => {
    it("renders the post title", () => {
        renderWithProviders(<PostCard post={mockPost} />);
        expect(screen.getByRole("heading", { name: "Hello World" })).toBeInTheDocument();
    });

    it("renders tags", () => {
        renderWithProviders(<PostCard post={mockPost} />);
        expect(screen.getByText("python")).toBeInTheDocument();
        expect(screen.getByText("fastapi")).toBeInTheDocument();
    });

    it("renders author name", () => {
        renderWithProviders(<PostCard post={mockPost} />);
        expect(screen.getByText("Alice")).toBeInTheDocument();
    });

    it("calls onLike when like button is clicked", async () => {
        const user    = userEvent.setup();
        const onLike  = vi.fn();
        renderWithProviders(<PostCard post={mockPost} onLike={onLike} />);

        await user.click(screen.getByRole("button", { name: /like/i }));
        expect(onLike).toHaveBeenCalledWith(mockPost.id);
    });

    it("does not show like button when onLike is not provided", () => {
        renderWithProviders(<PostCard post={mockPost} />);
        expect(screen.queryByRole("button", { name: /like/i })).not.toBeInTheDocument();
    });
});

Common Mistakes

Mistake 1 — Querying by class name (brittle to style changes)

❌ Wrong:

container.querySelector(".like-button");   // breaks if class name changes

✅ Correct:

screen.getByRole("button", { name: /like/i });   // ✓ accessible role

Mistake 2 — Not wrapping async interactions in act() or using userEvent correctly

❌ Wrong — state update warning:

fireEvent.click(button);   // state updates not wrapped

✅ Correct:

await userEvent.click(button);   // ✓ userEvent handles act() internally

🧠 Test Yourself

Why does React Testing Library recommend querying by role (e.g., getByRole("button")) rather than by test ID (e.g., getByTestId("like-btn"))?