E2E Tests
Test complete user flows and interactions with Playwright.
End-to-end (E2E) tests verify that your application works correctly from a user's perspective. They test complete user flows by simulating real user interactions in a browser.
The Pro Next.js Prisma starter kit uses Playwright for E2E testing. Playwright is a modern, reliable testing framework that supports multiple browsers and provides excellent debugging tools.
Why Playwright?
Playwright provides fast, reliable E2E testing with automatic waiting, network interception, and powerful debugging tools. It supports Chromium, Firefox, and WebKit, and provides a great developer experience with its UI mode and trace viewer.
Why write E2E tests?
E2E tests verify that your application works correctly as a whole. They catch issues that unit tests might miss, such as:
- Integration problems: Issues between different parts of your application
- User flow bugs: Problems with complete user journeys
- UI regressions: Visual or interaction issues
- Browser compatibility: Issues specific to different browsers
E2E tests are slower than unit tests, so use them strategically for critical user flows rather than trying to test everything.
Configuration
The Playwright configuration is in playwright.config.ts:
import path from 'node:path';
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
dotenv.config({ path: path.resolve(__dirname, '.env') });
const isCI = !!process.env.CI;
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: isCI,
retries: isCI ? 1 : 0,
workers: isCI ? 1 : undefined,
reporter: [['html']],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
video: {
mode: 'retain-on-failure',
size: { width: 640, height: 480 }
}
},
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome']
}
}
],
webServer: {
command: 'npm run build && npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !isCI,
stdout: 'pipe',
timeout: 180 * 1000
}
});Key features:
- Test directory: Tests are located in
./tests/e2e - Automatic server: Builds and starts the app automatically
- Video recording: Records videos of failed tests
- Trace viewer: Captures traces for debugging failed tests
- CI optimization: Adjusts retries and workers for CI environments
Running E2E tests
Run all E2E tests
npm run test:e2eThis runs all E2E tests. The app is automatically built and started before tests run.
Run with UI mode
npm run test:e2e:uiThis opens Playwright's UI mode, which provides a visual interface for running and debugging tests.
Run in debug mode
npm run test:e2e:debugThis opens Playwright Inspector, allowing you to step through tests and see what's happening.
Run in headed mode
npm run test:e2e:headedThis runs tests with a visible browser window, useful for debugging visual issues.
Setup Playwright
Install Playwright browsers (first time only):
npm run test:e2e:setupWriting E2E tests
Example: Testing authentication pages
Here's an example of testing authentication pages:
import { expect, test } from '@playwright/test';
test.describe('Authentication Pages', () => {
test('sign-in page loads correctly', async ({ page }) => {
await page.goto('/auth/sign-in');
// Check page title
await expect(page).toHaveTitle(/Sign in/);
// Check main heading
await expect(
page.getByRole('heading', { name: 'Sign in to your account' })
).toBeVisible();
// Check form elements
await expect(page.getByLabel('Email')).toBeVisible();
await expect(page.getByLabel('Password')).toBeVisible();
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
// Check links
await expect(
page.getByRole('link', { name: 'Forgot password?' })
).toBeVisible();
await expect(page.getByRole('link', { name: 'Sign up' })).toBeVisible();
});
test('sign-up page loads correctly', async ({ page }) => {
await page.goto('/auth/sign-up');
// Check page title
await expect(page).toHaveTitle(/Sign up/);
// Check main heading
await expect(
page.getByRole('heading', { name: 'Create an account' })
).toBeVisible();
});
});Common patterns
Navigation
import { test } from '@playwright/test';
test('navigates to dashboard', async ({ page }) => {
await page.goto('/');
await page.click('text=Dashboard');
await expect(page).toHaveURL('/dashboard');
});Form interactions
import { expect, test } from '@playwright/test';
test('fills out and submits form', async ({ page }) => {
await page.goto('/contact');
await page.fill('input[name="name"]', 'John Doe');
await page.fill('input[name="email"]', 'john@example.com');
await page.fill('textarea[name="message"]', 'Test message');
await page.click('button[type="submit"]');
await expect(page.locator('text=Message sent')).toBeVisible();
});Waiting for elements
import { expect, test } from '@playwright/test';
test('waits for dynamic content', async ({ page }) => {
await page.goto('/dashboard');
// Wait for data to load
await page.waitForSelector('text=Loading...', { state: 'hidden' });
// Check that data is displayed
await expect(page.locator('text=Total Users')).toBeVisible();
});Assertions
import { expect, test } from '@playwright/test';
test('checks various assertions', async ({ page }) => {
await page.goto('/');
// Check visibility
await expect(page.locator('h1')).toBeVisible();
// Check text content
await expect(page.locator('h1')).toHaveText('Welcome');
// Check URL
await expect(page).toHaveURL('http://localhost:3000/');
// Check element count
await expect(page.locator('button')).toHaveCount(3);
});Best practices
Test user flows, not implementation
Focus on what users do, not how the code works. Test complete user journeys rather than individual components.
// ✅ Good - tests user flow
test('user can sign up and access dashboard', async ({ page }) => {
await page.goto('/auth/sign-up');
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');
});
// ❌ Not so good - tests implementation
test('calls signup API', async ({ page }) => {
// Testing API calls directly
});Use page object model for complex flows
For complex pages or flows, use the page object model to keep tests maintainable.
class SignInPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/auth/sign-in');
}
async signIn(email: string, password: string) {
await this.page.fill('input[name="email"]', email);
await this.page.fill('input[name="password"]', password);
await this.page.click('button[type="submit"]');
}
}
test('user can sign in', async ({ page }) => {
const signInPage = new SignInPage(page);
await signInPage.goto();
await signInPage.signIn('test@example.com', 'password123');
await expect(page).toHaveURL('/dashboard');
});Use data-testid for stable selectors
Use data-testid attributes for elements that are likely to change, making tests more resilient.
// In your component
<button data-testid="submit-button">Submit</button>
// In your test
await page.click('[data-testid="submit-button"]');Keep tests independent
Each test should be able to run independently. Don't rely on the order of tests or shared state.
// ✅ Good - independent test
test('user can sign in', async ({ page }) => {
// Test creates its own user and signs in
});
// ❌ Not so good - depends on previous test
test('user can access dashboard', async ({ page }) => {
// Assumes user is already signed in from previous test
});Use setup files for common tasks
Use Playwright's setup files for tasks like authentication that are needed by multiple tests.
// tests/e2e/auth.setup.ts
import { test as setup } from '@playwright/test';
setup('authenticate', async ({ page }) => {
// Sign in and save auth state
await page.goto('/auth/sign-in');
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
await page.context().storageState({ path: 'playwright/.auth/user.json' });
});Debugging failed tests
When a test fails, Playwright provides several tools to help debug:
View test report
npx playwright show-reportThis opens the HTML test report showing all test results, screenshots, and videos.
Use trace viewer
When a test fails, Playwright automatically captures a trace. View it with:
npx playwright show-trace trace.zipThe trace viewer shows a timeline of all actions, network requests, and console logs.
Screenshots and videos
Playwright automatically captures screenshots and videos of failed tests. These are saved in the test-results/ directory.
Next steps
With E2E tests set up, you can now:
- Test complete user flows to ensure everything works together
- Catch integration issues before they reach production
- Verify UI behavior across different browsers
- Debug failures with powerful debugging tools
For faster feedback during development, use Unit Tests to test individual functions and components.