Next.js Testing Strategy: Unit, Integration, and E2E with Playwright and MSW
Build a comprehensive Next.js testing strategy: unit tests with Vitest, integration tests with MSW for API mocking, and E2E tests with Playwright — including App Router patterns.
Most Next.js testing guides cover unit tests in isolation. The real gap is the integration layer — testing Server Actions, API routes, middleware, and database queries together without spinning up a full browser. This post gives you a complete testing strategy: what to test at each layer, how to configure the tooling, and where the App Router forces you to think differently.
The Testing Pyramid for Next.js
┌──────────┐
│ E2E │ Playwright — 10–20 critical user journeys
│ (Slow) │
─┼──────────┼─
╱ ╲
╱ Integration ╲ MSW + supertest — API routes, Server Actions, DB
╱ (Medium) ╲
─┼──────────────────┼─
╱ ╲
╱ Unit (Fast) ╲ Vitest — pure functions, hooks, components
└─────────────────────────┘
Rule of thumb: 70% unit, 20% integration, 10% E2E. The expensive tests catch regressions; the cheap tests drive design.
Setup: Project Configuration
# Install test toolchain
pnpm add -D vitest @vitejs/plugin-react @testing-library/react \
@testing-library/user-event @testing-library/jest-dom \
msw happy-dom @playwright/test \
supertest @types/supertest
vitest.config.ts
// 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: 'happy-dom', // faster than jsdom, good enough for React
globals: true,
setupFiles: ['./tests/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
exclude: [
'node_modules/**',
'**/*.config.*',
'**/generated/**',
'tests/**',
],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
},
},
include: ['src/**/*.{test,spec}.{ts,tsx}'],
exclude: ['tests/e2e/**'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
tests/setup.ts
// tests/setup.ts
import '@testing-library/jest-dom';
import { server } from './mocks/server';
import { afterAll, afterEach, beforeAll, vi } from 'vitest';
// Mock Next.js navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
}),
useSearchParams: () => new URLSearchParams(),
usePathname: () => '/test',
redirect: vi.fn(),
}));
// Mock Next.js headers/cookies (Server Components)
vi.mock('next/headers', () => ({
cookies: () => ({
get: vi.fn(),
set: vi.fn(),
delete: vi.fn(),
}),
headers: () => new Headers(),
}));
// Start MSW
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
🌐 Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript — production-grade stack
- Fixed-price contracts — no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
1. Unit Tests with Vitest
Testing Pure Functions
// src/lib/utils/pricing.ts
export function calculateProration(
currentPlan: { amount: number; periodEnd: Date },
newPlan: { amount: number },
upgradeDate: Date = new Date()
): number {
const remainingDays = Math.max(
0,
Math.floor((currentPlan.periodEnd.getTime() - upgradeDate.getTime()) / 86_400_000)
);
const totalDays = 30;
const unused = (currentPlan.amount / totalDays) * remainingDays;
const newCharge = (newPlan.amount / totalDays) * remainingDays;
return Math.round(newCharge - unused);
}
// src/lib/utils/pricing.test.ts
import { describe, it, expect } from 'vitest';
import { calculateProration } from './pricing';
describe('calculateProration', () => {
it('charges full new plan amount when upgrading at period start', () => {
const periodEnd = new Date('2026-11-30');
const upgradeDate = new Date('2026-11-01');
const result = calculateProration(
{ amount: 2900, periodEnd },
{ amount: 9900 },
upgradeDate
);
// 29 remaining days: ($99 - $29) * (29/30) = $67.67 → 6767 cents
expect(result).toBe(6767);
});
it('returns 0 when upgrading on period end date', () => {
const periodEnd = new Date('2026-11-30');
const result = calculateProration(
{ amount: 2900, periodEnd },
{ amount: 9900 },
periodEnd
);
expect(result).toBe(0);
});
it('returns 0 when new plan is cheaper (downgrade scenario)', () => {
const periodEnd = new Date('2026-11-30');
const upgradeDate = new Date('2026-11-15');
const result = calculateProration(
{ amount: 9900, periodEnd },
{ amount: 2900 },
upgradeDate
);
// Negative proration = credit, not charge
expect(result).toBeLessThan(0);
});
});
Testing React Components
// src/components/pricing/PlanCard.tsx
'use client';
interface Plan {
id: string;
name: string;
amount: number;
features: string[];
isCurrent: boolean;
}
interface PlanCardProps {
plan: Plan;
onUpgrade: (planId: string) => Promise<void>;
}
export function PlanCard({ plan, onUpgrade }: PlanCardProps) {
const [isLoading, setIsLoading] = React.useState(false);
const handleClick = async () => {
setIsLoading(true);
try {
await onUpgrade(plan.id);
} finally {
setIsLoading(false);
}
};
return (
<div data-testid={`plan-card-${plan.id}`}>
<h3>{plan.name}</h3>
<p>${plan.amount / 100}/mo</p>
<ul>
{plan.features.map((f) => <li key={f}>{f}</li>)}
</ul>
<button
onClick={handleClick}
disabled={plan.isCurrent || isLoading}
aria-busy={isLoading}
>
{plan.isCurrent ? 'Current Plan' : isLoading ? 'Processing...' : 'Upgrade'}
</button>
</div>
);
}
// src/components/pricing/PlanCard.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { PlanCard } from './PlanCard';
const mockPlan = {
id: 'pro',
name: 'Pro',
amount: 9900,
features: ['Unlimited projects', 'Priority support'],
isCurrent: false,
};
describe('PlanCard', () => {
it('renders plan details correctly', () => {
render(<PlanCard plan={mockPlan} onUpgrade={vi.fn()} />);
expect(screen.getByText('Pro')).toBeInTheDocument();
expect(screen.getByText('$99/mo')).toBeInTheDocument();
expect(screen.getByText('Unlimited projects')).toBeInTheDocument();
});
it('calls onUpgrade with plan id when upgrade button clicked', async () => {
const user = userEvent.setup();
const onUpgrade = vi.fn().mockResolvedValue(undefined);
render(<PlanCard plan={mockPlan} onUpgrade={onUpgrade} />);
await user.click(screen.getByRole('button', { name: /upgrade/i }));
expect(onUpgrade).toHaveBeenCalledWith('pro');
});
it('shows loading state during upgrade', async () => {
const user = userEvent.setup();
let resolveUpgrade!: () => void;
const onUpgrade = vi.fn(
() => new Promise<void>((resolve) => { resolveUpgrade = resolve; })
);
render(<PlanCard plan={mockPlan} onUpgrade={onUpgrade} />);
await user.click(screen.getByRole('button', { name: /upgrade/i }));
expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true');
expect(screen.getByRole('button')).toHaveTextContent('Processing...');
resolveUpgrade();
await waitFor(() => expect(screen.getByRole('button')).not.toHaveAttribute('aria-busy', 'true'));
});
it('disables button when plan is current', () => {
render(
<PlanCard plan={{ ...mockPlan, isCurrent: true }} onUpgrade={vi.fn()} />
);
expect(screen.getByRole('button')).toBeDisabled();
expect(screen.getByRole('button')).toHaveTextContent('Current Plan');
});
});
2. MSW for API Mocking
Mock Server Setup
// tests/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
// Mock Stripe API
http.get('https://api.stripe.com/v1/customers/:customerId', ({ params }) => {
return HttpResponse.json({
id: params.customerId,
email: 'test@example.com',
subscriptions: { data: [] },
});
}),
// Mock your own API routes (for client-side fetch tests)
http.get('/api/accounts/current', () => {
return HttpResponse.json({
id: 'acc-test-123',
name: 'Test Company',
plan: 'pro',
seats: 5,
});
}),
http.post('/api/billing/upgrade', async ({ request }) => {
const body = await request.json() as { planId: string };
if (body.planId === 'invalid') {
return HttpResponse.json(
{ error: 'Invalid plan', code: 'PLAN_NOT_FOUND' },
{ status: 404 }
);
}
return HttpResponse.json({
success: true,
newPlan: body.planId,
effectiveDate: new Date().toISOString(),
});
}),
];
// tests/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
Integration Testing API Routes
// src/app/api/billing/upgrade/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
export async function POST(req: NextRequest) {
const session = await getServerSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { planId } = await req.json() as { planId: string };
const account = await db.account.findFirst({
where: { userId: session.user.id },
include: { subscription: true },
});
if (!account?.subscription?.stripeSubscriptionId) {
return NextResponse.json({ error: 'No active subscription' }, { status: 400 });
}
const updatedSub = await stripe.subscriptions.update(
account.subscription.stripeSubscriptionId,
{ items: [{ id: account.subscription.stripeItemId, price: planId }] }
);
await db.subscription.update({
where: { id: account.subscription.id },
data: { stripePriceId: planId, updatedAt: new Date() },
});
return NextResponse.json({ success: true, subscription: updatedSub.id });
}
// src/app/api/billing/upgrade/route.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
import { POST } from './route';
import { server } from '@/../tests/mocks/server';
import { http, HttpResponse } from 'msw';
// Mock auth
vi.mock('next-auth', () => ({
getServerSession: vi.fn(),
}));
// Mock Stripe
vi.mock('@/lib/stripe', () => ({
stripe: {
subscriptions: {
update: vi.fn().mockResolvedValue({ id: 'sub_test_updated' }),
},
},
}));
// Mock DB
vi.mock('@/lib/db', () => ({
db: {
account: {
findFirst: vi.fn(),
},
subscription: {
update: vi.fn(),
},
},
}));
import { getServerSession } from 'next-auth';
import { db } from '@/lib/db';
import { stripe } from '@/lib/stripe';
const mockSession = { user: { id: 'user-123', email: 'test@example.com' } };
const mockAccount = {
id: 'acc-123',
userId: 'user-123',
subscription: {
id: 'dbsub-123',
stripeSubscriptionId: 'sub_abc',
stripeItemId: 'si_abc',
stripePriceId: 'price_starter',
},
};
describe('POST /api/billing/upgrade', () => {
beforeEach(() => {
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(db.account.findFirst).mockResolvedValue(mockAccount as any);
vi.mocked(db.subscription.update).mockResolvedValue(mockAccount.subscription as any);
});
it('upgrades subscription successfully', async () => {
const req = new NextRequest('http://localhost/api/billing/upgrade', {
method: 'POST',
body: JSON.stringify({ planId: 'price_pro' }),
headers: { 'Content-Type': 'application/json' },
});
const response = await POST(req);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
expect(stripe.subscriptions.update).toHaveBeenCalledWith('sub_abc', {
items: [{ id: 'si_abc', price: 'price_pro' }],
});
});
it('returns 401 when unauthenticated', async () => {
vi.mocked(getServerSession).mockResolvedValue(null);
const req = new NextRequest('http://localhost/api/billing/upgrade', {
method: 'POST',
body: JSON.stringify({ planId: 'price_pro' }),
});
const response = await POST(req);
expect(response.status).toBe(401);
});
it('returns 400 when no active subscription', async () => {
vi.mocked(db.account.findFirst).mockResolvedValue({ ...mockAccount, subscription: null } as any);
const req = new NextRequest('http://localhost/api/billing/upgrade', {
method: 'POST',
body: JSON.stringify({ planId: 'price_pro' }),
});
const response = await POST(req);
expect(response.status).toBe(400);
});
});
🚀 Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4–8 weeks, full platforms in 3–5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
3. E2E Testing with Playwright
playwright.config.ts
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? 'github' : 'list',
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
// Setup: seed test database
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
dependencies: ['setup'],
},
{
name: 'mobile-safari',
use: { ...devices['iPhone 14'] },
dependencies: ['setup'],
},
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
});
Database Seeding for E2E
// tests/e2e/global.setup.ts
import { test as setup } from '@playwright/test';
import { execSync } from 'child_process';
setup('seed test database', async () => {
// Reset and seed test DB
execSync('DATABASE_URL=$TEST_DATABASE_URL npx prisma db push --force-reset', {
stdio: 'inherit',
});
execSync('DATABASE_URL=$TEST_DATABASE_URL npx tsx tests/e2e/seed.ts', {
stdio: 'inherit',
});
});
// tests/e2e/seed.ts
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const db = new PrismaClient({ datasourceUrl: process.env.TEST_DATABASE_URL });
async function seed() {
const hash = await bcrypt.hash('TestPassword123!', 10);
await db.user.upsert({
where: { email: 'e2e-test@viprasol.com' },
update: {},
create: {
email: 'e2e-test@viprasol.com',
passwordHash: hash,
name: 'E2E Test User',
account: {
create: {
name: 'E2E Test Company',
plan: 'pro',
subscription: {
create: {
status: 'active',
stripeSubscriptionId: 'sub_e2e_test',
stripePriceId: 'price_pro',
stripeItemId: 'si_e2e_test',
currentPeriodEnd: new Date('2027-01-01'),
},
},
},
},
},
});
console.log('✅ E2E seed complete');
}
seed().finally(() => db.$disconnect());
Page Object Model
// tests/e2e/pages/billing.page.ts
import { Page, Locator, expect } from '@playwright/test';
export class BillingPage {
readonly page: Page;
readonly upgradeButtons: Locator;
readonly currentPlanBadge: Locator;
readonly successToast: Locator;
constructor(page: Page) {
this.page = page;
this.upgradeButtons = page.getByRole('button', { name: /upgrade/i });
this.currentPlanBadge = page.getByText('Current Plan');
this.successToast = page.getByText(/plan updated successfully/i);
}
async goto() {
await this.page.goto('/settings/billing');
await this.page.waitForLoadState('networkidle');
}
async upgradeToPlan(planName: string) {
const planCard = this.page.getByTestId(`plan-card-${planName.toLowerCase()}`);
await planCard.getByRole('button', { name: /upgrade/i }).click();
await this.successToast.waitFor({ timeout: 10_000 });
}
}
E2E Test: Billing Upgrade Flow
// tests/e2e/billing.spec.ts
import { test, expect } from '@playwright/test';
import { BillingPage } from './pages/billing.page';
test.describe('Billing upgrade flow', () => {
test.beforeEach(async ({ page }) => {
// Log in via cookie (faster than form login)
await page.goto('/');
await page.evaluate(() => {
document.cookie = 'next-auth.session-token=e2e-session; path=/';
});
});
test('user can view current plan and available upgrades', async ({ page }) => {
const billing = new BillingPage(page);
await billing.goto();
await expect(billing.currentPlanBadge).toBeVisible();
await expect(billing.upgradeButtons.first()).toBeVisible();
});
test('user can upgrade from Starter to Pro', async ({ page }) => {
const billing = new BillingPage(page);
await billing.goto();
await billing.upgradeToPlan('enterprise');
await expect(billing.successToast).toBeVisible();
// Reload and verify new plan is shown
await page.reload();
await expect(page.getByTestId('plan-card-enterprise').getByText('Current Plan')).toBeVisible();
});
test('upgrade button is disabled for current plan', async ({ page }) => {
const billing = new BillingPage(page);
await billing.goto();
// Pro is the current plan (seeded)
const proCard = page.getByTestId('plan-card-pro');
await expect(proCard.getByRole('button')).toBeDisabled();
});
});
Testing Server Actions
// src/app/actions/billing.actions.ts
'use server';
import { auth } from '@/lib/auth';
import { stripe } from '@/lib/stripe';
import { db } from '@/lib/db';
import { revalidatePath } from 'next/cache';
export async function upgradePlanAction(planId: string): Promise<{
success: boolean;
error?: string;
}> {
const session = await auth();
if (!session?.user) return { success: false, error: 'Unauthorized' };
try {
const account = await db.account.findFirst({
where: { userId: session.user.id },
include: { subscription: true },
});
if (!account?.subscription) {
return { success: false, error: 'No active subscription found' };
}
await stripe.subscriptions.update(account.subscription.stripeSubscriptionId!, {
items: [{ id: account.subscription.stripeItemId!, price: planId }],
});
await db.subscription.update({
where: { accountId: account.id },
data: { stripePriceId: planId },
});
revalidatePath('/settings/billing');
return { success: true };
} catch (err: any) {
return { success: false, error: err.message };
}
}
// src/app/actions/billing.actions.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { upgradePlanAction } from './billing.actions';
vi.mock('@/lib/auth', () => ({ auth: vi.fn() }));
vi.mock('@/lib/stripe', () => ({
stripe: { subscriptions: { update: vi.fn().mockResolvedValue({ id: 'sub_updated' }) } },
}));
vi.mock('@/lib/db', () => ({
db: {
account: { findFirst: vi.fn() },
subscription: { update: vi.fn() },
},
}));
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }));
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
const mockAccount = {
id: 'acc-1',
userId: 'user-1',
subscription: {
stripeSubscriptionId: 'sub_abc',
stripeItemId: 'si_abc',
stripePriceId: 'price_starter',
},
};
describe('upgradePlanAction', () => {
beforeEach(() => {
vi.mocked(auth).mockResolvedValue({ user: { id: 'user-1' } } as any);
vi.mocked(db.account.findFirst).mockResolvedValue(mockAccount as any);
vi.mocked(db.subscription.update).mockResolvedValue({} as any);
});
it('upgrades plan and returns success', async () => {
const result = await upgradePlanAction('price_pro');
expect(result).toEqual({ success: true });
});
it('returns error when unauthenticated', async () => {
vi.mocked(auth).mockResolvedValue(null);
const result = await upgradePlanAction('price_pro');
expect(result).toEqual({ success: false, error: 'Unauthorized' });
});
it('returns error when no subscription', async () => {
vi.mocked(db.account.findFirst).mockResolvedValue({ ...mockAccount, subscription: null } as any);
const result = await upgradePlanAction('price_pro');
expect(result).toEqual({ success: false, error: 'No active subscription found' });
});
});
CI/CD Pipeline
# .github/workflows/test.yml
name: Test
on:
pull_request:
push:
branches: [main]
jobs:
unit-integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with: { node-version: '22', cache: 'pnpm' }
- run: pnpm install --frozen-lockfile
- name: Run DB migrations
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb
run: pnpm prisma migrate deploy
- name: Unit + integration tests
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb
NEXTAUTH_SECRET: test-secret-min-32-chars-long-xxxx
run: pnpm vitest run --coverage
- uses: codecov/codecov-action@v4
with: { files: ./coverage/lcov.info }
e2e:
runs-on: ubuntu-latest
needs: unit-integration
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with: { node-version: '22', cache: 'pnpm' }
- run: pnpm install --frozen-lockfile
- run: pnpm playwright install --with-deps chromium
- name: Run E2E tests
env:
PLAYWRIGHT_BASE_URL: http://localhost:3000
DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
run: pnpm playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
Cost Reference: Testing Investment
| Team Size | Testing Setup | Monthly CI Cost | ROI |
|---|---|---|---|
| Solo dev | Unit + basic E2E | ~$10 (GH Actions) | Catch regressions before users do |
| 3–5 devs | Full pyramid (unit + integration + E2E) | $30–80/mo | Save 2–3 hrs/sprint in manual QA |
| 10–20 devs | Parallel Playwright shards + visual regression | $150–400/mo | Prevents $10K+ incident costs |
| 50+ devs | Dedicated test infra (Playwright Cloud/BrowserStack) | $500–2K/mo | CI under 8 min at scale |
See Also
- React Hook Form + Zod: Type-Safe Forms and Multi-Step Wizards
- React Suspense Patterns for App Router
- TypeScript Testing Patterns: Mocks, Stubs, and Type-Safe Tests
- Contract Testing for Microservices
- Next.js App Router Patterns
Working With Viprasol
Shipping a Next.js product without a solid test foundation? We design testing strategies from scratch — choosing the right tools for your stack, setting coverage targets that match your risk tolerance, and setting up CI pipelines that keep feedback loops fast.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms — we build it all with Next.js and React.
Free consultation • No commitment • Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.