ShipVeryFastShipVeryFast
Documentation

End-to-end tests (Playwright)

ShipVeryFast ships a full Playwright suite under tests/e2e/ that drives a real browser against the running app, auth flows, navigation, forms, user journeys, and automated accessibility checks. This page covers playwright.config.ts, the targeted npm scripts, and how to write your own specs.

playwright.config.ts

The config lives at the repo root in playwright.config.ts. The defaults that matter day to day: testDir is ./tests/e2e, the per-test timeout is 30s (30 * 1000), the assertion expect.timeout is 10s, and fullyParallel is on. retries are 2 on CI and 0 locally; workersare capped at 3 on CI and left to Playwright's default locally. The baseURL is process.env.PLAYWRIGHT_BASE_URL or http://localhost:3000, so a bare page.goto('/login') resolves against your dev server.

// playwright.config.ts (excerpt)
export default defineConfig({
  testDir: './tests/e2e',
  timeout: 30 * 1000,
  expect: { timeout: 10 * 1000 },
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 3 : undefined,
  use: {
    baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
    locale: 'en-US',
    timezoneId: 'America/New_York',
  },
});

The webServer block

Playwright starts the app for you via the webServer block, so you never have to boot a server in a second terminal. On CI it serves the production build with npm run start (run npm run build first); locally it runs the dev server with npm run dev. reuseExistingServer is enabled when not on CI, so an app already listening on http://localhost:3000 is reused instead of spawning a duplicate. Startup gets up to 120s.

// playwright.config.ts (excerpt)
webServer: {
  command: process.env.CI ? 'npm run start' : 'npm run dev',
  url: 'http://localhost:3000',
  reuseExistingServer: !process.env.CI,
  timeout: 120 * 1000,
},

Tip: because reuseExistingServer is true locally, if you already have npm run dev running, the E2E run attaches to it, faster startup and shared hot state. On CI it always builds a fresh production server.

Browser projects

The project matrix is conditional on process.env.CI. On CI only chromium runs (for speed and reliability). Locally you get the full cross-browser and mobile matrix: chromium, firefox, webkit, plus Mobile Chrome (Pixel 5) and Mobile Safari (iPhone 12).

// playwright.config.ts (excerpt)
projects: process.env.CI
  ? [{ name: 'chromium', use: { ...devices['Desktop Chromium'] } }]
  : [
      { name: 'chromium', use: { ...devices['Desktop Chromium'] } },
      { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
      { name: 'webkit', use: { ...devices['Desktop Safari'] } },
      { name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
      { name: 'Mobile Safari', use: { ...devices['iPhone 12'] } },
    ],

To run a single project locally, pass --project, e.g. npx playwright test --project=chromium.

Targeted E2E scripts

package.json defines npx playwright testwrappers for each spec file so you can run just the slice you're working on. The spec files live in tests/e2e/.

ScriptRunsSpec file
npm run test:e2eThe whole suitetests/e2e/
npm run test:e2e:authAuthentication flowsauth-flow.e2e.spec.ts
npm run test:e2e:navigationNavigationnavigation.e2e.spec.ts
npm run test:e2e:formsForm behaviourforms.e2e.spec.ts
npm run test:e2e:accessibilityAccessibility (uses axe)accessibility.e2e.spec.ts
npm run test:e2e:flowsEnd-to-end user journeysflows.spec.ts
npm run test:e2e:headedThe suite with a visible browser--headed
npm run test:e2e:debugThe suite in Playwright debug mode--debug
# Run everything
npm run test:e2e

# Iterate on just the auth specs
npm run test:e2e:auth

# Watch it click through in a real browser
npm run test:e2e:headed

# Step through with the Playwright Inspector
npm run test:e2e:debug

A typical auth spec uses Page Object classes and role-based locators. Here is the shape of tests/e2e/auth-flow.e2e.spec.ts:

import { test, expect, Page } from '@playwright/test';

class LoginPage {
  constructor(private page: Page) {}
  async goto() {
    await this.page.goto('/login');
    await this.page.waitForLoadState('domcontentloaded');
  }
  async fillEmail(email: string) {
    await this.page.getByLabel(/email/i).fill(email);
  }
  async clickSubmit() {
    await this.page.getByRole('button', { name: /log in|sign in/i }).click();
  }
}

test.describe('Authentication Flow Tests', () => {
  test('Successful user registration', async ({ page }) => {
    // baseURL is http://localhost:3000, so relative paths just work
    await page.goto('/signup');
    await page.getByLabel(/email/i).fill('test-' + Date.now() + '@example.com');
    await page.getByLabel(/^password$/i).fill('SecurePass123!');
    await page.getByRole('button', { name: /sign up|create account|register/i }).click();
    await expect(page).toHaveURL(/dashboard|verify|welcome/);
  });
});

Accessibility testing with axe

tests/e2e/accessibility.e2e.spec.ts combines manual keyboard / focus / landmark checks with automated WCAG scans powered by @axe-core/playwright. The automated block builds an AxeBuilder against the page, scopes it to WCAG tags, and asserts that there are zero violations.

import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('Home page passes automated accessibility checks', async ({ page }) => {
  await page.goto('/');

  const accessibilityScanResults = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
    .analyze();

  expect(accessibilityScanResults.violations).toEqual([]);
});

The spec also defines its own waitForHydration() helper that waits for load and for a visible h1, main, nav, a, button before asserting, the app keeps the UI hidden until React hydration sets mounted, so this avoids scanning an empty page. Run the whole accessibility file with npm run test:e2e:accessibility.

Reporters and artifacts

Three reporters are always configured, html (written to test-results/html, open: 'never'), json (test-results/results.json), and junit (test-results/junit.xml), plus a fourth that switches on environment: github on CI, list locally. Failure artifacts go to outputDir, which is test-results/artifacts: a screenshot is captured only-on-failure, video is retain-on-failure, and trace is collected on-first-retry.

ArtifactWhereConfig
HTML reporttest-results/htmlreporter: ['html', ...]
JSON resultstest-results/results.jsonreporter: ['json', ...]
JUnit XMLtest-results/junit.xmlreporter: ['junit', ...]
Screenshots / video / tracestest-results/artifactsoutputDir + use
# Open the HTML report from the last run
npx playwright show-report test-results/html

# A failed test on retry leaves a trace you can replay
npx playwright show-trace test-results/artifacts/<trace>.zip

Next steps