agentsclimarketplace

Js testing

Skill iwritec0de/app-dev/skills/js-testing

Full-stack Next.js development plugin for Claude Code

Install
npx -y skills add iwritec0de/app-dev --skill js-testing

Assembled 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

  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

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

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.