General
Testing

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

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

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/);

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

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"]');

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

Terminal
npx playwright show-report

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

Terminal
npx playwright show-trace trace.zip

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