Skip to main content

Chapter 32: Component Testing

Component tests verify that React components render correctly and respond to user interactions. We use React Testing Library, which tests components the way users interact with them.

Setup

// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
// src/test/render.tsx — Custom render with providers
import { render, type RenderOptions } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: Infinity },
mutations: { retry: false },
},
});
}

function AllProviders({ children }: { children: React.ReactNode }) {
const queryClient = createTestQueryClient();
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}

export function renderWithProviders(ui: React.ReactElement, options?: RenderOptions) {
return render(ui, { wrapper: AllProviders, ...options });
}

export { screen, within, waitFor } from "@testing-library/react";
export { userEvent } from "@testing-library/user-event";

Testing User Interactions

// features/projects/components/__tests__/project-card.test.tsx
import { describe, it, expect, vi } from "vitest";
import { renderWithProviders, screen } from "@/test/render";
import userEvent from "@testing-library/user-event";
import { ProjectCard } from "../project-card";

const mockProject = {
id: "proj-1",
name: "Test Project",
description: "A test project description",
status: "active" as const,
priority: "high" as const,
taskCount: 12,
updatedAt: new Date("2025-12-15"),
};

describe("ProjectCard", () => {
it("renders project name and description", () => {
renderWithProviders(<ProjectCard project={mockProject} />);

expect(screen.getByText("Test Project")).toBeInTheDocument();
expect(screen.getByText("A test project description")).toBeInTheDocument();
});

it("displays task count", () => {
renderWithProviders(<ProjectCard project={mockProject} />);
expect(screen.getByText("12 tasks")).toBeInTheDocument();
});

it("shows status badge", () => {
renderWithProviders(<ProjectCard project={mockProject} />);
expect(screen.getByText("active")).toBeInTheDocument();
});

it("calls onClick when card is clicked", async () => {
const user = userEvent.setup();
const onClick = vi.fn();

renderWithProviders(<ProjectCard project={mockProject} onClick={onClick} />);

await user.click(screen.getByText("Test Project"));
expect(onClick).toHaveBeenCalledWith(mockProject);
});
});

Testing Forms

// features/projects/components/__tests__/create-project-form.test.tsx
import { describe, it, expect, vi } from "vitest";
import { renderWithProviders, screen, waitFor } from "@/test/render";
import userEvent from "@testing-library/user-event";
import { CreateProjectForm } from "../create-project-form";

describe("CreateProjectForm", () => {
it("submits with valid data", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();

renderWithProviders(<CreateProjectForm onSubmit={onSubmit} />);

await user.type(screen.getByLabelText("Project Name"), "My Project");
await user.type(screen.getByLabelText("Description"), "A great project");
await user.click(screen.getByRole("button", { name: "Create Project" }));

await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({ name: "My Project" })
);
});
});

it("shows validation error for empty name", async () => {
const user = userEvent.setup();

renderWithProviders(<CreateProjectForm onSubmit={vi.fn()} />);

// Focus and blur the name field without entering anything
await user.click(screen.getByLabelText("Project Name"));
await user.tab();

await waitFor(() => {
expect(screen.getByText("Name is required")).toBeInTheDocument();
});
});

it("disables submit button while submitting", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn(() => new Promise((resolve) => setTimeout(resolve, 100)));

renderWithProviders(<CreateProjectForm onSubmit={onSubmit} />);

await user.type(screen.getByLabelText("Project Name"), "My Project");
await user.click(screen.getByRole("button", { name: "Create Project" }));

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

Testing Async Components

describe("ProjectList", () => {
it("shows loading skeleton initially", () => {
// Mock the query to be loading
renderWithProviders(<ProjectList organizationId="org-1" />);
expect(screen.getByTestId("project-list-skeleton")).toBeInTheDocument();
});

it("renders projects after loading", async () => {
// Use MSW or mock the query data
renderWithProviders(<ProjectList organizationId="org-1" />);

await waitFor(() => {
expect(screen.getByText("Project Alpha")).toBeInTheDocument();
expect(screen.getByText("Project Beta")).toBeInTheDocument();
});
});

it("shows empty state when no projects", async () => {
renderWithProviders(<ProjectList organizationId="empty-org" />);

await waitFor(() => {
expect(screen.getByText("No projects yet")).toBeInTheDocument();
});
});
});

Query Selectors: Best Practices

Prefer selectors that match how users find elements:

PrioritySelectorWhen
1stgetByRoleButtons, links, headings, inputs with labels
2ndgetByLabelTextForm inputs
3rdgetByPlaceholderTextInputs with placeholders
4thgetByTextNon-interactive elements with text
5thgetByDisplayValueInputs with current values
LastgetByTestIdWhen no semantic selector works
// ✅ Preferred: semantic selectors
screen.getByRole("button", { name: "Create Project" });
screen.getByLabelText("Email Address");
screen.getByRole("heading", { name: "Projects" });

// ❌ Avoid: implementation-coupled selectors
screen.getByTestId("submit-button");
screen.getByClassName("btn-primary");

Summary

  • React Testing Library tests components through user-facing behavior
  • Custom render wraps components in necessary providers
  • userEvent simulates realistic user interactions
  • Form testing covers validation, submission, and error states
  • Semantic selectors (getByRole, getByLabelText) over test IDs