--- name: testing-helper description: Testing patterns for React and React Router v7 - Vitest, React Testing Library, route testing, mocking loaders/actions tags: [testing, vitest, react-testing-library, react-router] version: 1.0.0 author: Code Visionary --- # 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 ```typescript import { render, screen } from "@testing-library/react"; import { expect, test } from "vitest"; test("renders button", () => { render(); expect(screen.getByText("Click me")).toBeInTheDocument(); }); ``` ### Test User Interactions ```typescript 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(); await user.click(screen.getByRole("button")); expect(handleClick).toHaveBeenCalledOnce(); }); ``` ### Test Loaders ```typescript 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 ```bash npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom ``` ### Configure Vitest ```typescript // 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 ```typescript // 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 ```typescript 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(); 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(); const avatar = screen.getByRole("img", { name: "John Doe" }); expect(avatar).toHaveAttribute("src", user.avatar); }); }); ``` ### 2. User Interactions ```typescript 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(); 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(); await user.type(screen.getByRole("textbox"), "Hello"); expect(screen.getByRole("textbox")).toHaveValue("Hello"); expect(handleChange).toHaveBeenCalledTimes(5); // Once per character }); ``` ### 3. Async Operations ```typescript import { render, screen, waitFor } from "@testing-library/react"; test("loads and displays data", async () => { render(); // 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 ```typescript 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 ```typescript 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 ```typescript 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: , loader: async () => ({ user: { id: "1", name: "John" } }), }, ], { initialEntries: ["/users/1"], } ); render(); await screen.findByText("John"); }); ``` ## Mocking ### 1. Mock API Calls ```typescript 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 ```typescript 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 ```typescript 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 ```typescript 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: , action: actionSpy, }, ], { initialEntries: ["/create"], } ); render(); // 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: , action: actionSpy, }, ], { initialEntries: ["/create"], } ); render(); await user.type(screen.getByLabelText("Email"), "invalid"); await user.click(screen.getByRole("button", { name: "Submit" })); await screen.findByText("Invalid email"); }); ``` ## Best Practices - [ ] Use `screen` queries over destructured render result - [ ] Prefer `getByRole` over other query methods - [ ] Use `userEvent` instead of `fireEvent` - [ ] Test user behavior, not implementation details - [ ] Mock external dependencies (APIs, modules) - [ ] Clean up after each test - [ ] Use `waitFor` for 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: 1. **`getByRole`** - Most accessible 2. **`getByLabelText`** - Good for forms 3. **`getByPlaceholderText`** - Form fallback 4. **`getByText`** - User-visible text 5. **`getByTestId`** - Last resort ```typescript // ✅ 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 ```typescript // ❌ Causes act warning render(); expect(screen.getByText("Loaded")).toBeInTheDocument(); // ✅ Waits for update render(); 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 ```typescript // ❌ 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(); ``` ## References - [Vitest Documentation](https://vitest.dev/) - [React Testing Library](https://testing-library.com/react) - [Testing Library Queries](https://testing-library.com/docs/queries/about) - [React Router Testing](https://reactrouter.com/start/framework/testing) - [loader-action-optimizer skill](../loader-action-optimizer/)