Testing Helper
Master testing for React and React Router v7 applications. Learn how to write effective tests using Vitest and React Testing Library.
Quick Reference
Basic Component Test
import { render, screen } from "@testing-library/react";
import { expect, test } from "vitest";
test("renders button", () => {
render(<Button>Click me</Button>);
expect(screen.getByText("Click me")).toBeInTheDocument();
});
Test User Interactions
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
test("handles click", async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click</Button>);
await user.click(screen.getByRole("button"));
expect(handleClick).toHaveBeenCalledOnce();
});
Test Loaders
import { loader } from "./route";
test("loader fetches user", async () {
const result = await loader({
params: { userId: "123" },
request: new Request("http://localhost"),
context: {},
});
expect(result.user).toBeDefined();
});
When to Use This Skill
- Setting up testing infrastructure
- Writing component tests
- Testing loaders and actions
- Mocking API calls
- Testing user interactions
- Testing forms and validation
- Integration testing routes
Setup
Install Dependencies
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
Configure Vitest
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./test/setup.ts"],
globals: true,
},
});
Test Setup File
// test/setup.ts
import "@testing-library/jest-dom";
import { expect, afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
// Cleanup after each test
afterEach(() => {
cleanup();
});
Component Testing
1. Basic Rendering
import { render, screen } from "@testing-library/react";
import { describe, test, expect } from "vitest";
import { UserCard } from "./UserCard";
describe("UserCard", () => {
test("renders user information", () => {
const user = {
id: "1",
name: "John Doe",
email: "john@example.com",
};
render(<UserCard user={user} />);
expect(screen.getByText("John Doe")).toBeInTheDocument();
expect(screen.getByText("john@example.com")).toBeInTheDocument();
});
test("displays avatar when provided", () => {
const user = {
id: "1",
name: "John Doe",
email: "john@example.com",
avatar: "https://example.com/avatar.jpg",
};
render(<UserCard user={user} />);
const avatar = screen.getByRole("img", { name: "John Doe" });
expect(avatar).toHaveAttribute("src", user.avatar);
});
});
2. User Interactions
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
test("calls onClick when button is clicked", async () => {
const user = userEvent.setup();
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole("button", { name: "Click me" }));
expect(handleClick).toHaveBeenCalledOnce();
});
test("types in input field", async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
render(<input onChange={handleChange} />);
await user.type(screen.getByRole("textbox"), "Hello");
expect(screen.getByRole("textbox")).toHaveValue("Hello");
expect(handleChange).toHaveBeenCalledTimes(5); // Once per character
});
3. Async Operations
import { render, screen, waitFor } from "@testing-library/react";
test("loads and displays data", async () => {
render(<UserProfile userId="123" />);
// Initially shows loading
expect(screen.getByText("Loading...")).toBeInTheDocument();
// Wait for data to load
await waitFor(() => {
expect(screen.getByText("John Doe")).toBeInTheDocument();
});
// Loading indicator is gone
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});
Testing React Router
1. Test Loaders
import { loader } from "./route";
import { vi } from "vitest";
// Mock fetch
global.fetch = vi.fn();
test("loader returns user data", async () => {
const mockUser = { id: "123", name: "John" };
(fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
});
const result = await loader({
params: { userId: "123" },
request: new Request("http://localhost/users/123"),
context: {},
});
expect(result).toEqual({ user: mockUser });
expect(fetch).toHaveBeenCalledWith("/api/users/123");
});
test("loader throws on 404", async () => {
(fetch as any).mockResolvedValueOnce({
ok: false,
status: 404,
});
await expect(loader({
params: { userId: "999" },
request: new Request("http://localhost/users/999"),
context: {},
})).rejects.toThrow();
});
2. Test Actions
import { action } from "./route";
test("action creates user on valid data", async () => {
const formData = new FormData();
formData.set("name", "John Doe");
formData.set("email", "john@example.com");
const request = new Request("http://localhost/users", {
method: "POST",
body: formData,
});
const result = await action({
request,
params: {},
context: {},
});
expect(result).toHaveProperty("user");
});
test("action returns errors on invalid data", async () => {
const formData = new FormData();
formData.set("name", "");
formData.set("email", "invalid");
const request = new Request("http://localhost/users", {
method: "POST",
body: formData,
});
const result = await action({
request,
params: {},
context: {},
});
expect(result).toHaveProperty("errors");
expect(result.errors).toHaveProperty("name");
expect(result.errors).toHaveProperty("email");
});
3. Test Routes with RouterProvider
import { render, screen } from "@testing-library/react";
import { createMemoryRouter, RouterProvider } from "react-router";
test("renders route component", async () => {
const router = createMemoryRouter(
[
{
path: "/users/:userId",
element: <UserProfile />,
loader: async () => ({ user: { id: "1", name: "John" } }),
},
],
{
initialEntries: ["/users/1"],
}
);
render(<RouterProvider router={router} />);
await screen.findByText("John");
});
Mocking
1. Mock API Calls
import { vi } from "vitest";
// Mock fetch globally
global.fetch = vi.fn();
// Mock specific responses
(fetch as any).mockResolvedValue({
ok: true,
json: async () => ({ data: "mocked" }),
});
// Clean up after test
afterEach(() => {
vi.clearAllMocks();
});
2. Mock Modules
import { vi } from "vitest";
// Mock entire module
vi.mock("./api", () => ({
fetchUser: vi.fn(),
createUser: vi.fn(),
}));
// Import mocked module
import { fetchUser } from "./api";
test("uses mocked API", async () => {
(fetchUser as any).mockResolvedValue({ id: "1", name: "John" });
const user = await fetchUser("1");
expect(user).toEqual({ id: "1", name: "John" });
});
3. Mock React Router Hooks
import { vi } from "vitest";
import * as ReactRouter from "react-router";
vi.spyOn(ReactRouter, "useNavigate").mockReturnValue(vi.fn());
vi.spyOn(ReactRouter, "useLoaderData").mockReturnValue({
user: { id: "1", name: "John" },
});
Form Testing
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createMemoryRouter, RouterProvider } from "react-router";
test("submits form with valid data", async () => {
const user = userEvent.setup();
const actionSpy = vi.fn().mockResolvedValue({ success: true });
const router = createMemoryRouter(
[
{
path: "/create",
element: <CreateUserForm />,
action: actionSpy,
},
],
{
initialEntries: ["/create"],
}
);
render(<RouterProvider router={router} />);
// Fill form
await user.type(screen.getByLabelText("Name"), "John Doe");
await user.type(screen.getByLabelText("Email"), "john@example.com");
// Submit
await user.click(screen.getByRole("button", { name: "Submit" }));
// Verify action was called
expect(actionSpy).toHaveBeenCalled();
});
test("displays validation errors", async () => {
const user = userEvent.setup();
const actionSpy = vi.fn().mockResolvedValue({
errors: { email: ["Invalid email"] },
});
const router = createMemoryRouter(
[
{
path: "/create",
element: <CreateUserForm />,
action: actionSpy,
},
],
{
initialEntries: ["/create"],
}
);
render(<RouterProvider router={router} />);
await user.type(screen.getByLabelText("Email"), "invalid");
await user.click(screen.getByRole("button", { name: "Submit" }));
await screen.findByText("Invalid email");
});
Best Practices
- Use
screenqueries over destructured render result - Prefer
getByRoleover other query methods - Use
userEventinstead offireEvent - Test user behavior, not implementation details
- Mock external dependencies (APIs, modules)
- Clean up after each test
- Use
waitForfor async assertions - Test accessibility with role queries
- Don't test third-party libraries
- Keep tests simple and focused
Query Priority
Use queries in this order:
getByRole- Most accessiblegetByLabelText- Good for formsgetByPlaceholderText- Form fallbackgetByText- User-visible textgetByTestId- Last resort
// ✅ Best - accessible
screen.getByRole("button", { name: "Submit" });
screen.getByRole("textbox", { name: "Email" });
// ✅ Good - form labels
screen.getByLabelText("Email");
// ⚠️ Okay - if no role/label
screen.getByPlaceholderText("Enter email");
// ⚠️ Fallback
screen.getByText("Submit");
// ❌ Last resort
screen.getByTestId("submit-button");
Common Issues
Issue 1: Act Warnings
Symptoms: "Warning: An update to Component was not wrapped in act()"
Cause: State updates not properly awaited
Solution: Use waitFor or findBy queries
// ❌ Causes act warning
render(<AsyncComponent />);
expect(screen.getByText("Loaded")).toBeInTheDocument();
// ✅ Waits for update
render(<AsyncComponent />);
await screen.findByText("Loaded");
Issue 2: Can't Find Element
Symptoms: "Unable to find an element" Cause: Wrong query or timing issue Solution: Use correct query method and wait for element
// ❌ Element not visible yet
expect(screen.getByText("Success")).toBeInTheDocument();
// ✅ Wait for element to appear
await screen.findByText("Success");
// Or check if it doesn't exist
expect(screen.queryByText("Error")).not.toBeInTheDocument();