testinge2etokens

Token-Efficient E2E Testing with Claude Code & Playwright

Token-Efficient E2E Testing with Claude Code & Playwright

How to cut AI-assisted testing costs by 10× while getting better coverage — using a three-tier strategy that keeps screenshots out of your context window.

1 The Problem with Interactive AI Testing

When Claude Code drives a Playwright browser interactively — click, snapshot, click, snapshot — each step costs tokens. A full user journey of 15 interactions easily burns 30,000–60,000 tokens in a single session, mostly on accessibility snapshots that look like this:

### Snapshot
- Heading "Fleet Dashboard" [level=1]
- Button "+ Bootstrap Node" [ref=e47]
- Table [ref=e48]
  - Row [ref=e49]
    - Cell "mac-mini-01" [ref=e50]
    - Cell "online" [ref=e51]
    ...

Multiply that across 20 journeys and you’ve spent a meaningful chunk of a context window just describing what a browser sees. Then the context fills, compacts, and the next session starts cold.

The real cost: it’s not just API tokens. It’s session continuity. When context overflows mid-journey, you lose test state, have to re-login, re-navigate, and re-discover what was already verified.

2 The Three-Tier Testing Pyramid

The solution is to match the test tool to the question being answered. Not every test needs a browser. Not every assertion needs a screenshot.

Tier 1 — API / pytest ~200 tokens per test · backend logic · no browser Tier 2 — Playwright spec files ~2 k tokens per run · UI flows · text report only Tier 3 — Interactive MCP failures only 80% 15% 5% of tests of tests of tests

The wider the tier, the more tests live there. The narrower the tier, the fewer tokens it must burn.

Tier 1 · API

~200

tokens / test

Tier 2 · Spec file

~2k

tokens / full run

Tier 3 · Interactive

~50k

tokens / journey

The numbers make the decision obvious: default to Tier 1, only escalate to Tier 2 for UI flows, and use Tier 3 only to debug specific failures.

3 Tier 1 — API Tests with pytest + httpx

Pure API tests have zero browser overhead. They validate backend logic, auth, validation, and business rules — which is the majority of what matters. Claude reads a compact pass/fail output, not screenshots.

What belongs here

Authentication flows, CRUD endpoints, pagination, status transitions, rate limiting, permission enforcement, edge-case inputs, and all security checks (path traversal, SQL injection probes).

# tests/integration/test_bootstrap.py
async def test_bootstrap_409_if_already_running(client, db):
    # First bootstrap — should succeed
    r1 = await client.post("/api/v1/ansible/bootstrap",
        json={"minion_id": "mac-01", "target_ip": "203.0.113.11"})
    assert r1.status_code == 200

    # Second bootstrap on same node — must be 409
    r2 = await client.post("/api/v1/ansible/bootstrap",
        json={"minion_id": "mac-01", "target_ip": "203.0.113.11"})
    assert r2.status_code == 409

Running 50 of these costs roughly the same as one interactive Playwright journey. The output Claude reads back is just:

50 passed in 4.32s

Rule: If the assertion doesn’t require seeing pixels, it belongs in Tier 1.

4 Tier 2 — Playwright Spec Files (Not Interactive)

The key insight: writing and running Playwright spec files is fundamentally different from interactive Playwright MCP. When you run npx playwright test --reporter=line, the only thing that comes back into Claude’s context is the text output — not snapshots, not screenshots.

Write .spec.ts once npx playwright test headless Compact text ✓ 18 passed ✗ 2 failed Claude reads failure reason only ~2,000 tokens total vs ~50,000 for interactive

The spec file is written once by Claude in your session. Subsequent runs — from CI, from the kri.sh test command, from your terminal — consume near-zero AI tokens. Only failures come back into context for diagnosis.

Spec file structure for kri

tests/e2e/
  helpers.ts ← login helpers, API base URL
  auth.spec.ts ← AUTH-01..07 (API + UI)
  fleet.spec.ts ← FLEET-01..14 (API + UI)
  bootstrap.spec.ts← BOOT-01..22 (API + UI)
  nodes.spec.ts ← NODE-01..18 (API + UI)
  baselines.spec.ts← BASE-01..12 (API + UI)
  groups.spec.ts ← GRP-01..12 (API + UI)
  playbooks.spec.ts← PLAY-01..21 (API + UI)
playwright.config.ts ← screenshots only-on-failure

The login trick that saves the most tokens

The loginViaApi helper bypasses the login UI entirely — it calls the auth endpoint with request.post() and injects tokens directly into localStorage. This skips 4 interactive steps (navigate, fill email, fill password, click submit) on every single test.

// helpers.ts — fast login via API, skips UI
export async function loginViaApi(page: Page, user = ADMIN) {
  const res = await page.request.post(`${API}/auth/login`, {
    data: { email: user.email, password: user.password },
  })
  const { access_token, refresh_token } = await res.json()
  await page.goto('/')
  await page.evaluate(({ at, rt }) => {
    localStorage.setItem('access_token', at)
    localStorage.setItem('refresh_token', rt)
  }, { at: access_token, rt: refresh_token })
  await page.goto('/fleet')
}

5 Tier 3 — Interactive MCP for Failure Diagnosis

Interactive Playwright MCP (navigating, clicking, snapshotting step-by-step inside a Claude session) is expensive but irreplaceable for one thing: diagnosing visual failures that text output can’t describe.

Run specs kri.sh test All pass? → done ✓ Failure? read error text first Still unclear? ↓ use MCP for this test only Escalation ladder Interactive MCP is the last resort, not the first tool

The subagent multiplier

When you do need interactive testing, spawn a subagent for it. The subagent’s context is isolated — all the snapshots it takes burn its token budget, not yours. It comes back with a one-paragraph summary.

# In your main session:
Agent({
  description: "Bootstrap modal interactive test",
  prompt: """Test the Bootstrap Node flow at http://localhost:5173:
    1. Login as admin@fleet.example.internal / changeme
    2. Click + Bootstrap Node
    3. Type mac-mini-01 — verify IP pre-fills and is locked
    4. Type brand-new-xyz — verify IP is editable
    5. Report PASS/FAIL for each step with any error text."""
})

The subagent does all the snapshotting internally. You receive one compact result. Your session context stays clean for the next task.

6 Running the Suite

All tests are wired into kri.sh. Make sure kri is running first.

# Start kri
./scripts/kri.sh start

# Run full E2E suite — gets compact line reporter output
./scripts/kri.sh test

# Run a specific file
./scripts/kri.sh test auth

# Run from repo root directly
npx playwright test --reporter=line

# Run only API-level tests (fastest)
npx playwright test --grep "API" --reporter=line

# Run with full HTML report (human review)
npx playwright test --reporter=html

What Claude reads back when all passes:

  Running 47 tests using 1 worker
  ··············································
  47 passed (38.2s)

What Claude reads back on failure:

  Running 47 tests using 1 worker
  ·······················✗····················
  1 failed

 bootstrap.spec.ts:89 BOOT-03 existing minion ID auto-fills IP
    Error: Timed out 5000ms waiting for expect(locator).toBeVisible()
    Received: <p> "New node — enter IP address below" </p> visible

That failure message is 200 tokens. Claude knows exactly what to check without a screenshot.

7 Decision Reference

Question to answerToolCost
Does this API return the right status code?pytest / Playwright API request~200 tk
Does this UI flow work end-to-end?Playwright spec file (headless)~2k tk
Why is this specific test failing visually?Subagent with interactive MCP~10k tk
Full visual exploration of an unknown issueInteractive MCP (direct, last resort)~50k tk

The 10× savings come from two habits: writing spec files instead of interactive journeys, and spawning subagents for the rare times you need a real browser session. Both keep your main context window clean and your token bill predictable.


Enjoyed this post?

Get the next one in your inbox — only when I ship something worth reading.

Newsletter form not configured.

Or follow on Substack for the newsletter.

Comments via GitHub Discussions

Comments not configured. Set GISCUS env vars to enable.