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:
| Priority | Selector | When |
|---|---|---|
| 1st | getByRole | Buttons, links, headings, inputs with labels |
| 2nd | getByLabelText | Form inputs |
| 3rd | getByPlaceholderText | Inputs with placeholders |
| 4th | getByText | Non-interactive elements with text |
| 5th | getByDisplayValue | Inputs with current values |
| Last | getByTestId | When 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