From app-dev
This skill should be used when the user asks to 'write tests', 'add test coverage', 'test a component', 'test an API route', 'test a hook', 'set up Vitest', 'use React Testing Library', 'mock an API', or mentions 'component testing', 'integration tests', 'vitest', 'RTL', 'msw', 'testing library'. Provides JavaScript/TypeScript testing patterns using Vitest and React Testing Library for Next.js applications.
npx claudepluginhub iwritec0de/claude-plugin-marketplace --plugin app-devThis skill uses the workspace's default tool permissions.
1. **Vitest is the test runner.** Do not use Jest. All configuration, mocking, and assertions use the Vitest API (`vi.fn()`, `vi.mock()`, `vi.spyOn()`, etc.).
Provides Ktor server patterns for routing DSL, plugins (auth, CORS, serialization), Koin DI, WebSockets, services, and testApplication testing.
Conducts multi-source web research with firecrawl and exa MCPs: searches, scrapes pages, synthesizes cited reports. For deep dives, competitive analysis, tech evaluations, or due diligence.
Provides demand forecasting, safety stock optimization, replenishment planning, and promotional lift estimation for multi-location retailers managing 300-800 SKUs.
vi.fn(), vi.mock(), vi.spyOn(), etc.).getByTestId unless no semantic query is possible.renderHook).useState, useEffect, useRef, or any React built-in. If you need to control what a hook returns, extract the logic into a custom hook and test it with renderHook.fetch or axios directly. This gives realistic request/response cycles and catches serialization issues.userEvent over fireEvent. userEvent simulates real browser behavior (focus, blur, keystrokes). fireEvent dispatches synthetic DOM events and should only be used for events userEvent does not support.vitest.setup.ts or a dedicated test utility file, not scattered across tests via implicit globals.// components/greeting.tsx
export function Greeting({ name }: { name: string }) {
return <h1>Hello, {name}!</h1>;
}
// components/greeting.test.tsx
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { Greeting } from "./greeting";
describe("Greeting", () => {
it("renders the user name", () => {
render(<Greeting name="Kelley" />);
expect(
screen.getByRole("heading", { name: /hello, kelley/i })
).toBeInTheDocument();
});
});
// components/counter.tsx
"use client";
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
</div>
);
}
// components/counter.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { Counter } from "./counter";
describe("Counter", () => {
it("increments the count when the button is clicked", async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText("Count: 0")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /increment/i }));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});
});
// components/search-input.tsx
"use client";
import { useState } from "react";
export function SearchInput({ onSearch }: { onSearch: (query: string) => void }) {
const [query, setQuery] = useState("");
return (
<form
onSubmit={(e) => {
e.preventDefault();
onSearch(query);
}}
>
<label htmlFor="search">Search</label>
<input
id="search"
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button type="submit">Go</button>
</form>
);
}
// components/search-input.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { SearchInput } from "./search-input";
describe("SearchInput", () => {
it("calls onSearch with the typed query on submit", async () => {
const user = userEvent.setup();
const handleSearch = vi.fn();
render(<SearchInput onSearch={handleSearch} />);
await user.type(screen.getByLabelText(/search/i), "vitest");
await user.click(screen.getByRole("button", { name: /go/i }));
expect(handleSearch).toHaveBeenCalledWith("vitest");
expect(handleSearch).toHaveBeenCalledTimes(1);
});
});
// components/user-profile.tsx
"use client";
import { useEffect, useState } from "react";
interface User {
id: number;
name: string;
email: string;
}
export function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then((data) => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <p>Loading profile...</p>;
if (!user) return <p>User not found</p>;
return (
<article>
<h2>{user.name}</h2>
<p>{user.email}</p>
</article>
);
}
// components/user-profile.test.tsx
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { http, HttpResponse } from "msw";
import { server } from "@/test/msw-server";
import { UserProfile } from "./user-profile";
describe("UserProfile", () => {
it("shows loading state then renders user data", async () => {
server.use(
http.get("/api/users/1", () => {
return HttpResponse.json({
id: 1,
name: "Kelley",
email: "kelley@example.com",
});
})
);
render(<UserProfile userId={1} />);
expect(screen.getByText(/loading profile/i)).toBeInTheDocument();
expect(
await screen.findByRole("heading", { name: /kelley/i })
).toBeInTheDocument();
expect(screen.getByText("kelley@example.com")).toBeInTheDocument();
});
});
// components/contact-form.tsx
"use client";
import { useState } from "react";
export function ContactForm() {
const [submitted, setSubmitted] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const res = await fetch("/api/contact", {
method: "POST",
body: JSON.stringify({
name: formData.get("name"),
message: formData.get("message"),
}),
headers: { "Content-Type": "application/json" },
});
if (res.ok) setSubmitted(true);
}
if (submitted) return <p>Thank you for your message!</p>;
return (
<form onSubmit={handleSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
<button type="submit">Send</button>
</form>
);
}
// components/contact-form.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { http, HttpResponse } from "msw";
import { server } from "@/test/msw-server";
import { ContactForm } from "./contact-form";
describe("ContactForm", () => {
it("submits the form and shows a success message", async () => {
const user = userEvent.setup();
server.use(
http.post("/api/contact", () => {
return HttpResponse.json({ success: true });
})
);
render(<ContactForm />);
await user.type(screen.getByLabelText(/name/i), "Kelley");
await user.type(screen.getByLabelText(/message/i), "Hello there");
await user.click(screen.getByRole("button", { name: /send/i }));
expect(
await screen.findByText(/thank you for your message/i)
).toBeInTheDocument();
});
});
// components/contact-form.test.tsx (additional test)
describe("ContactForm", () => {
// ... success test above ...
it("shows an error message when submission fails", async () => {
const user = userEvent.setup();
server.use(
http.post("/api/contact", () => {
return HttpResponse.json(
{ error: "Server error" },
{ status: 500 }
);
})
);
render(<ContactForm />);
await user.type(screen.getByLabelText(/name/i), "Kelley");
await user.type(screen.getByLabelText(/message/i), "Hello");
await user.click(screen.getByRole("button", { name: /send/i }));
// The form should still be visible (not replaced by success message)
expect(
await screen.findByText(/something went wrong/i)
).toBeInTheDocument();
expect(screen.getByRole("button", { name: /send/i })).toBeInTheDocument();
});
});
// app/api/users/route.ts
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const role = searchParams.get("role");
const users = role
? await db.user.findMany({ where: { role } })
: await db.user.findMany();
return NextResponse.json(users);
}
// app/api/users/route.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET } from "./route";
vi.mock("@/lib/db", () => ({
db: {
user: {
findMany: vi.fn(),
},
},
}));
import { db } from "@/lib/db";
const mockFindMany = vi.mocked(db.user.findMany);
describe("GET /api/users", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns all users when no role filter is provided", async () => {
const users = [
{ id: 1, name: "Alice", role: "admin" },
{ id: 2, name: "Bob", role: "user" },
];
mockFindMany.mockResolvedValue(users);
const request = new Request("http://localhost/api/users");
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual(users);
expect(mockFindMany).toHaveBeenCalledWith();
});
it("filters users by role when query param is provided", async () => {
const admins = [{ id: 1, name: "Alice", role: "admin" }];
mockFindMany.mockResolvedValue(admins);
const request = new Request("http://localhost/api/users?role=admin");
const response = await GET(request);
const data = await response.json();
expect(data).toEqual(admins);
expect(mockFindMany).toHaveBeenCalledWith({ where: { role: "admin" } });
});
});
// app/api/posts/route.ts
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { getServerSession } from "@/lib/auth";
export async function POST(request: Request) {
const session = await getServerSession();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
if (!body.title || !body.content) {
return NextResponse.json(
{ error: "Title and content are required" },
{ status: 400 }
);
}
const post = await db.post.create({
data: {
title: body.title,
content: body.content,
authorId: session.user.id,
},
});
return NextResponse.json(post, { status: 201 });
}
// app/api/posts/route.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { POST } from "./route";
vi.mock("@/lib/db", () => ({
db: {
post: {
create: vi.fn(),
},
},
}));
vi.mock("@/lib/auth", () => ({
getServerSession: vi.fn(),
}));
import { db } from "@/lib/db";
import { getServerSession } from "@/lib/auth";
const mockCreate = vi.mocked(db.post.create);
const mockGetSession = vi.mocked(getServerSession);
function jsonRequest(body: unknown): Request {
return new Request("http://localhost/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
describe("POST /api/posts", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns 401 when the user is not authenticated", async () => {
mockGetSession.mockResolvedValue(null);
const response = await POST(jsonRequest({ title: "Hi", content: "Body" }));
expect(response.status).toBe(401);
expect(await response.json()).toEqual({ error: "Unauthorized" });
});
it("returns 400 when required fields are missing", async () => {
mockGetSession.mockResolvedValue({ user: { id: "u1", name: "Alice" } });
const response = await POST(jsonRequest({ title: "" }));
expect(response.status).toBe(400);
});
it("creates a post and returns 201", async () => {
mockGetSession.mockResolvedValue({ user: { id: "u1", name: "Alice" } });
mockCreate.mockResolvedValue({
id: "p1",
title: "My Post",
content: "Content here",
authorId: "u1",
} as any);
const response = await POST(
jsonRequest({ title: "My Post", content: "Content here" })
);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.title).toBe("My Post");
expect(mockCreate).toHaveBeenCalledWith({
data: {
title: "My Post",
content: "Content here",
authorId: "u1",
},
});
});
});
// hooks/use-debounce.ts
import { useState, useEffect } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// hooks/use-debounce.test.ts
import { renderHook, act } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { useDebounce } from "./use-debounce";
describe("useDebounce", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("returns the initial value immediately", () => {
const { result } = renderHook(() => useDebounce("hello", 500));
expect(result.current).toBe("hello");
});
it("updates the value after the delay", () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: "hello", delay: 500 } }
);
rerender({ value: "world", delay: 500 });
// Value should not have changed yet
expect(result.current).toBe("hello");
act(() => {
vi.advanceTimersByTime(500);
});
expect(result.current).toBe("world");
});
});
// hooks/use-current-user.ts
"use client";
import { useEffect, useState } from "react";
interface User {
id: string;
name: string;
}
export function useCurrentUser() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/me")
.then((res) => {
if (!res.ok) throw new Error("Failed to fetch user");
return res.json();
})
.then(setUser)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
return { user, loading, error };
}
// hooks/use-current-user.test.ts
import { renderHook, waitFor } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { http, HttpResponse } from "msw";
import { server } from "@/test/msw-server";
import { useCurrentUser } from "./use-current-user";
describe("useCurrentUser", () => {
it("returns the current user after loading", async () => {
server.use(
http.get("/api/me", () => {
return HttpResponse.json({ id: "u1", name: "Kelley" });
})
);
const { result } = renderHook(() => useCurrentUser());
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.user).toEqual({ id: "u1", name: "Kelley" });
expect(result.current.error).toBeNull();
});
it("returns an error when the API fails", async () => {
server.use(
http.get("/api/me", () => {
return HttpResponse.json(null, { status: 500 });
})
);
const { result } = renderHook(() => useCurrentUser());
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.user).toBeNull();
expect(result.current.error).toBe("Failed to fetch user");
});
});
// hooks/use-theme.test.tsx
import { renderHook } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { ThemeProvider } from "@/components/theme-provider";
import { useTheme } from "./use-theme";
function wrapper({ children }: { children: React.ReactNode }) {
return <ThemeProvider defaultTheme="dark">{children}</ThemeProvider>;
}
describe("useTheme", () => {
it("returns the theme from the provider", () => {
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current.theme).toBe("dark");
});
});
// test/msw-server.ts
import { setupServer } from "msw/node";
export const server = setupServer();
// Handlers are added per-test with server.use(...)
// vitest.setup.ts (excerpt)
import { server } from "@/test/msw-server";
import { beforeAll, afterEach, afterAll } from "vitest";
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
For integration tests, use a real database (SQLite in-memory, or a test Postgres instance). Only mock the database module for unit tests of API route handlers where you want to isolate the handler logic:
vi.mock("@/lib/db", () => ({
db: {
user: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
},
},
}));
vi.mock("@/lib/auth", () => ({
getServerSession: vi.fn(),
}));
// In a specific test:
vi.mocked(getServerSession).mockResolvedValue({
user: { id: "u1", name: "Alice", role: "admin" },
});
import { vi, beforeEach, afterEach } from "vitest";
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-15T10:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
// In a test:
vi.advanceTimersByTime(5000); // advance 5 seconds
vi.stubEnvimport { vi, beforeEach, afterEach } from "vitest";
beforeEach(() => {
vi.stubEnv("DATABASE_URL", "postgresql://test:test@localhost/testdb");
vi.stubEnv("NODE_ENV", "test");
});
afterEach(() => {
vi.unstubAllEnvs();
});
getByTestId When a Semantic Query Exists// BAD — requires adding data-testid to the component for no reason
screen.getByTestId("submit-button");
// GOOD — queries the way a user would find the button
screen.getByRole("button", { name: /submit/i });
Snapshot tests on large component trees are brittle and produce meaningless diffs. Test specific behavior instead.
// BAD
expect(container).toMatchSnapshot();
// GOOD
expect(screen.getByRole("heading", { name: /dashboard/i })).toBeInTheDocument();
expect(screen.getByText(/3 new notifications/i)).toBeInTheDocument();
useState or useEffectNever mock React internals. If a component's behavior depends on state, test the behavior through user interactions.
// BAD — fragile, breaks on refactors, tests nothing useful
vi.spyOn(React, "useState").mockReturnValue([true, vi.fn()]);
// GOOD — interact with the component as a user would
await user.click(screen.getByRole("button", { name: /toggle/i }));
expect(screen.getByText(/panel is open/i)).toBeInTheDocument();
vi.mock at Module Level for EverythingOnly mock what you must. Over-mocking makes tests pass even when the real integration is broken.
// BAD — mocking everything, test proves nothing
vi.mock("@/lib/utils");
vi.mock("@/lib/format");
vi.mock("@/components/button");
// GOOD — mock only the boundary (network, database)
// Let utils, formatters, and child components run with real code
// BAD — breaks when you change Tailwind classes
expect(element).toHaveClass("bg-red-500 text-white");
// GOOD — test the accessible semantics or visible outcome
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByRole("alert")).toHaveTextContent(/error/i);
Place test files next to the code they test:
components/
button.tsx
button.test.tsx
user-profile.tsx
user-profile.test.tsx
hooks/
use-debounce.ts
use-debounce.test.ts
app/
api/
users/
route.ts
route.test.ts
test/
msw-server.ts # MSW server instance
handlers.ts # Default/shared MSW handlers
render.tsx # Custom render with providers
factories.ts # Test data factories
// test/render.tsx
import { render, type RenderOptions } from "@testing-library/react";
import { ThemeProvider } from "@/components/theme-provider";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
function AllProviders({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="light">{children}</ThemeProvider>
</QueryClientProvider>
);
}
export function renderWithProviders(
ui: React.ReactElement,
options?: Omit<RenderOptions, "wrapper">
) {
return render(ui, { wrapper: AllProviders, ...options });
}
// vitest.setup.ts
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, beforeAll, afterAll } from "vitest";
import { server } from "@/test/msw-server";
// RTL cleanup after each test
afterEach(() => {
cleanup();
});
// MSW server lifecycle
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());