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:
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 bracketpartial_bracket- Tournament with some bracket gamespending_bracket- Tournament with no bracket gamesdraft_captain_turn- Draft waiting for captain pickshuffle_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¶
page.getByRole()- Accessibility-based (preferred)page.getByTestId()- Explicit test hookspage.getByText()- Visible text contentpage.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¶
Trace Viewer¶
Failed tests automatically capture traces. View them:
Debug Mode¶
# Run with Playwright Inspector
just test::pw::debug
# Run specific test in debug mode
just test::pw::spec navigation --debug
UI Mode¶
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 shardplaywright-test-results-{shard}- Screenshots, videos, tracesplaywright-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