Skip to main content
General
Testing

E2E Tests

Test complete user flows and interactions with Playwright.

Open MarkdownFull AI corpusFeedback

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 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:

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

Terminal
npm run test:e2e

This runs all E2E tests. The app is automatically built and started before tests run.

Run with UI mode

Terminal
npm run test:e2e:ui

This opens Playwright's UI mode, which provides a visual interface for running and debugging tests.

Run in debug mode

Terminal
npm run test:e2e:debug

This opens Playwright Inspector, allowing you to step through tests and see what's happening.

Run in headed mode

Terminal
npm run test:e2e:headed

This runs tests with a visible browser window, useful for debugging visual issues.

Setup Playwright

Install Playwright browsers (first time only):

Terminal
npm run test:e2e:setup

Writing E2E tests

Example: Testing authentication pages

Here's an example of testing authentication pages:

tests/e2e/auth.spec.ts
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

tests/e2e/navigation.spec.ts
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

tests/e2e/forms.spec.ts
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

tests/e2e/waiting.spec.ts
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

tests/e2e/assertions.spec.ts
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.

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:

Terminal
node --env-file=.env tests/e2e/seed.mjs

Do 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

Terminal
npx playwright show-report

This 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:

Terminal
npx playwright show-trace trace.zip

The 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.