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 Drizzle 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 automatic waiting, network interception and useful debugging tools. Playwright supports Chromium, Firefox and WebKit. The shipped configuration runs Desktop Chrome only so the default suite stays focused and reasonably fast.
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 behavior: Issues that only appear in a real browser
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: false,
forbidOnly: isCI,
retries: isCI ? 1 : 0,
workers: 1,
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
- Deterministic execution: Uses one worker because authenticated tests reset shared database fixtures such as users, organizations and two-factor state
- Configured browser: Runs the Chromium project with the Desktop Chrome device profile. Add Firefox or WebKit projects explicitly if your support policy requires them
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/);
await expect(
page.getByText('Sign in to your account', { exact: true })
).toBeVisible();
// Check form elements
await expect(page.getByLabel('Email')).toBeVisible();
await expect(page.getByLabel('Password', { exact: true })).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(/Create an account/);
await expect(
page.getByText('Create your account', { exact: true })
).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"]');Make shared state explicit
Prefer independent tests when a flow can create and remove its own data. The shipped authenticated application suite is deliberately serial because it shares deterministic users and resets mutable authentication state between security scenarios.
The authenticated suite uses tests/e2e/seed.mjs to create test-only owner and
administrator accounts in the configured test database. It also resets mutable
security state such as TOTP enrollment before the relevant flow. Never point
the E2E environment at a development, staging or production database containing
real users.
Use an isolated database
Set DATABASE_URL in the local .env file to a disposable E2E database. The
seed script inserts predictable fixtures and is intentionally safe to rerun,
but it must never operate on customer data.
The seed is executed from tests/e2e/application.spec.ts before the authenticated
suite. Run it through Node with the test environment loaded when you need to
restore those fixtures manually:
node --env-file=.env tests/e2e/seed.mjsDo not add a setup project or saved browser authentication state unless you also change the tests to consume it. The current tests sign in through the UI so they exercise the real authentication flow.
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
The configuration captures a trace on the first retry. CI retries failures once, so its failed-test artifacts can include a trace. Local runs use no retries; enable tracing explicitly or reproduce the failure with debug mode when needed. View a captured trace with:
npx playwright show-trace trace.zipThe trace viewer shows a timeline of all actions, network requests, and console logs.
Videos and screenshots
The shipped configuration retains video for failed tests in test-results/.
Screenshots are not enabled by default. Add screenshot: 'only-on-failure' to
the Playwright use configuration if your CI artifacts should include them.
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.