# Playwright E2E Testing -- How I Write Tests

This skill defines my workflow for writing and executing Playwright end-to-end tests against any web application. Follow these phases in order. The patterns are framework-agnostic (Angular, React, Next.js -- the Playwright side stays the same).

**Learned from:** Artem Bondar's "Playwright: Web Automation Testing From Zero to Hero" (Udemy, 50 sections). **Proven on:** pw-practice-app (Angular + Playwright).

---

## Phase 1: Assess the Application

Before writing any test, understand what you're testing.

1. Identify the app's stack, entry point, and how to start it locally
2. Walk through the core user flows manually -- login, CRUD operations, key business paths
3. Identify authentication requirements -- does the app need login? API tokens? OAuth?
4. Note any third-party widgets, iframes, or dynamic content that will need special handling
5. Check if the app already has `data-testid` attributes -- if not, plan to add them for complex elements only

**Output:** Mental model of the app's user flows and a list of what to test first

---

## Phase 2: Project Setup

### Initialize

```bash
npm init playwright@latest
npx playwright install
```

### Folder structure

```
tests/
  auth.setup.ts              # Shared auth state
  e2e/                        # Spec files grouped by feature
  page-objects/
    base.page.ts              # Shared page fixture + utility methods
    page-manager.ts           # Single entry point for all page objects
    login.page.ts
    dashboard.page.ts
  fixtures.ts                 # Custom fixtures (replaces beforeEach)
  test-data/                  # Mock response JSON files
playwright.config.ts
global-setup.ts               # Framework-wide prerequisites
global-teardown.ts            # Cleanup after entire run
.auth/                        # Stored auth state (gitignored)
```

### Configuration decisions

Make these choices in `playwright.config.ts` before writing tests:

| Decision | My defaults |
|----------|-------------|
| Browsers | Chromium + Firefox + mobile (Pixel 5) |
| Parallelism | `fullyParallel: false`, `workers: 1` locally; scale up in CI |
| Retries | 0 locally, 2 in CI |
| Base URL | `process.env.BASE_URL` with localhost fallback |
| Reporters | HTML + JSON + JUnit XML + Allure |
| Trace | `on-first-retry` |
| Video | On, per-project resolution (1920x1080 desktop, 393x727 mobile) |
| Screenshots | `only-on-failure` |
| Web server | `npm start` with 180s timeout, reuse unless CI |
| `.only()` in CI | `forbidOnly: true` -- never let a focused test slip through |

**Output:** `playwright.config.ts`, folder structure, and configuration decisions documented

---

## Phase 3: Authentication Strategy

Decide how tests will authenticate **before** writing any spec files. Repeating login in every test is the number one time sink.

### Option A: Shared browser state (preferred for UI tests)

Auth runs once, saves cookies/localStorage to `.auth/user.json`, every browser project loads it via `storageState`. Tests start already logged in.

```ts
// auth.setup.ts
import { test as setup } from '@playwright/test';

setup('authenticate', async ({ page }) => {
  await page.goto('/');
  // ... login steps ...
  await page.waitForResponse('**/api/tags');
  await page.context().storageState({ path: '.auth/user.json' });
});
```

Wire it in config with `dependencies: ['setup']` on each browser project.

### Option B: API token authentication

When the app uses token-based auth, skip the UI entirely. Get the token via API, store it in a JSON file or `process.env`, and inject it into `extraHTTPHeaders`.

### Option C: Global setup for framework-wide prerequisites

Global setup runs once per `npx playwright test` invocation, regardless of which projects execute. It does **not** inherit the `use` config -- you must build `request.newContext()` manually and pass headers explicitly.

Use global setup for seeding shared test data (articles, users) that the entire suite depends on. Share state between setup/teardown/tests via `process.env`.

**Rule:** `.auth/` goes in `.gitignore`. Always.

**Output:** Auth strategy chosen and wired into config

---

## Phase 4: Write Tests

### Locator hierarchy

Pick locators in this order. Stop at the first one that works.

| Priority | Locator | When |
|----------|---------|------|
| 1 | `getByRole()` | Always try first -- matches how users see the page |
| 2 | `getByLabel()` | Form fields with visible labels |
| 3 | `getByPlaceholder()` | When label is absent |
| 4 | `getByText()` with `{ exact: true }` | Static visible text |
| 5 | `getByTestId()` | Last resort -- requires attribute in source |

**Never use:** CSS class selectors, XPath, framework-generated IDs, DOM structure selectors. If you're reaching for `.btn-primary` or `div > ul > li:nth-child(3)`, the locator strategy is wrong.

### Narrowing locators

Use `has`, `hasText`, and `filter()` to scope down. Use `nth()`, `first()`, `last()` for lists. Chain filters when a single selector is ambiguous.

### Text extraction

Three distinct methods for reading text from the page:

- `await locator.textContent()` -- single element's text (includes hidden text)
- `await locator.allTextContents()` -- array of text from all matched elements
- `await locator.inputValue()` -- value from an input field (not the label, not placeholder)

### Assertions

**Locator assertions** auto-wait. Always `await` them:

```ts
await expect(locator).toBeVisible();
await expect(locator).toHaveText('Welcome');
await expect(locator).toHaveCount(3);
await expect(page).toHaveURL(/dashboard/);
```

**Soft assertions** log failures but don't stop the test. Use them for non-critical checks where you want to see all failures at once:

```ts
await expect.soft(locator).toHaveText('$9.99');
await expect.soft(locator).toContainText('In Stock');
```

Always use hard assertions for preconditions that later steps depend on.

**Negation:** `await expect(locator).not.toBeVisible();`

### UI component patterns

Each component type has a preferred interaction pattern:

| Component | Pattern | Key detail |
|-----------|---------|------------|
| Text inputs | `fill()` to set, `clear()` to reset, `pressSequentially()` for slow typing | `inputValue()` to read back |
| Checkboxes | `check()` / `uncheck()` (idempotent) | Assert with `toBeChecked()` |
| Radio buttons | `check()` on the target option | Only one can be checked -- assert the right one |
| Native `<select>` | `selectOption('text')` or `selectOption({ value: 'v' })` | Assert with `toHaveValue()` |
| Custom dropdowns | Click to open, then locate option in listbox | No native select API applies |
| Tooltips | `hover()` to trigger, then locate tooltip element | Register handler before the trigger action |
| Browser dialogs | `page.on('dialog', handler)` before the triggering click | `dialog.accept('input')` for prompts |
| Web tables | `getByRole('row', { name: '...' })` to find row, then act on cells | Loop with `for...of` + `await` |
| Datepickers | Open picker, navigate months, select day cell | Avoid `.other-month` cells |
| Sliders | `fill()` for native, `mouse.click()` with bounding box math for custom | Calculate position from boundingBox |
| iFrames | `page.frameLocator('#id')` then chain locators inside | All locator methods work inside the frame |
| Drag and drop | `dragTo()` for simple cases, manual `mouse.down/move/up` for complex | Calculate positions from boundingBox |

### Auto-waiting

Playwright auto-waits on every action and every locator assertion. You almost never need manual waits.

**When you actually need a wait**, wait for a specific signal:

- `page.waitForResponse('**/api/data')` -- a network response
- `page.waitForURL('**/dashboard')` -- a URL change
- `page.waitForSelector('.lazy-loaded')` -- an element appearing in DOM
- `page.waitForLoadState('networkidle')` -- network settling

**Never use `page.waitForTimeout()`.** It exists for debugging only. If you need it in a real test, you haven't identified the right signal.

### Timeout hierarchy (highest priority wins)

| Level | Config location |
|-------|----------------|
| Per-assertion | `{ timeout: 10_000 }` on the assertion call |
| Per-action | `{ timeout: 10_000 }` on the action call |
| Expect config | `expect.timeout` in config |
| Action config | `use.actionTimeout` in config |
| Navigation config | `use.navigationTimeout` in config |
| Test body | `test.setTimeout(60_000)` inside the test |
| Project-level | `timeout` per project in config |
| Global | Top-level `timeout` in config (default 30s) |

**Output:** Working spec files with locator strategy, assertions, and component interactions

---

## Phase 5: Page Object Model

Tests should never interact with the browser directly. They go through page objects.

### Rules

- **Page objects hold locators and actions.** Tests hold assertions.
- **One page object per logical page or major component.**
- **PageManager** is the single entry point -- tests never `new` up page objects.
- **BasePage** holds the `page` fixture and shared utilities. Every page object extends it.
- **Private methods** for internal helpers (menu state handling, calendar navigation). Public methods for test-facing actions.
- **Parameterize methods** to make them reusable -- pass form values as arguments, not hardcode them.
- Page object methods return `void` or data. Never return locators to the test.

### When the UI changes

Only the page object needs updating, not every test. That's the entire point. If a test breaks because a locator changed, the fix goes in the page object, not the spec file.

**Output:** `BasePage`, page objects, and `PageManager` wired into tests

---

## Phase 6: Custom Fixtures

When the same setup logic appears in multiple spec files, move it to a fixture using `base.extend`. This replaces `beforeEach` boilerplate.

```ts
// fixtures.ts
import { test as base } from '@playwright/test';
import { PageManager } from './page-objects/page-manager';

export const test = base.extend<{ pm: PageManager }>({
  pm: async ({ page }, use) => {
    const pm = new PageManager(page);
    await page.goto('/');
    await use(pm);
  },
});
export { expect } from '@playwright/test';
```

Then import `test` from your fixtures file, not from `@playwright/test`.

### Rules

- Each fixture encapsulates one concern (navigation, auth, test data).
- Fixtures compose -- a fixture can depend on other fixtures.
- Prefer fixtures over `beforeEach` when setup is reused across multiple spec files.
- Prefer `beforeEach` over `afterEach` -- cleanup hooks are fragile when tests fail.

**Output:** `fixtures.ts` with custom fixtures replacing `beforeEach` boilerplate

---

## Phase 7: Test Data

### Faker for dynamic data

```bash
npm install @faker-js/faker --save-dev
```

Generate data inside the test, not at module scope. Each test gets unique values. Use `faker.seed()` for reproducible failures during debugging.

### API setup/teardown for test preconditions

Create data via `request` fixture (fast, bypasses UI), validate in UI, clean up via API:

```ts
test('delete article', async ({ page, request }) => {
  // Create via API
  const res = await request.post('/api/articles', { data: { ... }, headers: { ... } });
  expect(res.status()).toEqual(201);

  // Act in UI
  await page.getByText('Test Article').click();
  await page.getByRole('button', { name: 'Delete' }).click();

  // Verify in UI
  await expect(page.locator('app-article-list')).not.toContainText('Test Article');
});
```

### Mock response files

Save API mock responses as JSON files in `test-data/`. Read them in `page.route()` handlers instead of inlining large objects. Keeps tests readable.

### Capture browser responses

Use `page.waitForResponse()` to extract data (IDs, slugs) from API calls triggered by UI actions, then use that data for cleanup via `request.delete()`.

**Key distinction:**
- `page.route()` / `page.waitForResponse()` -- intercepts browser network traffic
- `request.post()` / `request.delete()` -- standalone API calls, bypasses the browser

**Output:** Test data strategy (Faker, API fixtures, mock JSON files) integrated into specs

---

## Phase 8: Test Organization

### Tags

Tag tests with `@` annotations in the title:

```ts
test('user can checkout @smoke @regression', async ({ page }) => { ... });
```

```bash
npx playwright test --grep @smoke
npx playwright test --grep-invert @smoke    # everything except smoke
```

### NPM scripts

Define common commands in `package.json` so the team doesn't have to remember flags:

```json
{
  "scripts": {
    "test": "npx playwright test",
    "test:smoke": "npx playwright test --grep @smoke",
    "test:chromium": "npx playwright test --project chromium",
    "test:headed": "npx playwright test --headed",
    "test:debug": "npx playwright test --debug",
    "test:ui": "npx playwright test --ui",
    "report": "npx playwright show-report"
  }
}
```

### Serial vs parallel

Use `test.describe.configure({ mode: 'serial' })` when tests within a describe block depend on each other. Default to parallel everywhere else.

### Retries

Zero locally (fix the flake, don't mask it). 2 in CI (network and rendering variance is real). Use `testInfo.retry` for conditional logic on retry (clear cache, reset state).

**Output:** Tagged specs, NPM scripts, and execution strategy documented

---

## Phase 9: Visual Testing

Visual testing catches layout shifts, broken styles, and missing elements that functional assertions miss.

### Built-in: `toHaveScreenshot()`

- First run creates the baseline. Test validates from the second run onward.
- Baselines are per-browser and per-OS. Do not share across environments.
- Prefer scoped screenshots (locator) over full-page -- smaller surface = fewer spurious diffs.
- Use `maxDiffPixels` (per-call or global) to prevent false failures from rendering drift.
- Mask dynamic content: `{ mask: [page.locator('.timestamp')] }`.
- `--update-snapshots` overwrites baselines -- always review diffs before committing.

### CI visual validation: Argos CI

For visual regression at scale, integrate Argos CI with GitHub Actions. It compares screenshots across PRs, surfaces diffs in the PR review, and avoids the "baseline per OS" problem by running comparisons server-side.

**Output:** Baseline screenshots committed, `maxDiffPixels` tuned, Argos CI wired for PRs

---

## Phase 10: Mobile Testing

Add mobile projects in config using Playwright's device descriptors (`devices['Pixel 5']`, `devices['iPhone 13']`).

Use `testInfo.project.name` for conditional logic:

```ts
if (testInfo.project.name === 'mobile-chrome') {
  await page.getByRole('button', { name: 'Menu' }).click();
}
```

Filter mobile-specific tests with `testMatch` in the mobile project config. Set separate video resolution for mobile projects.

**Output:** Mobile project in config with device descriptors, conditional test logic, and separate video resolution

---

## Phase 11: CI/CD and Docker

### GitHub Actions

- Trigger on push and pull_request
- `npx playwright install --with-deps` installs browsers + system dependencies
- Upload `playwright-report/` as an artifact (30-day retention)
- Use matrix sharding for large suites: `--shard=${{ matrix.shard }}/4`
- Wire Argos CI for visual testing validation on PRs

### Docker

Two-service docker-compose architecture:

1. **App service** -- runs the dev server with health checks
2. **Tests service** -- runs Playwright against the app service, shares network namespace

Mount report volumes (`playwright-report/`, `test-results/`) from container to host so you can view results locally after the container exits.

Use the official `mcr.microsoft.com/playwright` image -- it has all browser dependencies pre-installed.

### Environment variables

- `BASE_URL` for switching between local/staging/production
- `CI=1` for CI-specific behavior (forbid `.only()`, enable retries)
- `.env` files for local development, `process.env` from CLI for CI
- Multiple config files (`playwright.staging.config.ts`) for environment-specific overrides

**Output:** GitHub Actions workflow, `docker-compose.yml`, and environment variable strategy

---

## Phase 12: Debugging and Reports

### Debugging workflow

1. **Start with the HTML report** -- `npx playwright show-report`. Look at the failure screenshot, trace, and video.
2. **Trace viewer** -- step through actions, network calls, and DOM snapshots. Most failures are obvious here.
3. **UI mode** -- `npx playwright test --ui` for interactive test development. Run, re-run, inspect.
4. **Headed mode** -- `--headed` to watch the browser. Useful for timing issues.
5. **Debug mode** -- `--debug` for step-by-step with Playwright Inspector.
6. **Codegen** -- `npx playwright codegen` to generate locators by clicking on the page.

### Reporters

Configure multiple reporters -- they serve different audiences:

| Reporter | Purpose |
|----------|---------|
| HTML | Human review, failure screenshots, trace links |
| JSON | Programmatic analysis, custom dashboards |
| JUnit XML | CI integration (Jenkins, GitHub Actions) |
| Allure | Rich reporting with history, categories, trends |
| List | Console output during local runs |

**Output:** Debugging workflow established, multiple reporters configured

---

## Anti-Patterns

| Do not | Do instead |
|--------|------------|
| `page.waitForTimeout(3000)` | Wait for a specific signal: response, selector, URL |
| CSS class selectors (`.btn-primary`) | `getByRole`, `getByLabel`, `getByText` |
| Login steps in every test | Shared auth state via `storageState` |
| `afterEach` for cleanup | `beforeEach` for setup -- ensures clean state regardless of failures |
| Hardcoded test data everywhere | Faker for dynamic data, API fixtures for setup |
| Inline large mock objects | JSON files in `test-data/` |
| `force: true` on clicks | Fix the locator or wait for the correct state |
| `test.only` committed to source | Use tags and `--grep`; `forbidOnly: true` in CI |
| Sleeping to "fix" flaky tests | Find the race condition and wait for the right signal |
| Full-page visual screenshots | Scoped locator screenshots |
| Tests reaching into component internals | Test what the user sees and interacts with |
| Copy-pasting locators across spec files | Page objects own locators; tests call methods |
| XPath | Never necessary in Playwright |

---

## Key Testing Insights (Hard-Won Lessons)

1. **Authentication is infrastructure, not test logic.** Solve it once in setup, never repeat it in specs. The `storageState` pattern eliminates the single biggest source of slow, flaky tests.

2. **API setup, UI verification.** Create test data via API (fast, reliable), then validate the user experience in the browser. Reverse for cleanup -- capture IDs from browser responses, delete via API.

3. **Auto-waiting is the default.** Playwright waits for you. Every manual `waitForTimeout` in your test suite is a red flag that you haven't identified the real signal.

4. **Page objects are a maintenance boundary, not an abstraction exercise.** When the UI changes, exactly one file changes. If that's not true, the page object structure is wrong.

5. **Flaky tests are bugs.** Retries in CI exist for genuine environment variance (network, rendering). If a test needs retries to pass locally, it has a race condition. Fix it.

6. **Visual testing catches what functional assertions miss.** A button can be "visible" and "enabled" while being rendered 200px off-screen. Screenshots catch layout regressions that locator assertions cannot.

7. **Docker makes tests reproducible.** "Works on my machine" ends when the test suite runs in the same container in CI and locally. Mount reports to the host so you can review them.

8. **Tags are your test execution API.** `@smoke`, `@regression`, `@mobile` -- they let you run the right subset for the right context without maintaining separate config files.

---

## The Pattern

> I identify what users care about, design the test architecture, and decide what to automate and how. Playwright turns those decisions into fast, reliable checks that run on every push. The skill is knowing *what to test* and *when a failure is real*.

### Workflow

```
Assess the app -> Set up project + auth -> Write specs with POM ->
Add fixtures + test data -> Tag and organize -> Visual baselines ->
Mobile coverage -> CI pipeline + Docker -> Monitor and maintain
```

Each phase validates the previous one. Don't skip ahead.
