Component Testing
beginnerReact component testing with Testing Library, user events, and accessibility testing.
testingreacttesting-librarycomponents
Tested on⬢20▲16⚛19TS5.9
$ bunx sinew add testing/component-testingInteractive demo coming soon
1The Problem
Component tests are often brittle and don't catch real bugs:
- Testing implementation details breaks on refactors
- Missing accessibility issues
- User interactions aren't tested realistically
2The Solution
Use Testing Library to test components the way users interact with them. Focus on behavior, not implementation.
3Files
vitest.config.ts
vitest.config.tsTypeScript
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { resolve } from "path";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
globals: true,
css: true,
},
resolve: {
alias: {
"@": resolve(__dirname, "./"),
},
},
});vitest.setup.ts
vitest.setup.tsTypeScript
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});tests/utils.tsx
tests/utils.tsxTypeScript
import { render, RenderOptions } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ReactElement } from "react";
// Add providers here
function AllProviders({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
function customRender(
ui: ReactElement,
options?: Omit<RenderOptions, "wrapper">
) {
return {
user: userEvent.setup(),
...render(ui, { wrapper: AllProviders, ...options }),
};
}
export * from "@testing-library/react";
export { customRender as render };components/button.tsx
components/button.tsxTypeScript
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger";
loading?: boolean;
children: React.ReactNode;
}
export function Button({
variant = "primary",
loading = false,
children,
disabled,
...props
}: ButtonProps) {
return (
<button
className={`btn btn-${variant}`}
disabled={disabled || loading}
aria-busy={loading}
{...props}
>
{loading ? "Loading..." : children}
</button>
);
}tests/button.test.tsx
tests/button.test.tsxTypeScript
import { render, screen } from "./utils";
import { Button } from "@/components/button";
describe("Button", () => {
it("renders children", () => {
render(<Button>Click me</Button>);
expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
});
it("handles click events", async () => {
const handleClick = vi.fn();
const { user } = render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole("button"));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it("shows loading state", () => {
render(<Button loading>Submit</Button>);
const button = screen.getByRole("button");
expect(button).toHaveTextContent("Loading...");
expect(button).toBeDisabled();
expect(button).toHaveAttribute("aria-busy", "true");
});
it("is disabled when disabled prop is true", () => {
render(<Button disabled>Submit</Button>);
expect(screen.getByRole("button")).toBeDisabled();
});
it("does not fire click when disabled", async () => {
const handleClick = vi.fn();
const { user } = render(
<Button disabled onClick={handleClick}>
Click me
</Button>
);
await user.click(screen.getByRole("button"));
expect(handleClick).not.toHaveBeenCalled();
});
});components/search-form.tsx
components/search-form.tsxTypeScript
"use client";
import { useState } from "react";
interface SearchFormProps {
onSearch: (query: string) => void;
}
export function SearchForm({ onSearch }: SearchFormProps) {
const [query, setQuery] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (query.trim()) {
onSearch(query.trim());
}
};
return (
<form onSubmit={handleSubmit} role="search">
<label htmlFor="search-input" className="sr-only">
Search
</label>
<input
id="search-input"
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
aria-label="Search"
/>
<button type="submit">Search</button>
</form>
);
}tests/search-form.test.tsx
tests/search-form.test.tsxTypeScript
import { render, screen } from "./utils";
import { SearchForm } from "@/components/search-form";
describe("SearchForm", () => {
it("calls onSearch with query on submit", async () => {
const handleSearch = vi.fn();
const { user } = render(<SearchForm onSearch={handleSearch} />);
await user.type(screen.getByRole("searchbox"), "react testing");
await user.click(screen.getByRole("button", { name: "Search" }));
expect(handleSearch).toHaveBeenCalledWith("react testing");
});
it("does not call onSearch with empty query", async () => {
const handleSearch = vi.fn();
const { user } = render(<SearchForm onSearch={handleSearch} />);
await user.click(screen.getByRole("button", { name: "Search" }));
expect(handleSearch).not.toHaveBeenCalled();
});
it("trims whitespace from query", async () => {
const handleSearch = vi.fn();
const { user } = render(<SearchForm onSearch={handleSearch} />);
await user.type(screen.getByRole("searchbox"), " react ");
await user.click(screen.getByRole("button", { name: "Search" }));
expect(handleSearch).toHaveBeenCalledWith("react");
});
it("is accessible", () => {
render(<SearchForm onSearch={() => {}} />);
expect(screen.getByRole("search")).toBeInTheDocument();
expect(screen.getByRole("searchbox")).toHaveAccessibleName("Search");
});
});4Dependencies
$ bun add -D @testing-library/react @testing-library/user-event5Configuration
Package.json Scripts
{
"scripts": {
"test": "vitest",
"test:watch": "vitest --watch",
"test:coverage": "vitest --coverage"
}
}JSON
6Usage
Querying Elements
// Preferred: By role (most accessible)
screen.getByRole("button", { name: "Submit" });
screen.getByRole("textbox", { name: "Email" });
screen.getByRole("heading", { level: 1 });
// By label (forms)
screen.getByLabelText("Email");
// By placeholder (less preferred)
screen.getByPlaceholderText("Enter email");
// By text content
screen.getByText("Welcome");
// By test ID (last resort)
screen.getByTestId("custom-element");TypeScript
Async Queries
// Wait for element to appear
await screen.findByText("Loaded data");
// Assert element is not present
expect(screen.queryByText("Loading")).not.toBeInTheDocument();
// Wait for element to disappear
await waitForElementToBeRemoved(() => screen.queryByText("Loading"));TypeScript
User Events
const { user } = render(<MyComponent />);
// Typing
await user.type(input, "hello");
await user.clear(input);
// Clicking
await user.click(button);
await user.dblClick(element);
// Keyboard
await user.keyboard("{Enter}");
await user.tab();
// Selection
await user.selectOptions(select, ["option1"]);TypeScript
7Troubleshooting
Cannot find element
- Use
screen.debug()to see rendered output - Check that element is visible (not hidden by CSS)
- Use
findBy*for async elements
Events not firing
- Use
userEventinstead offireEvent - Make sure to
awaituser actions - Check that element is not disabled