Js testing
Full-stack Next.js development plugin for Claude Code
npx -y skills add iwritec0de/app-dev --skill js-testingAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 3 stars3 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
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.
The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.
SKILL.md
23.0 KB, as published. Nobody here has run it
JS Testing Skill — Vitest + React Testing Library for Next.js
Critical Rules
- 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.). - React Testing Library (RTL) for component tests. Query elements the way a user would find them — by role, label, text, or placeholder. Never reach for
getByTestIdunless no semantic query is possible. - Test user behavior, not implementation. Assert what the user sees and can interact with. Do not assert on component state, refs, internal variables, or hook return values directly (except when using
renderHook). - Never test React internals. Do not mock
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 withrenderHook. - Use MSW for API mocking. Mock at the network layer with Mock Service Worker, not by stubbing
fetchoraxiosdirectly. This gives realistic request/response cycles and catches serialization issues. - Prefer
userEventoverfireEvent.userEventsimulates real browser behavior (focus, blur, keystrokes).fireEventdispatches synthetic DOM events and should only be used for eventsuserEventdoes not support. - Every test file must be self-contained. Shared setup belongs in
vitest.setup.tsor a dedicated test utility file, not scattered across tests via implicit globals.
Component Testing
Basic Render Test
// 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();
});
});
User Interaction Test (Click)
// 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();
});
});
User Interaction Test (Typing)
// 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);
});
});
Async Data Loading Test
// 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: "[email protected]",
});
})
);
render(<UserProfile userId={1} />);
expect(screen.getByText(/loading profile/i)).toBeInTheDocument();
expect(
await screen.findByRole("heading", { name: /kelley/i })
).toBeInTheDocument();
expect(screen.getByText("[email protected]")).toBeInTheDocument();
});
});
Form Submission Test
// 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();
});
});
Error State Test
// 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();
});
});
API Route Testing (Next.js App Router)
Testing a GET Route Handler
// 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" } });
});
});
Testing a POST Route Handler
// 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",
},
});
});
});
Hook Testing
Testing a Custom Hook
// 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");
});
});
Testing an Async Hook with Providers
// 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");
});
});
Wrapping a Hook with Providers
// 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");
});
});
What to Mock and How
External APIs — Use MSW
// 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());
Database — Prefer a Test Database, Not a Mock ORM
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(),
},
},
}));
Auth — Mock the Session
vi.mock("@/lib/auth", () => ({
getServerSession: vi.fn(),
}));
// In a specific test:
vi.mocked(getServerSession).mockResolvedValue({
user: { id: "u1", name: "Alice", role: "admin" },
});
Time — Use Fake Timers
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
Environment Variables — Use vi.stubEnv
import { vi, beforeEach, afterEach } from "vitest";
beforeEach(() => {
vi.stubEnv("DATABASE_URL", "postgresql://test:test@localhost/testdb");
vi.stubEnv("NODE_ENV", "test");
});
afterEach(() => {
vi.unstubAllEnvs();
});
Anti-Patterns (JS-Specific)
1. Using 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 });
2. Snapshot Tests for Complex Components
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();
3. Mocking useState or useEffect
Never 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();
4. Using vi.mock at Module Level for Everything
Only 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
5. Testing CSS Classes or Styles
// 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);
Test File Organization
Co-Located Tests (Preferred)
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
Shared Test Utilities
test/
msw-server.ts # MSW server instance
handlers.ts # Default/shared MSW handlers
render.tsx # Custom render with providers
factories.ts # Test data factories
Custom Render with Providers
// 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 });
}
Setup Files
// 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());