<!-- skill: Web Testing | version: 1.0.0 | description: Playwright E2E, Vitest units and coverage gates in CI | category: Web -->
## Instructions
- Pyramid: 70% Vitest unit (pure fns/components), 20% integration (API + DB via Testcontainers), 10% Playwright E2E (critical paths); E2E never duplicates unit edge cases.
- Playwright setup: `npx init playwright` (chromium/firefox/webkit), `playwright.config.ts` (`baseURL`, `trace: 'on-first-retry'`, `screenshot: 'only-on-failure'`), `testDir ./e2e`, parallel workers CI `workers: 2`.
- Selectors: `getByRole('button', {name: 'Save'})` > `getByLabel` > `getByTestId('cart-count')`; never CSS `.btn-primary` or xpath; `expect(page.getByRole('heading')).toBeVisible()` auto-retrying.
- Fixtures: `test.extend({ authedPage })` seeding via API (`POST /test/seed`) not UI clicks; `storageState` per role (`admin.json/user.json`); teardown `DELETE /test/reset`; unique emails `u+${Date.now()}@x.com`.
- Flows: login → create → assert URL + DB + email stub; `page.route('**/api/**', mock)` only for 3P (Stripe), real backend otherwise; `expect().toPass()` for eventual consistency (webhooks).
- Vitest unit: `vitest run --coverage`, `jsdom` for components, `msw` for fetch mocks, `fake timers` for debounce; `describe/it/expect`, `vi.fn()` spies; Testing Library `render(screen.getByRole)` + `userEvent` typing.
- Testing Library queries: `getByRole/getByLabelText/getByPlaceholder/getByText` priority, `findBy*` async, `queryBy*` absence; `userEvent.click/type` over `fireEvent`; `within(dialog)` scoping.
- API contract: Supertest/Vitest `POST /api/posts → 201 {id}` + Zod schema assert, 401/422 negatives, idempotency key test (double POST → one row); OpenAPI snapshot `toMatchSnapshot`.
- CI: GitHub Action matrix (node 20, 3 browsers E2E nightly / chromium per-PR), `actions/upload-artifact` traces + `html` report, `playwright --shard=1/3` split; main blocks on fail.
- Coverage gates: `--coverage.thresholds {lines:80, branches:75}` fail under, `codecov` PR comment, exclude `*.stories.*`/generated; mutation spot-check `stryker` on money/auth code.
- Flake discipline: `retries: 2` CI only, `test.slow()` for cold boot, no `waitForTimeout(3000)` — use `expect(locator).toBeVisible()`; quarantine `*.flaky.ts` with issue link, fix in 7d.
- Visual/a11y: `toHaveScreenshot('hero.png', {maxDiffPixels: 100})` per breakpoint, `axe()` scan zero serious violations, `reduced-motion` + keyboard-only pass (`Tab/Enter`) in E2E.
## Examples
- Checkout: seed product, `authedPage` adds to cart, stubs Stripe `confirm` route, asserts `/success` + DB order + email stub, trace on retry.
- Auth: unit `hashPassword/verify` vectors, API 401 without cookie + 422 bad email, E2E login/logout + guarded `/app` redirect, session expiry case.
- Search: Vitest debounce + ranking fn, integration `GET /api/search?q` relevance order, E2E types + `findByRole('option')` + Enter navigates, a11y `aria-expanded` asserted.
## Workflows
1. Setup: init Playwright + Vitest + Testing Library + MSW, config baseURL/traces/shards, seed/reset test endpoints.
2. Units: pure logic + components with `userEvent`, fake timers, 80% gate; run `vitest --coverage` per PR.
3. API: Supertest status + schema + negatives + idempotency; OpenAPI snapshot.
4. E2E: role fixtures + storageState, critical flows only, auto-retry asserts, trace/screenshot on fail.
5. CI: per-PR chromium + unit, nightly all browsers + visual + axe; shard, artifacts, coverage comment.
## Templates
- Playwright fixture + spec:
```ts
// e2e/fixtures.ts
import { test as base } from '@playwright/test';
export const test = base.extend({
  authedPage: async ({ browser }, use) => {
    const page = await browser.newPage();
    await page.request.post('/test/seed', { data: { email: `u+${Date.now()}@x.com` } });
    await page.goto('/login'); await page.getByLabel('Email').fill('u@x.com');
    await page.getByLabel('Password').fill('pw123456'); await page.getByRole('button', { name: 'Log in' }).click();
    await page.waitForURL('/app'); await use(page); await page.close();
  } });
// e2e/checkout.spec.ts
import { test, expect } from './fixtures';
test('checkout', async ({ authedPage: p }) => {
  await p.goto('/shop'); await p.getByRole('button', { name: 'Add' }).first().click();
  await p.getByTestId('cart-count').click(); await p.getByRole('button', { name: 'Checkout' }).click();
  await expect(p).toHaveURL(/success/); await expect(p.getByRole('heading')).toContainText('Thanks');
});
```
- Vitest + Testing Library:
```ts
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { Price } from './Price';
describe('Price', () => {
  it('formats + adds', async () => {
    render(<Price value={1999} onAdd={() => {}} />);
    expect(screen.getByText('$19.99')).toBeDefined();
    await userEvent.click(screen.getByRole('button', { name: /add/i }));
  });
  it('debounces', async () => {
    vi.useFakeTimers(); const fn = vi.fn(); /* … */ vi.advanceTimersByTime(300);
    expect(fn).toHaveBeenCalledTimes(1); vi.useRealTimers();
  });
});
```
- CI workflow:
```yaml
jobs:
  test: { runs-on: ubuntu-latest, steps: [
    { uses: actions/checkout@v4 }, { uses: actions/setup-node@v4, with: { node-version: 20 } },
    { run: npm ci }, { run: npx vitest run --coverage },
    { run: npx playwright install chromium },
    { run: npx playwright test --shard=1/2 } ] }
```
## Tools
- SARAH shell_run for `vitest run --coverage`, `playwright test --ui`, `playwright show-report`, `npx axe`.
- SARAH file_write for specs/fixtures/mocks; web_fetch for Playwright + Vitest + Testing Library docs.
## Advanced
- Sharding/matrix: `matrix: {shard: [1/3,2/3,3/3]}` 3 runners, `merge-reports` single HTML, nightly `all-browsers + mobile (Pixel 7/iPhone)` + `visual` only on `main` to save minutes.
- Contract chaos: `msw` failure injection (500/timeout/slow 3s) asserting retries + skeletons, `testcontainers` Postgres/Redis real deps, `k6` 50 VUs on critical POST after E2E green.
- Quality gates: `codecov patch:85/project:80`, `stryker` 30 threshold on `billing/*`, flake dashboard (retry rate >5% → quarantine), `playwright --update-snapshots` only with designer approval.
## Documentation
- https://playwright.dev/docs/intro
- https://vitest.dev/guide/
- https://testing-library.com/docs/
- https://playwright.dev/docs/test-fixtures
- https://docs.codecov.com/docs
