Unit Testing and Integration Testing: A Comprehensive Guide to Vitest + Testing Library + MSW
Testing isn’t optional—it’s the “airbag” of production-level applications: You don’t need it on a daily basis, but it can save your life when it really counts.
1. What You'll Learn
- Setting up a Next.js 16 testing environment using Vitest (vitest.config.ts + React 17M compatibility layer)
- Render and assert Server and Client components using @testing-library/react
- Writing integration tests for Server Actions (Mocking Prisma + Simulating revalidatePath)
- Using jest-dom custom matchers (toBeInTheDocument / toHaveTextContent)
- Use MSW to intercept external API requests and isolate network dependencies
2. The True Story of a Full-Stack Engineer
(1) Pain Point: On the eve of launch, a single space caused the payment page to crash
Alice works as a full-stack engineer at an e-commerce platform serving the Middle Eastern market. The platform processes more than 50,000 orders daily, and the team maintains a release cycle of twice a week.
During last Friday’s launch, a seemingly harmless change—adding an extra space to the price formatting function in the OrderSummary component—caused payment amounts for Saudi users to display an extra decimal place. Although the data itself was not actually incorrect, customer service received over 200 complaints, and three users canceled their orders as a result.
To make matters worse:
- The team has not performed any tests on this component.
- The manual test was approved after just two clicks in Chrome
- This bug wasn't caught during the code review either
Alice was determined: She had to establish a testing system to prevent this kind of problem from happening again.
(2) Vitest + Testing Library Solution
Alice introduced the Vitest testing stack into the project:
npm install -D vitest @testing-library/react @testing-library/jest-dom @vitejs/plugin-react msw
Then I created the first test:
import { render, screen } from '@testing-library/react'
import { OrderSummary } from './OrderSummary'
it('displays formatted price correctly', () => {
render(<OrderSummary total={99.99} currency="SAR" />)
expect(screen.getByText(/99\.99/)).toBeInTheDocument()
})
(3) Revenue
| Dimension | Before | After |
|---|---|---|
| Code Coverage | < 5% | > 75% |
| Pre-release regression testing | None | Runs automatically in 5 minutes |
| Production Bug Rate | 8–12 per month | 0–2 per month |
| Confidence in Delivering New Features | Low | High |
3. Setting Up the Vitest Testing Environment
Vitest is the native testing framework for the Vite ecosystem and is natively compatible with Next.js 16 (which uses Turbopack and Vite under the hood).
graph TB
A[vitest.config.ts] --> B[React Plugin<br/>@vitejs/plugin-react]
A --> C[Test Globals<br/>globals:true]
A --> D[Environment<br/>jsdom]
A --> E[Setup Files<br/>setup-test.ts]
E --> F[jest-dom Matcher]
E --> G[MSW Start]
style A fill:#cce5ff
style F fill:#d4edda
style G fill:#d4edda
| Configuration File | Purpose | Key Options |
|---|---|---|
vitest.config.ts |
Test Main Configuration | environment: 'jsdom' Simulated Browser |
setup-test.ts |
Global Initialization | Import jest-dom, start the MSW service |
tsconfig.json |
Type Support | types: ['vitest/globals'] |
(1) Configure 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: './src/__tests__/setup-test.ts',
include: ['src/**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
thresholds: {
statements: 70,
branches: 60,
functions: 70,
lines: 70
}
}
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
})
(2) Global Setup File
// src/__tests__/setup-test.ts
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
afterEach(() => {
cleanup()
})
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
refresh: vi.fn(),
back: vi.fn(),
forward: vi.fn()
}),
usePathname: () => '/',
useSearchParams: () => new URLSearchParams()
}))
vi.mock('react-dom', async () => {
const actual = await vi.importActual('react-dom')
return { ...actual, useFormState: vi.fn() }
})
▶ Example: Verifying that Vitest is running
// src/__tests__/basic.test.ts
import { render, screen } from '@testing-library/react'
function Hello({ name }: { name: string }) {
return <h1>Hello, {name}!</h1>
}
it('renders hello message', () => {
render(<Hello name="Alice" />)
expect(screen.getByText('Hello, Alice!')).toBeInTheDocument()
})
npx vitest run
✓ src/__tests__/basic.test.ts (1 test) 12ms
Test Files 1 passed (1)
Tests 1 passed (1)
▶ Example: Adding a test script to package.json
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}
4. @testing-library/react Component Testing
The core philosophy of Testing Library: Test what users see and interact with, rather than implementation details.
(1) Queries for "render" and "screen"
graph LR
A[render Components] --> B[screen Search]
B --> C{Query Type}
C --> D[getByText Text]
C --> E[getByRole Semantics]
C --> F[getByTestId Test ID]
C --> G[getByPlaceholderText Placeholder]
D --> H[Assertion expect]
E --> H
F --> H
G --> H
| Query Method | Applicable Scenarios | Example |
|---|---|---|
getByText |
Text content | getByText('Submit Order') |
getByRole |
Semantic Elements | getByRole('button', { name: /Submit/i }) |
getByPlaceholderText |
Input Field Prompt | getByPlaceholderText('Enter email') |
getByTestId |
Non-semantic element | getByTestId('order-total') |
queryByText |
No assertions | expect(queryByText('Error')).not.toBeInTheDocument() |
(2) Testing Server Components (Synchronous Rendering)
Server Components do not require client-side JavaScript; you can directly assert the HTML output during testing:
// src/app/products/page.tsx
async function ProductsPage() {
const res = await fetch('https://api.example.com/products')
const products = await res.json()
return (
<ul>
{products.map((p: { id: number; name: string; price: number }) => (
<li key={p.id} data-testid="product-item">
{p.name} — ${p.price}
</li>
))}
</ul>
)
}
export default ProductsPage
// src/__tests__/products-page.test.tsx
import { render, screen } from '@testing-library/react'
import ProductsPage from '@/app/products/page'
// Mock global fetch
global.fetch = vi.fn().mockResolvedValue({
json: () => Promise.resolve([
{ id: 1, name: 'iPhone 16', price: 999 },
{ id: 2, name: 'Samsung S26', price: 899 }
])
})
it('renders product list from server', async () => {
const page = await ProductsPage()
render(page)
expect(screen.getByText('iPhone 16')).toBeInTheDocument()
expect(screen.getByText('Samsung S26')).toBeInTheDocument()
expect(screen.getAllByTestId('product-item')).toHaveLength(2)
})
▶ Example: Testing Client Components (Including User Interactions)
// src/components/Counter.tsx
'use client'
import { useState } from 'react'
export function Counter({ initial = 0 }) {
const [count, setCount] = useState(initial)
return (
<div>
<p data-testid="count">Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
<button onClick={() => setCount(c => c - 1)}>Decrement</button>
</div>
)
}
// src/__tests__/counter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { Counter } from '@/components/Counter'
describe('Counter', () => {
it('renders with initial value', () => {
render(<Counter initial={5} />)
expect(screen.getByTestId('count')).toHaveTextContent('Count: 5')
})
it('increments on button click', () => {
render(<Counter initial={0} />)
fireEvent.click(screen.getByText('Increment'))
expect(screen.getByTestId('count')).toHaveTextContent('Count: 1')
})
it('decrements on button click', () => {
render(<Counter initial={10} />)
fireEvent.click(screen.getByText('Decrement'))
expect(screen.getByTestId('count')).toHaveTextContent('Count: 9')
})
})
5. Custom Matchers in jest-dom
jest-dom provides semantic DOM assertion matchers, making test code more natural-language-like.
| Matcher | Function | Example |
|---|---|---|
toBeInTheDocument() |
Elements in the DOM | expect(el).toBeInTheDocument() |
toHaveTextContent(text) |
Text Content Match | expect(el).toHaveTextContent('Hello') |
toBeVisible() |
Element Visible | expect(el).toBeVisible() |
toBeDisabled() |
Button disabled | expect(btn).toBeDisabled() |
toHaveClass(cls) |
CSS class matching | expect(el).toHaveClass('active') |
toHaveAttribute(attr) |
Attribute Match | expect(input).toHaveAttribute('type', 'email') |
toHaveValue(val) |
Form Value Matching | expect(input).toHaveValue('test@example.com') |
▶ Example: Form Validation Test
import { render, screen, fireEvent } from '@testing-library/react'
import { LoginForm } from '@/components/LoginForm'
describe('LoginForm validation', () => {
it('shows error on empty email', () => {
render(<LoginForm />)
fireEvent.click(screen.getByRole('button', { name: /Log In/i }))
expect(screen.getByText(/Please enter your email address/i)).toBeInTheDocument()
expect(screen.getByText(/Please enter your email address/i)).toBeVisible()
})
it('disables submit while loading', () => {
render(<LoginForm />)
fireEvent.change(screen.getByPlaceholderText('Enter your email address'), {
target: { value: 'alice@example.com' }
})
fireEvent.click(screen.getByRole('button', { name: /Log In/i }))
expect(screen.getByRole('button', { name: /Logging in.../i })).toBeDisabled()
})
it('clears error after valid input', () => {
render(<LoginForm />)
fireEvent.click(screen.getByRole('button', { name: /Log In/i }))
expect(screen.getByText(/Please enter your email address/i)).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('Enter your email address'), {
target: { value: 'alice@example.com' }
})
expect(screen.queryByText(/Please enter your email address/i)).not.toBeInTheDocument()
})
})
6. Testing Server Actions (Mock Prisma + revalidatePath)
Server Actions need to simulate database operations and cache invalidation functions.
graph TB
A[Test Server Action] --> B[Mock Prisma Client]
A --> C[Mock revalidatePath]
A --> D[Mock redirect]
B --> E[Return Simulated Data]
C --> F[Verify the number of times it was called/Parameters]
D --> G[Verify the Redirect Path]
style A fill:#cce5ff
style B fill:#d4edda
style C fill:#fff3cd
(1) Mock Prisma Tool
// src/__tests__/utils/mock-prisma.ts
import { vi } from 'vitest'
export function createMockPrisma() {
return {
task: {
findMany: vi.fn().mockResolvedValue([
{ id: '1', title: 'Test Task', status: 'TODO', projectId: 'p1' }
]),
create: vi.fn().mockImplementation(({ data }) => Promise.resolve({
id: 'new-id',
...data,
createdAt: new Date()
})),
update: vi.fn().mockImplementation(({ data }) => Promise.resolve(data)),
delete: vi.fn().mockResolvedValue({ id: 'deleted-id' })
},
project: {
findUnique: vi.fn().mockResolvedValue({
id: 'p1',
name: 'Test Project',
tasks: []
})
},
$transaction: vi.fn().mockImplementation((cb) => cb(createMockPrisma()))
} as any
}
(2) Example of Testing a Server Action
// src/actions/task.ts
'use server'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
const taskSchema = z.object({
title: z.string().min(1, 'The title cannot be left blank.'),
projectId: z.string().min(1),
status: z.enum(['TODO', 'IN_PROGRESS', 'DONE'])
})
export async function createTask(formData: FormData) {
const data = Object.fromEntries(formData)
const parsed = taskSchema.safeParse(data)
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors }
}
await prisma.task.create({ data: parsed.data })
revalidatePath(`/projects/${parsed.data.projectId}`)
redirect(`/projects/${parsed.data.projectId}`)
}
▶ Example: Complete Server Action Test
// src/__tests__/actions/task.test.ts
import { createTask } from '@/actions/task'
import { createMockPrisma } from '../utils/mock-prisma'
import { vi, describe, it, expect, beforeEach } from 'vitest'
vi.mock('@/lib/prisma', () => ({ prisma: createMockPrisma() }))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('next/navigation', () => ({ redirect: vi.fn() }))
describe('createTask', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('creates a task successfully', async () => {
const formData = new FormData()
formData.append('title', 'Writing Test Documentation')
formData.append('projectId', 'p1')
formData.append('status', 'TODO')
const result = await createTask(formData)
expect(result).toBeUndefined()
})
it('returns validation error for empty title', async () => {
const formData = new FormData()
formData.append('title', '')
formData.append('projectId', 'p1')
formData.append('status', 'TODO')
const result = await createTask(formData)
expect(result).toHaveProperty('error')
expect(result.error.title).toBeDefined()
})
it('calls revalidatePath after creation', async () => {
const formData = new FormData()
formData.append('title', 'New Task')
formData.append('projectId', 'p1')
formData.append('status', 'TODO')
await createTask(formData)
const { revalidatePath } = await import('next/cache')
expect(revalidatePath).toHaveBeenCalledWith('/projects/p1')
})
})
7. MSW Mock Server-Side API
MSW (Mock Service Worker) intercepts network requests, allowing you to test data retrieval logic without having to start the actual backend.
graph LR
A[Component Initiation fetch] --> B[MSW Service Worker]
B --> C{Request Match}
C -->|Match Handler| D[Back mock Data]
C -->|Mismatch| E[Pass-through to the actual network]
style B fill:#cce5ff
style D fill:#d4edda
| Concept | Description | Example |
|---|---|---|
| Handler | Request handler | http.get('/api/products', resolver) |
| Resolver | Returns a mock response | return HttpResponse.json([...]) |
| Server | Node.js mock server | setupServer(...handlers) |
| Browser | MSW on the browser | setupWorker(...handlers) |
(1) Defining Mock Handlers
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
const API_BASE = 'https://api.example.com'
export const handlers = [
// GET /api/products
http.get(`${API_BASE}/products`, () => {
return HttpResponse.json([
{ id: 1, name: 'iPhone 16', price: 999, category: 'Electronics' },
{ id: 2, name: 'Samsung S26', price: 899, category: 'Electronics' }
])
}),
// POST /api/orders
http.post(`${API_BASE}/orders`, async ({ request }) => {
const body = await request.json()
return HttpResponse.json({
id: 'order-123',
...body as any,
status: 'confirmed',
createdAt: new Date().toISOString()
})
}),
// GET /api/users/:id
http.get(`${API_BASE}/users/:id`, ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Alice Wang',
email: 'alice@example.com',
role: 'admin'
})
}),
// 500 error for testing error boundaries
http.get(`${API_BASE}/errors/internal`, () => {
return HttpResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
)
})
]
(2) Integration into the Test Setup
// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
// src/__tests__/setup-test.ts(Full Version)
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, afterAll, beforeAll, vi } from 'vitest'
import { server } from '@/mocks/server'
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }))
afterEach(() => { cleanup(); server.resetHandlers() })
afterAll(() => server.close())
// Mock next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(), replace: vi.fn(),
refresh: vi.fn(), back: vi.fn(), forward: vi.fn()
}),
usePathname: () => '/',
useSearchParams: () => new URLSearchParams()
}))
▶ Example: Testing the MSW Interception API
// src/__tests__/api-integration.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { server } from '@/mocks/server'
import ProductsPage from '@/app/products/page'
describe('ProductsPage with MSW', () => {
it('renders products from mocked API', async () => {
const page = await ProductsPage()
render(page)
expect(screen.getByText('iPhone 16')).toBeInTheDocument()
expect(screen.getByText('Samsung S26')).toBeInTheDocument()
})
it('handles empty product list', async () => {
server.use(
http.get('https://api.example.com/products', () => {
return HttpResponse.json([])
})
)
const page = await ProductsPage()
render(page)
await waitFor(() => {
expect(screen.queryByTestId('product-item')).not.toBeInTheDocument()
})
})
it('shows error on API failure', async () => {
server.use(
http.get('https://api.example.com/products', () => {
return HttpResponse.json(
{ error: 'Service unavailable' },
{ status: 503 }
)
})
)
await expect(ProductsPage()).rejects.toThrow()
})
})
8. Complete Example: Full-Stack Testing of a Todo App
// src/__tests__/todo-comprehensive.test.ts
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { server } from '@/mocks/server'
import { createMockPrisma } from './utils/mock-prisma'
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================
// Comprehensive Testing:Todo Application Components + Action + API
// ============================================
// --- 1. Mock Dependency ---
vi.mock('@/lib/prisma', () => ({ prisma: createMockPrisma() }))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('next/navigation', () => ({
redirect: vi.fn(),
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() })
}))
// --- 2. Todo Components ---
function TodoItem({ id, title, done, onToggle }: {
id: string; title: string; done: boolean
onToggle: (id: string) => void
}) {
return (
<div data-testid="todo-item">
<span style={{ textDecoration: done ? 'line-through' : 'none' }}>
{title}
</span>
<button onClick={() => onToggle(id)}>
{done ? 'Undo' : 'Done'}
</button>
</div>
)
}
function TodoList({ todos }: { todos: Array<{ id: string; title: string; done: boolean }> }) {
return (
<div>
{todos.map(t => (
<TodoItem key={t.id} {...t} onToggle={(id) => {
const idx = todos.findIndex(t => t.id === id)
todos[idx].done = !todos[idx].done
}} />
))}
</div>
)
}
// --- 3. Test Cases ---
describe('Todo App', () => {
const mockTodos = [
{ id: '1', title: 'Learn Next.js testing', done: false },
{ id: '2', title: 'Write unit tests', done: true },
{ id: '3', title: 'Set up CI pipeline', done: false }
]
it('renders all todos', () => {
render(<TodoList todos={mockTodos} />)
expect(screen.getAllByTestId('todo-item')).toHaveLength(3)
expect(screen.getByText('Learn Next.js testing')).toBeInTheDocument()
})
it('shows strikethrough for done items', () => {
render(<TodoList todos={mockTodos} />)
const doneItem = screen.getByText('Write unit tests')
expect(doneItem).toHaveStyle('text-decoration: line-through')
})
it('toggles todo on button click', () => {
render(<TodoList todos={mockTodos} />)
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[0])
expect(buttons[0]).toHaveTextContent('Undo')
})
it('has correct button labels', () => {
render(<TodoList todos={mockTodos} />)
const buttons = screen.getAllByRole('button')
expect(buttons[0]).toHaveTextContent('Done')
expect(buttons[1]).toHaveTextContent('Undo')
})
})
// --- 4. MSW API Test ---
describe('Todo API Mock', () => {
it('fetch todos from mocked API', async () => {
server.use(
http.get('https://api.example.com/todos', () => {
return HttpResponse.json(mockTodos)
})
)
const res = await fetch('https://api.example.com/todos')
const data = await res.json()
expect(data).toHaveLength(3)
expect(data[0].title).toBe('Learn Next.js testing')
})
it('handles API error gracefully', async () => {
server.use(
http.get('https://api.example.com/todos', () => {
return HttpResponse.json(null, { status: 500 })
})
)
const res = await fetch('https://api.example.com/todos')
expect(res.status).toBe(500)
})
})
✓ src/__tests__/todo-comprehensive.test.ts (7 tests) 45ms
Test Files 1 passed (1)
Tests 7 passed (7)
❓ FAQ
expect/describe/it). Next.js 16 uses Turbopack (part of the Vite ecosystem) under the hood, so Vitest is the more natural choice.cookies() or headers()?global.fetch is simple, but it cannot handle complex request matching, response serialization, or error scenarios.useActionState?useActionState depends on useFormState in React 19, so you need to mock useFormState in react-dom during testing. We recommend using fireEvent.submit to trigger the form submission, then asserting the UI state after submission (success/error messages).fireEvent and userEvent in the Testing Library?fireEvent directly triggers DOM events (such as click), while userEvent simulates more realistic user interactions (such as keyboard input and focus management). We recommend using userEvent for production testing (as it more closely mimics the user experience) and fireEvent for unit testing (as it is more lightweight).📖 Summary
- Vitest is the preferred testing framework for Next.js 16. When configuring
vitest.config.ts, you must specifyenvironment: 'jsdom'and the React plugin. @testing-library/reactprovides therenderandscreenAPIs, focusing on user-visible behavior rather than implementation details- The jest-dom matchers (
toBeInTheDocument,toHaveTextContent,toBeVisible) make test assertions more meaningful - Server Actions testing requires mocking the Prisma Client,
revalidatePath, andredirectto verify data writing and cache invalidation - MSW intercepts network requests at the Service Worker layer, making it suitable for isolating external APIs in integration testing
setup-test.tsis a key file in the testing infrastructure that centrally manages mocks and global initialization
📝 Exercises
-
Basic Exercise (⭐): Create a
vitest.config.tsthat includes the React plugin, the jsdom environment, and a path alias for@/, then write the simplestrender(Hello)test to verify that the Jest-DOM matcher is working properly. -
Advanced Exercise (⭐⭐): Write comprehensive tests for the Server Actions in your project (such as
createUserorsubmitOrder): mock Prisma’screatemethod, verify thatrevalidatePathis called, and test the error response when Zod validation fails. -
Challenge (⭐⭐⭐): Use MSW to build three mock handlers (GET /api/tasks, POST /api/tasks, DELETE /api/tasks/:id), and then write at least 8 test cases for the TaskList component, which includes data retrieval, creation, and deletion functionality (including initial load, empty list, and error handling).



