# JS Testing

> 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.

- Skill: `iwritec0de/js-testing` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add iwritec0de/js-testing`
- Raw SKILL.md: https://api.skillmd.com/api/skills/iwritec0de/js-testing/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- License: MIT
- Author: iwritec0de (https://skillmd.com/u/iwritec0de)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/iwritec0de/js-testing

---


# JS Testing Skill — Vitest + React Testing Library for Next.js

## Critical Rules

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.).
2. **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 `getByTestId` unless no semantic query is possible.
3. **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`).
4. **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 with `renderHook`.
5. **Use MSW for API mocking.** Mock at the network layer with Mock Service Worker, not by stubbing `fetch` or `axios` directly. This gives realistic request/response cycles and catches serialization issues.
6. **Prefer `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.
7. **Every test file must be self-contained.** Shared setup belongs in `vitest.setup.ts` or a dedicated test utility file, not scattered across tests via implicit globals.

---

## Component Testing

### Basic Render Test

```tsx
// components/greeting.tsx
export function Greeting({ name }: { name: string }) {
  return <h1>Hello, {name}!</h1>;
}
```

```tsx
// 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)

```tsx
// 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>
  );
}
```

```tsx
// 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)

```tsx
// 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>
  );
}
```

```tsx
// 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

```tsx
// 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>
  );
}
```

```tsx
// 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();
  });
});
```

### Form Submission Test

```tsx
// 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>
  );
}
```

```tsx
// 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

```tsx
// 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

```ts
// 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);
}
```

```ts
// 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

```ts
// 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 });
}
```

```ts
// 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

```ts
// 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;
}
```

```ts
// 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

```tsx
// 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 };
}
```

```tsx
// 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

```tsx
// 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

```ts
// test/msw-server.ts
import { setupServer } from "msw/node";

export const server = setupServer();
// Handlers are added per-test with server.use(...)
```

```ts
// 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:

```ts
vi.mock("@/lib/db", () => ({
  db: {
    user: {
      findMany: vi.fn(),
      findUnique: vi.fn(),
      create: vi.fn(),
    },
  },
}));
```

### Auth — Mock the Session

```ts
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

```ts
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`

```ts
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

```tsx
// 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.

```tsx
// 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.

```tsx
// 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.

```tsx
// 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

```tsx
// 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

```tsx
// 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

```ts
// 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());
```

