Skip to main content Home Creators agents-store claude-plugins testing-patterns
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.
Jump to install Skills Marketplace Discover and explore AI skills built by the community.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Copy promptShow prompt details A direct command skips the review prompt. Inspect the source before running it.
npx skills add https://github.com/Agents-Store/claude-plugins --skill testing-patternsThe command stays on one line. Scroll horizontally to inspect it before copying.
Prefer a local copy? Download the files currently available to SkillsMP.
Download Zip Downloading... More from this repository
Related occupations SOC
Based on SOC occupation classification
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 LibraryIndividual 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
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom
Configuration
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from
({
: [ ()],
: {
: ,
: ,
: [ ],
: ,
: {
: path. (__dirname, ),
},
},
})
'path'
export
default
defineConfig
plugins
react
test
environment
'jsdom'
globals
true
setupFiles
'./vitest.setup.ts'
css
true
alias
'@'
resolve
'./src'
import '@testing-library/jest-dom/vitest'
Add test script
{
"scripts" : {
"test" : "vitest" ,
"test:run" : "vitest run" ,
"test:coverage" : "vitest run --coverage"
}
}
Testing Client Components
'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 >
)
}
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
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
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():
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:
import { describe, it, expect, vi, beforeEach } from 'vitest'
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:
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 npm install -D @playwright/test
npx playwright install
Configuration
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' ,
url : 'http://localhost:3000' ,
reuseExistingServer : !process.env .CI ,
},
})
Writing E2E Tests
import { test, expect } from '@playwright/test'
test ('homepage has correct title' , async ({ page }) => {
await page.goto ('/' )
await expect (page).toHaveTitle (/My App/ )
})
test ('navigation works' , async ({ page }) => {
await page.goto ('/' )
await page.click ('text=About' )
await expect (page).toHaveURL ('/about' )
await expect (page.locator ('h1' )).toContainText ('About' )
})
Auth State Reuse Save auth state once and reuse across tests:
import { test as setup, expect } from '@playwright/test'
const authFile = 'e2e/.auth/user.json'
setup ('authenticate' , async ({ page }) => {
await page.goto ('/login' )
await page.fill ('input[name="email"]' , 'test@example.com' )
await page.fill ('input[name="password"]' , 'password123' )
await page.click ('button[type="submit"]' )
await expect (page).toHaveURL ('/dashboard' )
await page.context ().storageState ({ path : authFile })
})
export default defineConfig ({
projects : [
{ name : 'setup' , testMatch : /.*\.setup\.ts/ },
{
name : 'chromium' ,
use : {
...devices['Desktop Chrome' ],
storageState : 'e2e/.auth/user.json' ,
},
dependencies : ['setup' ],
},
],
})
Testing Forms E2E
import { test, expect } from '@playwright/test'
test ('creates a new post' , async ({ page }) => {
await page.goto ('/posts/create' )
await page.fill ('input[name="title"]' , 'E2E Test Post' )
await page.fill ('textarea[name="content"]' , 'This is test content' )
await page.click ('button[type="submit"]' )
await expect (page).toHaveURL ('/posts' )
await expect (page.locator ('text=E2E Test Post' )).toBeVisible ()
})
test ('shows validation errors' , async ({ page }) => {
await page.goto ('/posts/create' )
await page.click ('button[type="submit"]' )
await expect (page.locator ('text=Title is required' )).toBeVisible ()
})
Instant Navigation Regression Tests (Next.js 16.3) With Cache Components + Partial Prefetching enabled, @next/playwright provides an instant() helper to assert that content is visible instantly during navigation (no loading gap):
import { test, expect } from '@playwright/test'
import { instant } from '@next/playwright'
test ('product page navigates instantly' , async ({ page }) => {
await page.goto ('/' )
await instant (page, async () => {
await page.click ('a[href="/products/1"]' )
await expect (page.locator ('h1' )).toBeVisible ()
})
})
CI Integration & File Organization For GitHub Actions workflows and test file organization conventions, see references/ci-and-organization.md.
What This Skill Does NOT Cover
Visual regression testing (Chromatic, Percy)
Load testing and performance benchmarks
Specific assertion library APIs beyond basics
Database seeding and test fixtures (ORM-specific)
Testing third-party integrations (mock them)