From nextjs-plugin
Testing strategies for Next.js: Server Components, Client Components, Server Actions, Route Handlers, end-to-end with Playwright. Vitest/Jest configuration, React Testing Library patterns, msw for network mocks. Use this skill to: - Pick the right test layer for what you're testing. - Configure Vitest or Jest for Next.js. - Test Server Components without running them in isolation. - Test Server Actions and Route Handlers as pure functions. - Set up Playwright for e2e and integration of RSC. Do NOT use this skill for: - General Next.js conventions (see nextjs-conventions). - RSC vs Client model (see server-component-patterns). - Plain Node.js test patterns (see nodejs-plugin equivalents).
How this skill is triggered — by the user, by Claude, or both
Slash command
/nextjs-plugin:nextjs-testingThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Next.js testing splits along the same line as the runtime: server-side code runs in Node, client-side in jsdom or a browser. The right tool depends on the layer.
Next.js testing splits along the same line as the runtime: server-side code runs in Node, client-side in jsdom or a browser. The right tool depends on the layer.
| Layer | Framework |
|---|---|
| Client Components, hooks, plain JS/TS units | Vitest (preferred for new projects) or Jest |
| Server Components | Playwright integration tests OR Vitest with the right config |
| Server Actions | Vitest (treat the action body as a pure function — extract logic) |
| Route Handlers | Vitest with mocked NextRequest |
| End-to-end (full stack) | Playwright (Vercel-recommended) or Cypress |
For new projects, prefer Vitest + Playwright. Match what exists otherwise.
vitest.config.ts:
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
globals: true,
css: true,
},
resolve: {
alias: { '@': path.resolve(__dirname, './') },
},
});
vitest.setup.ts:
import '@testing-library/jest-dom/vitest';
Install: pnpm add -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event.
// app/users/_components/UserFilter.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserFilter } from './UserFilter';
describe('UserFilter', () => {
it('calls onChange as user types', async () => {
const onChange = vi.fn();
const user = userEvent.setup();
render(<UserFilter onChange={onChange} />);
await user.type(screen.getByRole('textbox'), 'alice');
expect(onChange).toHaveBeenLastCalledWith('alice');
});
});
Query priority: getByRole > getByLabelText > getByText > getByTestId (last resort). Roles match how assistive tech sees the UI.
Server Components are async and run on the server — they don't render in jsdom directly. Two approaches:
// app/users/page.tsx
import { db } from '@/lib/db';
import { UserList } from './_components/UserList';
export async function getUsers() {
return db.users.findMany();
}
export default async function UsersPage() {
const users = await getUsers();
return <UserList users={users} />;
}
// app/users/page.test.ts
import { describe, it, expect, vi } from 'vitest';
import { getUsers } from './page';
import { db } from '@/lib/db';
vi.mock('@/lib/db', () => ({
db: { users: { findMany: vi.fn().mockResolvedValue([{ id: '1', name: 'Alice' }]) } },
}));
describe('getUsers', () => {
it('returns users from DB', async () => {
expect(await getUsers()).toEqual([{ id: '1', name: 'Alice' }]);
});
});
The view layer (UserList) is a pure component — test separately as a unit.
// e2e/users.spec.ts
import { test, expect } from '@playwright/test';
test('users page lists users', async ({ page }) => {
await page.goto('/users');
await expect(page.getByRole('listitem')).toHaveCount(3);
await expect(page.getByText('Alice')).toBeVisible();
});
Run against a test database with known seed data, OR mock the API layer the page consumes.
Server Actions are async functions — testable as pure code if you extract the logic:
// app/users/actions.ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
const Schema = z.object({ email: z.string().email(), name: z.string().min(1) });
export async function createUser(formData: FormData) {
const parsed = Schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { ok: false as const, error: parsed.error.flatten() };
const user = await db.users.create({ data: parsed.data });
revalidatePath('/users');
return { ok: true as const, user };
}
// app/users/actions.test.ts
import { describe, it, expect, vi } from 'vitest';
import { createUser } from './actions';
vi.mock('@/lib/db', () => ({
db: { users: { create: vi.fn().mockResolvedValue({ id: '1', email: '[email protected]', name: 'A' }) } },
}));
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
describe('createUser', () => {
it('returns ok with valid input', async () => {
const fd = new FormData();
fd.set('email', '[email protected]');
fd.set('name', 'Alice');
const result = await createUser(fd);
expect(result.ok).toBe(true);
});
it('returns error on invalid input', async () => {
const fd = new FormData();
fd.set('email', 'invalid');
const result = await createUser(fd);
expect(result.ok).toBe(false);
});
});
For the auth check at the top of the action, mock auth() similarly.
// app/api/users/route.test.ts
import { describe, it, expect, vi } from 'vitest';
import { NextRequest } from 'next/server';
import { GET, POST } from './route';
vi.mock('@/lib/db', () => ({
db: { users: { findMany: vi.fn().mockResolvedValue([]), create: vi.fn() } },
}));
describe('GET /api/users', () => {
it('returns 200 with users', async () => {
const res = await GET();
expect(res.status).toBe(200);
expect(await res.json()).toEqual([]);
});
});
describe('POST /api/users', () => {
it('returns 400 on invalid body', async () => {
const req = new NextRequest('http://localhost/api/users', {
method: 'POST',
body: JSON.stringify({ email: 'bad' }),
});
const res = await POST(req);
expect(res.status).toBe(400);
});
});
For dynamic routes:
import { GET } from './[id]/route';
const res = await GET(new NextRequest('...'), { params: Promise.resolve({ id: '123' }) });
(In Next.js 15+ params is Promise; otherwise plain object.)
For component tests that hit fetch:
// vitest.setup.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
http.get('https://api.example.com/users', () =>
HttpResponse.json([{ id: '1', name: 'Alice' }])
)
);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Per-test override:
import { http, HttpResponse } from 'msw';
server.use(http.get('...', () => HttpResponse.error()));
playwright.config.ts:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { browserName: 'chromium' } },
{ name: 'firefox', use: { browserName: 'firefox' } },
{ name: 'webkit', use: { browserName: 'webkit' } },
],
});
Install: pnpm dlx playwright install.
Target ≥80% on services, route handlers, server actions, and Client Components with logic. Skip:
next.config.js, middleware.ts config bodies (test the behavior end-to-end).Configure exclusions:
// vitest.config.ts
test: {
coverage: {
exclude: [
'app/**/layout.tsx',
'app/**/loading.tsx',
'app/**/error.tsx',
'app/**/not-found.tsx',
'**/*.config.{js,ts,mjs}',
'middleware.ts',
],
},
}
The qa-engineer agent has a hard 3-attempt cap on fixing failing tests. If a test is fundamentally fragile after 3 attempts, mark it it.skip(...) with a comment explaining why and report in the QA summary. Don't iterate past the cap.
getByTestId as the first choice. Use getByRole / getByLabelText first.localhost:3000 without webServer config — Playwright won't start the dev server.npx claudepluginhub aratkruglik/claude-sdlc --plugin nextjs-pluginTests Next.js App Router components, Server Actions, and Route Handlers using Jest, Vitest, and MSW. Covers mocking next/navigation, next/headers, and direct handler invocation.
Testing React SPAs: React Testing Library + Vitest/Jest for components and hooks, msw for network mocks, Playwright/Cypress for e2e. Query priority, user events, async assertions, mocking patterns. Use this skill to: - Pick the right runner (Vitest vs Jest) and setup. - Write component tests with RTL using accessible queries. - Test custom hooks via renderHook. - Mock network with msw at the boundary. - Set up Playwright or Cypress for end-to-end coverage. Do NOT use this skill for: - General React conventions (see react-conventions). - State management testing patterns specific to a store lib (see react-state-management). - Form-specific testing (apply react-forms patterns inside test).
Provides Playwright E2E testing best practices for Next.js apps: selectors, flaky test debugging, auth state, API mocking, parallel execution, and CI config.