Join JS Mastery Pro to apply what you learned today through real-world builds, weekly challenges, and a community of developers working toward the same goal.
Testing is one of those things that many developers skip until something breaks. You can ship a stunning Next.js app, but if a small change introduces a bug, your users won’t care how polished your UI looks. That’s why testing matters.
When it comes to testing, there are three main levels you should know:
Most developers stop at unit and integration tests. But here’s the catch: neither of those fully guarantees the user experience. That’s where E2E tests are needed. They make sure your app works exactly as your users expect.
Now, a lot of developers think E2E testing is complicated. But honestly, with Playwright, it’s easier than you might expect. In fact, writing E2E tests with Playwright can feel simpler than setting up unit or integration tests.
So in this post, let’s go through the 5 best practices for writing solid E2E tests in your Next.js applications using Playwright.
It might be tempting to run your Playwright tests on next dev, especially when working locally. But that’s not a good idea. The development server includes hot reloading, debug logs, and other behavior that doesn’t exist in production, and all of that can lead to flaky or misleading tests.
Instead, always run your tests against the production build of your app. This way, you’re testing the exact environment your users will interact with.
# Build and start your app
npm run build
npm run startAnd inside your playwright.config.ts:
webServer: {
command: "npm run start",
url: process.env.BASE_URL,
timeout: 60 * 1000,
reuseExistingServer: !process.env.CI,
},With this setup, Playwright waits until your app is ready and ensures your tests run against the same optimized build you’ll deploy to production.
One of Playwright’s coolest features is mocking API responses. Instead of hitting real APIs, you can control exactly what your Next.js app receives making tests faster, predictable, and network-proof.
Why bother? Without mocks, your tests might break due to flaky network calls, rate limits, or inconsistent data. And that slows down your CI/CD pipeline.
With a fixture, you can set up API mocks once and reuse them across tests. For example, you could mock a /api/products route for a product listing page, ensuring every test sees the same data, no surprises, no waiting on slow APIs.
// Define types for the fixture
interface TestFixtures {
mockedProductPage: Page;
}
// Mock data for the API response
const mockProducts = [
{ id: 'prod1', name: 'Test Product', price: 99.99 },
{ id: 'prod2', name: 'Another Product', price: 49.99 },
];
// Extend the base test to include a custom fixture
const test = base.extend<TestFixtures>({
mockedProductPage: async ({ page }, use) => {
// Mock the Next.js API route
await page.route('/api/products', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockProducts),
}),
);
// Navigate to the Next.js product listing page (e.g., rendered via getServerSideProps)
await page.goto('/products', { waitUntil: 'networkidle' });
// Pass the page with mocked data to the test
await use(page);
},
});
test('displays mocked product data on listing page', async ({ mockedProductPage }) => {
// Verify server-side rendered content from mocked API
await expect(mockedProductPage.getByText('Test Product')).toBeVisible();
await expect(mockedProductPage.getByText('$99.99')).toBeVisible();
await expect(mockedProductPage.getByText('Another Product')).toBeVisible();
await expect(mockedProductPage.getByText('$49.99')).toBeVisible();
});
test('filters products using mocked data', async ({ mockedProductPage }) => {
// Interact with a filter input (e.g., client-side filtering)
await mockedProductPage.fill('input[name="search"]', 'Test Product');
await mockedProductPage.click('button[data-testid="filter"]');
// Verify only the mocked Test Product is visible
await expect(mockedProductPage.getByText('Test Product')).toBeVisible();
await expect(mockedProductPage.getByText('$99.99')).toBeVisible();
await expect(mockedProductPage.getByText('Another Product')).not.toBeVisible();
});Here’s what’s happening:
In larger Next.js projects, you can extend fixtures to mock multiple API routes, external services (e.g., Stripe or Auth0), or even error responses to test edge cases.
Pro tip: Combine API mocking fixtures with Playwright’s project dependencies to set up a mock server or shared mock data once for all tests. This optimizes test setup for complex applications with multiple APIs.
For more advanced mocking techniques with real-world project examples, explore The Complete Next.js Testing Course.
The Playwright VS Code extension is a game-changer for writing E2E tests. One of the hardest parts of test automation is writing locators, figuring out the right getByRole, getByText, or CSS selector that uniquely identifies an element. The extension makes this painless by automatically generating reliable locators for you.
Instead of manually inspecting the DOM or trial-and-error with selectors, you can:
Install Playwright Test for VS Code from the VS Code marketplace:

Instead of writing something brittle like:
await page.click('div > button.btn-primary:nth-child(2)');The extension will suggest a stable locator:
await page.getByRole('button', { name: 'Submit' }).click();Much cleaner, more readable, and less likely to break when your HTML changes.
Tools like this make testing accessible. If you're new to Next.js and want to integrate them from day one, The Ultimate Next.js Course walks you through setup and best practices.
One of the first challenges you’ll run into with E2E tests is authentication. What happens if a page or action requires a logged-in user?
A common mistake is logging in inside every test using a beforeEach hook. That works, but it’s slow and repetitive. With Playwright, there’s a much cleaner way: set up authentication once, save the logged-in state, and then reuse it across all your tests.
Here’s how you can do it.
Create a file called auth.setup.ts inside your tests folder:
test('authenticate and save storage state', async ({ page }) => {
// Go directly to sign-in
await page.goto('/sign-in');
// Fill in credentials
await page.getByLabel('Email Address').fill('e2e@test.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: /sign in/i }).click();
// Wait until a valid session cookie is set
await expect
.poll(async () => {
const cookies = await page.context().cookies();
return cookies.some((c) =>
[
'next-auth.session-token',
'__Secure-next-auth.session-token',
'authjs.session-token',
'__Secure-authjs.session-token',
].includes(c.name),
);
})
.toBe(true);
await page.goto('/');
// Save the authenticated state for other tests
await page.context().storageState({ path: 'storage/auth.json' });
});This test signs in a user, waits until the session is active, and then stores the logged-in state in a JSON file.
Now, tell Playwright to run the auth setup before your actual tests. In playwright.config.ts:
projects: [
// Run the auth setup first
{
name: "setup",
testMatch: /tests\/e2e\/setup\/auth\.setup\.ts/
},
// Use the saved state for browser tests
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
storageState: "storage/auth.json"
},
dependencies: ["setup"],
},
],Now, Playwright will first run the auth.setup.ts test, save the login state, and then inject that state into your other tests.
No more logging in over and over, your E2E tests will be both faster and easier to maintain.
Playwright’s parallel test execution is a superpower that slashes your Next.js test suite’s runtime. It runs tests concurrently across multiple CPU cores, each in its own browser context—think of it as a fresh browser session per test. This keeps tests isolated, preventing issues like shared cookies or local storage from causing flaky failures. For Next.js apps, where pages might tweak server state or client-side data, this isolation is a game-changer for reliability.
Here’s a quick example testing a Next.js product page in parallel, with each test in its own context:
test.describe('Product Page Tests', () => {
test('adds product to cart', async ({ page }) => {
await page.goto('/products/prod1', { waitUntil: 'networkidle' });
await page.click('button[data-testid="add-to-cart"]');
await expect(page.getByText('Cart: 1 item')).toBeVisible();
});
test('views product details', async ({ page }) => {
await page.goto('/products/prod1', { waitUntil: 'networkidle' });
// Verify product details
await expect(page.getByRole('heading', { name: 'Test Product' })).toBeVisible();
await expect(page.getByText('Price: $99.99')).toBeVisible();
});
test('checks product availability', async ({ page }) => {
await page.goto('/products/prod1', { waitUntil: 'commit' });
// Verify availability status
await expect(page.getByText('In Stock')).toBeVisible();
});
});Configure parallelism in playwright.config.ts
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
workers: '50%', // Use 50% of CPU cores for parallelism
fullyParallel: true, // Run all tests in parallel
use: {
baseURL: 'http://localhost:3000', // Your Next.js app
},
});What’s happening? Playwright runs these tests simultaneously, with each page in a fresh context, so cart actions don’t mess with product details. The fullyParallel: true config maximizes speed, perfect for Next.js apps with many tests.
Pro tip: In CI, use test sharding to split tests across machines for even faster runs.
By nailing these best practices, production builds, API mocking, stable selectors, auth state reuse, and parallel runs, you’ll craft fast, reliable, and maintainable tests for your Next.js app. Playwright makes it a breeze, so your codebase stays solid as it grows.
Which best practice will you tackle first?