Server-side rendering (SSR) in Next.js can be tricky to test, especially when your pages depend on external APIs. In this article, we'll explore how to combine Playwright and Mock Service Worker (MSW) to create robust, reliable tests for your SSR pages.
The Challenge
When testing SSR pages that fetch data, you face several challenges:
- External API dependencies make tests flaky
- Network errors need to be simulated
- Different data scenarios require different responses
- Race conditions in parallel tests can cause inconsistent results
The Solution: Playwright + MSW
Playwright provides excellent end-to-end testing capabilities, while MSW intercepts network requests at the browser level, giving us complete control over API responses.
Setting Up the Testing Environment
1. Install Dependencies
npm install --save-dev @playwright/test msw
2. Configure MSW in Your Next.js App
First, create MSW handlers for your API endpoints:
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('https://jsonplaceholder.typicode.com/users', () => {
return HttpResponse.json([
{ id: 1, name: 'John Doe', username: 'johndoe', email: 'john@example.com' },
{ id: 2, name: 'Jane Smith', username: 'janesmith', email: 'jane@example.com' }
]);
})
];
3. Create Test Scenarios
Define different scenarios for your tests:
// src/mocks/scenarios.ts
import { http, HttpResponse } from 'msw';
export const scenarios = {
default: handlers,
apiError: [
http.get('https://jsonplaceholder.typicode.com/users', () => {
return new HttpResponse(null, { status: 500 });
})
],
emptyData: [
http.get('https://jsonplaceholder.typicode.com/users', () => {
return HttpResponse.json([]);
})
],
networkError: [
http.get('https://jsonplaceholder.typicode.com/users', () => {
return HttpResponse.error();
})
]
};
4. Set Up MSW API Route
Create a Next.js API route to control MSW during tests:
// src/app/api/msw/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { scenarios } from '@/mocks/scenarios';
let currentHandlers = scenarios.default;
export async function POST(request: NextRequest) {
const { action, scenario } = await request.json();
switch (action) {
case 'useScenario':
if (scenarios[scenario]) {
currentHandlers = scenarios[scenario];
return NextResponse.json({ message: `Scenario '${scenario}' applied` });
}
break;
case 'reset':
currentHandlers = scenarios.default;
return NextResponse.json({ message: 'Handlers reset to default' });
}
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
}
Building Robust SSR Pages
Your SSR pages should handle errors gracefully:
// src/app/users/page.tsx
import Link from 'next/link';
async function getUsers() {
const res = await fetch('https://jsonplaceholder.typicode.com/users', {
cache: 'no-store'
});
if (!res.ok) {
throw new Error('Failed to fetch users');
}
return res.json();
}
export default async function UsersPage() {
try {
const users = await getUsers();
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Users</h1>
<div className="grid gap-4">
{users.map((user) => (
<div key={user.id} className="border rounded-lg p-4">
<h2 className="text-xl font-semibold mb-2">
<Link href={`/users/${user.id}`}>
{user.name}
</Link>
</h2>
<p className="text-gray-600">@{user.username}</p>
<p className="text-gray-600">{user.email}</p>
</div>
))}
</div>
<Link href="/" className="text-blue-600 hover:text-blue-800">
← Back to Home
</Link>
</div>
);
} catch (error) {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Users</h1>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-red-800 font-semibold">Failed to fetch users</p>
<p className="text-red-600 text-sm mt-2">
{error instanceof Error ? error.message : 'An unknown error occurred'}
</p>
</div>
<Link href="/" className="text-blue-600 hover:text-blue-800">
← Back to Home
</Link>
</div>
);
}
}
Writing Playwright Tests
Test Setup
Create a test setup helper:
// tests/setup/test-setup.ts
import { test as baseTest } from '@playwright/test';
async function resetMSWHandlers() {
const response = await fetch('http://localhost:3001/api/msw', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'reset' })
});
// Small delay to ensure reset is processed
await new Promise(resolve => setTimeout(resolve, 100));
}
export const test = baseTest.extend({
page: async ({ page }, use) => {
await resetMSWHandlers(); // Reset before test
await use(page);
await resetMSWHandlers(); // Reset after test
},
});
export async function useMSWScenario(scenario: string) {
const response = await fetch('http://localhost:3001/api/msw', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'useScenario', scenario })
});
// Small delay to ensure scenario is applied
await new Promise(resolve => setTimeout(resolve, 100));
}
export { expect } from '@playwright/test';
Test Examples
// tests/ssr-parallel.spec.ts
import { test, expect, useMSWScenario } from './setup/test-setup';
test.describe('SSR with API Testing', () => {
test('should display users successfully', async ({ page }) => {
await page.goto('/users');
await expect(page.locator('h1')).toContainText('Users');
await expect(page.locator('text=John Doe')).toBeVisible();
await expect(page.locator('text=Jane Smith')).toBeVisible();
});
test('should handle API errors gracefully', async ({ page }) => {
await useMSWScenario('apiError');
await page.goto('/users');
// Verify error is displayed properly
await expect(page.locator('h1')).toContainText('Users');
await expect(page.locator('p.text-red-800:has-text("Failed to fetch users")')).toBeVisible();
await expect(page.locator('.bg-red-50')).toBeVisible();
await expect(page.locator('text=← Back to Home')).toBeVisible();
});
test('should handle empty data', async ({ page }) => {
await useMSWScenario('emptyData');
await page.goto('/users');
await expect(page.locator('h1')).toContainText('Users');
await expect(page.locator('text=John Doe')).not.toBeVisible();
});
test('should handle network errors', async ({ page }) => {
await useMSWScenario('networkError');
await page.goto('/users');
await expect(page.locator('text=Failed to fetch users')).toBeVisible();
});
});
Playwright Configuration
Configure Playwright to avoid race conditions:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: false, // Prevent MSW race conditions
workers: 1, // Single worker for MSW stability
retries: 1,
use: {
baseURL: 'http://localhost:3001',
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:3001',
reuseExistingServer: !process.env.CI,
env: {
NEXT_PUBLIC_API_MOCKING: 'enabled',
},
},
});
Key Benefits
✅ Reliable Tests: No external API dependencies
✅ Error Scenarios: Easy to test network failures and API errors
✅ Fast Execution: No real network calls
✅ Deterministic: Same results every time
✅ Complete Control: Mock any API response scenario
Best Practices
- Reset MSW handlers before and after each test
- Add small delays after MSW operations to ensure they're processed
- Use sequential execution to avoid race conditions with shared MSW state
- Test error scenarios - they're as important as happy paths
- Keep handlers simple and focused on specific test scenarios
Conclusion
Combining Playwright with MSW provides a powerful testing setup for Next.js SSR applications. This approach gives you the confidence that your pages handle both successful API responses and various error conditions gracefully.
The key is proper isolation between tests and careful management of MSW state to avoid flaky tests. With this setup, you can build comprehensive test suites that cover all the edge cases your users might encounter.
Top comments (0)