Skip to main content

testing-patterns

Next.js testing patterns with Vitest and Playwright. Use when the user asks about "testing Next.js", "unit tests", "integration tests", "E2E tests", "Vitest with Next.js", "Playwright", "testing Server Components", "testing Server Actions", "testing Route Handlers", "mocking next/navigation", "mocking next/headers", or needs guidance on test setup and patterns for App Router applications.

Source facts

Repository
Agents-Store/opencode-plugins
Last source activity
October 4, 2026 at 20:44
Detected SKILL.md language
English
Stars
0
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
2 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
testing-patterns
description
Next.js testing patterns with Vitest and Playwright. Use when the user asks about "testing Next.js", "unit tests", "integration tests", "E2E tests", "Vitest with Next.js", "Playwright", "testing Server Components", "testing Server Actions", "testing Route Handlers", "mocking next/navigation", "mocking next/headers", or needs guidance on test setup and patterns for App Router applications.
# Testing Patterns ## Types of Tests | Type | Tool | What it tests | |------|------|---------------| | Unit / Component | **Vitest** + React Testing Library | Individual components, hooks, utils | | End-to-End | **Playwright** | Full user flows in a real browser | | Snapshot | **Vitest** | Component rendered output stability | > **Note:** `async` Server Components are not fully supported in unit test frameworks yet. Use E2E testing for `async` Server Components. ## Vitest Setup ### Installation ```bash npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom ``` Vitest 5 requires Node.js >= 22.12 and Vite >= 6.4 (Node 20 is not supported). `vite` is now a peer dependency: npm, pnpm and Bun install it automatically, but with Yarn add it explicitly (`yarn add -D vite`). `clearMocks` now defaults to `true`, so recorded mock calls are cleared before every test — set `clearMocks: false` in the Vitest config only if a suite relies on history carrying over. ### Configuration ```typescript // 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', globals: true, setupFiles: ['./vitest.setup.ts'], css: true, alias: { '@': path.resolve(__dirname, './src'), }, }, }) ``` ```typescript // vitest.setup.ts import '@testing-library/jest-dom/vitest' ``` ### Add test script ```jsonc // package.json { "scripts": { "test": "vitest", "test:run": "vitest run", "test:coverage": "vitest run --coverage" } } ``` ## Testing Client Components ```tsx // src/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(count + 1)}>Increment</button> </div> ) } ``` ```tsx // src/components/__tests__/counter.test.tsx import { render, screen, fireEvent } from '@testing-library/react' import { describe, it, expect } from 'vitest' import { Counter } from '../counter' describe('Counter', () => { it('renders initial count of 0', () => { render(<Counter />) expect(screen.getByText('Count: 0')).toBeInTheDocument() }) it('increments count on click', () => { render(<Counter />) fireEvent.click(screen.getByRole('button', { name: /increment/i })) expect(screen.getByText('Count: 1')).toBeInTheDocument() }) }) ``` ## Mocking `next/navigation` ```tsx // src/components/__tests__/nav-component.test.tsx import { render, screen, fireEvent } from '@testing-library/react' import { describe, it, expect, vi, beforeEach } from 'vitest' // Mock next/navigation const pushMock = vi.fn() const replaceMock = vi.fn() vi.mock('next/navigation', () => ({ useRouter: () => ({ push: pushMock, replace: replaceMock, back: vi.fn(), forward: vi.fn(), refresh: vi.fn(), prefetch: vi.fn(), }), usePathname: () => '/current-path', useSearchParams: () => new URLSearchParams('q=test'), useParams: () => ({ id: '123' }), })) import { SearchForm } from '../search-form' describe('SearchForm', () => { beforeEach(() => { vi.clearAllMocks() }) it('navigates on submit', () => { render(<SearchForm />) const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'nextjs' } }) fireEvent.submit(screen.getByRole('form')) expect(pushMock).toHaveBeenCalledWith('/search?q=nextjs') }) }) ``` ## Mocking `next/headers` For testing code that uses `cookies()` or `headers()`: ```typescript // src/lib/__tests__/session.test.ts import { describe, it, expect, vi, beforeEach } from 'vitest' const mockCookies = { get: vi.fn(), set: vi.fn(), delete: vi.fn(), } vi.mock('next/headers', () => ({ cookies: vi.fn(async () => mockCookies), headers: vi.fn(async () => new Headers({ 'x-custom': 'value' })), })) import { getSession, deleteSession } from '../session' describe('Session', () => { beforeEach(() => { vi.clearAllMocks() }) it('returns null when no session cookie', async () => { mockCookies.get.mockReturnValue(undefined) const session = await getSession() expect(session).toBeNull() }) it('deletes session cookie', async () => { await deleteSession() expect(mockCookies.delete).toHaveBeenCalledWith('session') }) }) ``` ## Testing Server Actions Mock the dependencies, test the logic: ```typescript // src/app/actions/__tests__/post-actions.test.ts import { describe, it, expect, vi, beforeEach } from 'vitest' // Mock dependencies vi.mock('next/cache', () => ({ revalidatePath: vi.fn(), revalidateTag: vi.fn(), })) vi.mock('next/navigation', () => ({ redirect: vi.fn(), })) vi.mock('@/lib/db', () => ({ db: { post: { create: vi.fn(), delete: vi.fn(), }, }, })) import { createPost } from '../post-actions' import { revalidatePath } from 'next/cache' import { db } from '@/lib/db' describe('createPost', () => { beforeEach(() => { vi.clearAllMocks() }) it('creates a post with valid data', async () => { const formData = new FormData() formData.set('title', 'Test Post') formData.set('content', 'Test content that is long enough') vi.mocked(db.post.create).mockResolvedValue({ id: '1', title: 'Test Post' }) const result = await createPost({ message: '' }, formData) expect(db.post.create).toHaveBeenCalled() expect(revalidatePath).toHaveBeenCalledWith('/posts') expect(result.success).toBe(true) }) it('returns validation errors for invalid data', async () => { const formData = new FormData() formData.set('title', '') formData.set('content', 'short') const result = await createPost({ message: '' }, formData) expect(result.errors).toBeDefined() expect(db.post.create).not.toHaveBeenCalled() }) }) ``` ## Testing Route Handlers Create mock `NextRequest` objects: ```typescript // src/app/api/__tests__/posts.test.ts import { describe, it, expect, vi, beforeEach } from 'vitest' import { NextRequest } from 'next/server' vi.mock('@/lib/db', () => ({ db: { post: { findMany: vi.fn(), create: vi.fn(), }, }, })) import { GET, POST } from '../posts/route' import { db } from '@/lib/db' describe('GET /api/posts', () => { it('returns posts as JSON', async () => { const mockPosts = [{ id: '1', title: 'Test' }] vi.mocked(db.post.findMany).mockResolvedValue(mockPosts) const request = new NextRequest('http://localhost:3000/api/posts') const response = await GET(request) const data = await response.json() expect(response.status).toBe(200) expect(data).toEqual(mockPosts) }) it('handles query params', async () => { vi.mocked(db.post.findMany).mockResolvedValue([]) const request = new NextRequest('http://localhost:3000/api/posts?page=2&limit=5') await GET(request) expect(db.post.findMany).toHaveBeenCalledWith( expect.objectContaining({ skip: 5, take: 5 }) ) }) }) describe('POST /api/posts', () => { it('creates a post and returns 201', async () => { const newPost = { id: '1', title: 'New Post', content: 'Content' } vi.mocked(db.post.create).mockResolvedValue(newPost) const request = new NextRequest('http://localhost:3000/api/posts', { method: 'POST', body: JSON.stringify({ title: 'New Post', content: 'Content' }), headers: { 'Content-Type': 'application/json' }, }) const response = await POST(request) const data = await response.json() expect(response.status).toBe(201) expect(data.title).toBe('New Post') }) it('returns 422 for invalid body', async () => { const request = new NextRequest('http://localhost:3000/api/posts', { method: 'POST', body: JSON.stringify({ title: '' }), headers: { 'Content-Type': 'application/json' }, }) const response = await POST(request) expect(response.status).toBe(422) }) }) ``` ## Playwright E2E Setup ### Installation ```bash npm install -D @playwright/test npx playwright install ``` ### Configuration ```typescript // playwright.config.ts import { defineConfig, devices } from '@playwright/test' export default defineConfig({ testDir: './e2e', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: 'html', use: { baseURL: 'http://localhost:3000', trace: 'on-first-retry', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, { name: 'webkit', use: { ...devices['Desktop Safari'] } }, ], webServer: { command: 'npm run dev',
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub