Skip to content

Playwright E2E Testing

This document covers Playwright end-to-end testing for DraftForge.

Overview

Playwright is the recommended E2E testing framework, offering:

  • Parallel execution - Tests run concurrently for faster feedback
  • Multiple browser support - Chromium, mobile viewports
  • Auto-waiting - Built-in smart waits for elements
  • Trace viewer - Debug failed tests with screenshots and DOM snapshots

Quick Start

# Install Playwright browsers (first time only)
just test::pw::install

# Run all tests headless
just test::pw::headless

# Run tests with visible browser
just test::pw::headed

# Open Playwright UI for interactive debugging
just test::pw::ui

Test Structure

frontend/tests/playwright/
├── e2e/                    # Test specs organized by feature
│   ├── 00-hydration-handling.spec.ts
│   ├── 01-navigation.spec.ts
│   ├── 03-tournaments/     # Tournament list tests
│   ├── 04-tournament/      # Single tournament tests
│   ├── 05-match-stats/     # Match statistics tests
│   ├── 06-mobile/          # Mobile responsive tests
│   ├── 07-draft/           # Captain draft tests
│   ├── 08-shuffle-draft/   # Shuffle draft tests
│   ├── 09-bracket/         # Bracket display tests
│   ├── 10-leagues/         # League tests
│   └── herodraft/          # HeroDraft multi-browser tests
├── fixtures/               # Test fixtures and auth helpers
│   ├── auth.ts             # Authentication fixtures
│   ├── herodraft.ts        # HeroDraft-specific fixtures
│   └── index.ts            # Re-exports all fixtures
├── helpers/                # Page objects and utilities
│   ├── utils.ts            # Shared utilities
│   ├── users.ts            # User card helpers
│   ├── tournament.ts       # TournamentPage class
│   ├── league.ts           # LeaguePage class
│   └── HeroDraftPage.ts    # HeroDraftPage class
└── global-setup.ts         # Pre-test data caching

Running Tests

By Category

# Navigation tests
just test::pw::spec navigation

# Tournament tests
just test::pw::spec tournament

# Draft tests
just test::pw::spec draft

# Bracket tests
just test::pw::spec bracket

# League tests
just test::pw::spec league

# Mobile responsive tests
just test::pw::spec mobile

# HeroDraft multi-browser tests
just test::pw::spec herodraft

By Spec Pattern

# Run tests matching grep pattern
just test::pw::spec bracket

# Run tests matching pattern (not a file path)
just test::pw::spec navigation

# Run tests matching pattern
just test::pw::spec draft

Debug Mode

# Run with Playwright Inspector
just test::pw::debug

# Open UI mode for visual debugging
just test::pw::ui

Performance Optimization

Local Parallel Execution

Tests run in parallel using 50% of CPU cores by default:

# Default (50% of CPUs)
just test::pw::headless

# Specify worker count
just test::pw::headless --workers=4

# Single worker for debugging
just test::pw::headless --workers=1

CI Sharding

Tests are automatically sharded across 4 parallel CI runners:

# Run specific shard locally (for debugging CI issues)
just test::pw::headless --shard=1/4
just test::pw::headless --shard=2/4
just test::pw::headless --shard=3/4
just test::pw::headless --shard=4/4

Projects

The Playwright config defines three projects:

Project Purpose Parallelism
chromium General E2E tests Fully parallel
mobile-chrome Mobile viewport tests Fully parallel
herodraft Multi-browser draft scenarios Sequential

Authentication

Full documentation: See Authentication Test Fixtures for complete details on all login fixtures, usage examples, and how to add new test users.

Login Fixtures

Tests use custom fixtures for authentication. All login fixtures are available in tests by importing from ../../fixtures:

import { test, expect } from '../../fixtures';

Available Login Fixtures

Fixture User Purpose
loginAdmin kettleofketchup Site superuser with Steam ID. Full admin access.
loginStaff hurk_ Site staff member. Staff-level access.
loginUser bucketoffish55 Regular user. No Steam ID, basic access.
loginUserClaimer user_claimer User for testing claim/merge flow. Has same Steam ID as claimable_profile.
loginOrgAdmin org_admin_tester Organization admin (admin of org 1).
loginOrgStaff org_staff_tester Organization staff member (staff of org 1).
loginLeagueAdmin league_admin_tester League admin (admin of league 1).
loginLeagueStaff league_staff_tester League staff member (staff of league 1).

Test Data Users (No Login)

These users exist in the test database but cannot log in (no Discord ID):

User Purpose
claimable_profile User with Steam ID but no Discord. Used to test claim/merge feature. Has same Steam ID as user_claimer.

Basic Usage

test('admin can edit tournament', async ({ page, loginAdmin }) => {
  await loginAdmin();
  await page.goto('/tournament/1');
  // ... test admin functionality
});

test('staff can view stats', async ({ page, loginStaff }) => {
  await loginStaff();
  // ...
});

test('user can view tournament', async ({ page, loginUser }) => {
  await loginUser();
  // ...
});

Organization/League Role Testing

test('org admin can manage organization', async ({ page, loginOrgAdmin }) => {
  await loginOrgAdmin();
  await page.goto('/organizations/1');
  // ... test org admin functionality
});

test('league staff can view league', async ({ page, loginLeagueStaff }) => {
  await loginLeagueStaff();
  await page.goto('/leagues/1');
  // ...
});

Claim Profile Testing

The loginUserClaimer fixture sets up two users with matching Steam IDs for testing the claim/merge feature:

test('user can claim profile', async ({ page, loginUserClaimer }) => {
  // This login also ensures claimable_profile exists
  await loginUserClaimer();

  await page.goto('/users');
  // claimable_profile should show claim button
  // (same Steam ID, no Discord ID)
});

Login As Specific User

For testing user-specific flows (e.g., captains):

test('captain can make picks', async ({ page, loginAsUser }) => {
  // Login as user with PK 42
  await loginAsUser(42);
  await page.goto('/tournament/1');
  // ...
});

Login By Discord ID

Discord IDs are stable across populate runs (unlike PKs):

test('specific captain flow', async ({ context, loginAsDiscordId }) => {
  await loginAsDiscordId(context, '584468301988757504');
  // ...
});

Page Objects

TournamentPage

import { TournamentPage, getTournamentByKey } from '../../fixtures';

test('tournament page loads', async ({ page, context }) => {
  const tournament = await getTournamentByKey(context, 'completed_bracket');
  const tournamentPage = new TournamentPage(page);

  await tournamentPage.goto(tournament.pk);
  await tournamentPage.clickTeamsTab();
  await tournamentPage.waitForTeamsToLoad();
});

LeaguePage

import { LeaguePage, getFirstLeague } from '../../fixtures';

test('league tabs work', async ({ page, context }) => {
  const league = await getFirstLeague(context);
  const leaguePage = new LeaguePage(page);

  await leaguePage.goto(league.pk, 'info');
  await leaguePage.clickTournamentsTab();
  await leaguePage.assertTabActive('tournaments');
});

HeroDraftPage

import { HeroDraftPage } from '../../helpers/HeroDraftPage';

test('draft flow', async ({ page }) => {
  const draftPage = new HeroDraftPage(page);

  await draftPage.waitForModal();
  await draftPage.assertWaitingPhase();
  await draftPage.clickReady();
  await draftPage.waitForPhaseTransition('rolling');
});

Test Data

Tournament Lookup

Tests should use key-based lookup instead of hardcoded IDs:

// Good - dynamic lookup
const tournament = await getTournamentByKey(context, 'completed_bracket');
await page.goto(`/tournament/${tournament.pk}`);

// Bad - hardcoded ID
await page.goto('/tournament/1');

Available tournament keys (see Test Tournaments):

  • completed_bracket - Fully completed tournament with bracket
  • partial_bracket - Tournament with some bracket games
  • pending_bracket - Tournament with no bracket games
  • draft_captain_turn - Draft waiting for captain pick
  • shuffle_draft_captain_turn - Shuffle draft waiting for captain

League Lookup

// Get first available league
const league = await getFirstLeague(context);
await leaguePage.goto(league.pk, 'info');

Writing Tests

Best Practices

import { test, expect, visitAndWaitForHydration } from '../../fixtures';

test.describe('Feature Name', () => {
  test.beforeEach(async ({ loginAdmin }) => {
    await loginAdmin();
  });

  test('should do something', async ({ page }) => {
    // Use helper for navigation with hydration wait
    await visitAndWaitForHydration(page, '/tournaments');

    // Prefer semantic locators
    await page.getByRole('button', { name: 'Create' }).click();

    // Use data-testid for custom elements
    await page.locator('[data-testid="tournament-name"]').fill('Test');

    // Explicit waits instead of arbitrary timeouts
    await page.waitForLoadState('networkidle');

    // Assertions
    await expect(page.getByText('Success')).toBeVisible();
  });
});

Locator Priority

  1. page.getByRole() - Accessibility-based (preferred)
  2. page.getByTestId() - Explicit test hooks
  3. page.getByText() - Visible text content
  4. page.locator() - CSS/XPath fallback

Avoid Arbitrary Waits

// Bad - arbitrary timeout
await page.waitForTimeout(2000);

// Good - explicit wait for condition
await page.waitForLoadState('networkidle');
await expect(dialog).toBeVisible();
await page.waitForSelector('[data-testid="loaded"]');

Debugging

View Test Report

# Open HTML report after test run
just test::pw::report

Trace Viewer

Failed tests automatically capture traces. View them:

npx playwright show-trace frontend/test-results/path-to-trace.zip

Debug Mode

# Run with Playwright Inspector
just test::pw::debug

# Run specific test in debug mode
just test::pw::spec navigation --debug

UI Mode

# Interactive UI for running and debugging tests
just test::pw::ui

CI Integration

GitHub Actions

Tests run automatically on push/PR with 4-way sharding:

strategy:
  matrix:
    shard: [1, 2, 3, 4]
steps:
  - run: just test::pw::headless --shard=${{ matrix.shard }}/4

Artifacts

On failure, CI uploads:

  • playwright-report-{shard} - HTML report per shard
  • playwright-test-results-{shard} - Screenshots, videos, traces
  • playwright-report-merged - Combined report from all shards

Comparison: Playwright vs Cypress

Feature Playwright Cypress
Parallel execution Native Plugin required
Multi-browser Single test file Separate runs
Auto-waiting Built-in Built-in
Network mocking page.route() cy.intercept()
Cross-origin Supported Limited
Mobile emulation Native Plugin required

Migrating from Cypress

See the Cypress to Playwright Migration Plan for details on:

  • API mapping (cy.visit → page.goto, etc.)
  • Custom command conversion
  • Test structure changes