
Qa Testing Playwright
- 539 installs
- 73 repo stars
- Updated July 13, 2026
- vasilyu1983/ai-agents-public
qa-testing-playwright is an agent skill that runs reliable Playwright end-to-end browser tests from AI coding sessions for developers who need automated UI validation before release.
About
qa-testing-playwright is a skills.sh-listed agent skill from vasilyu1983/ai-agents-public with 456 installs and rank 6 on the catalog. It equips AI coding agents to author and run Playwright end-to-end browser tests directly from the development environment. Developers reach for qa-testing-playwright when they need repeatable UI flows exercised in real browsers instead of manual click-through checks. The skill targets reliable E2E coverage for web applications and extensions where regressions are costly. It fits the ship phase when teams want agent-assisted test creation, execution, and debugging of browser automation suites.
- Automates browser-based end-to-end testing using Playwright
- Enables AI agents to generate, run, and debug UI interaction tests
- Supports headless, headed, and cloud execution modes
- Integrates directly with Claude Code, Cursor, and similar agents
- Produces test reports and failure screenshots automatically
Qa Testing Playwright by the numbers
- 539 all-time installs (skills.sh)
- +9 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #609 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/vasilyu1983/ai-agents-public --skill qa-testing-playwrightAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 539 |
|---|---|
| repo stars | ★ 73 |
| Last updated | July 13, 2026 |
| Repository | vasilyu1983/ai-agents-public ↗ |
How do you run Playwright E2E tests from an AI agent?
Run reliable end-to-end browser tests with Playwright directly from their AI coding agent.
Who is it for?
Developers shipping web apps who want agent-assisted Playwright E2E coverage without context-switching to a separate QA toolchain.
Skip if: Backend-only services with no browser UI where unit or API contract tests are sufficient.
When should I use this skill?
The user asks to add, run, debug, or stabilize Playwright end-to-end browser tests from an AI coding session.
What you get
Playwright test files, executed E2E browser runs, and pass-or-fail results for UI flows.
- Playwright test specs
- E2E test run results
By the numbers
- 456 installs on skills.sh
- Rank 6 on the skills.sh catalog
Files
QA Testing (Playwright)
High-signal, cost-aware E2E testing for web applications.
Core docs:
- https://playwright.dev/docs/best-practices
- https://playwright.dev/docs/locators
- https://playwright.dev/docs/test-retries
- https://playwright.dev/docs/trace-viewer
- https://playwright.dev/docs/test-sharding
- https://playwright.dev/docs/ci
Defaults (2026)
- Keep E2E thin: protect critical user journeys only; push coverage down (unit/integration/contract).
- Locator priority:
getByRole→getByLabel/getByText→getByTestId(fallback). - Waiting: rely on Playwright auto-wait + web-first assertions; no sleeps/time-based waits.
- Isolation: tests must run alone, in parallel, and in any order; eliminate shared mutable state.
- Flake posture: retries are a debugging tool; treat rerun-pass as a failure signal and fix root cause.
- CI posture: smoke gate on PRs; shard/parallelize regression on schedule; always keep artifacts (trace/video/screenshot).
Quick Start
| Command | Purpose |
|---|---|
npm init playwright@latest | Initialize Playwright |
npx playwright test | Run all tests |
npx playwright test --grep @smoke | Run smoke tests |
npx playwright test --project=chromium | Run a single project |
npx playwright test --ui | Debug with UI mode |
npx playwright test --debug | Step through a test |
npx playwright show-trace trace.zip | Inspect trace artifacts |
npx playwright show-report | Inspect HTML report |
When to Use
- E2E tests for web applications
- Test user authentication flows
- Verify form submissions
- Test responsive designs
- Automate browser interactions
- Set up Playwright in CI/CD
When NOT to Use
| Scenario | Use Instead |
|---|---|
| Unit testing | Jest, Vitest, pytest |
| API contracts | qa-api-testing-contracts |
| Load testing | k6, Locust, Artillery |
| Mobile native | Appium |
Authoring Rules
Locator Strategy
// 1. Role locators (preferred)
await page.getByRole('button', { name: 'Sign in' }).click();
// 2. Label/text locators
await page.getByLabel('Email').fill('user@example.com');
// 3. Test IDs (fallback)
await page.getByTestId('user-avatar').click();Flake Control
- Avoid sleeps; use Playwright auto-wait
- Use retries as signal, not a crutch
- Capture trace/screenshot/video on failure
- Prefer user-like interactions; avoid
force: true
Workflow
- Write the smallest test that proves the user outcome (intent + oracle).
- Stabilize locators and assertions before adding more steps.
- Make state explicit: seed per test/worker, clean up deterministically, mock third-party boundaries.
- In CI: shard/parallelize, capture artifacts, and fail fast on rerun-pass flakes.
Debugging Checklist
If something is flaky:
- Open trace first; identify whether it is selector ambiguity, missing wait, or state leakage.
- Replace brittle selectors with semantic locators; replace sleeps with
expect(...)or a targeted wait. - Reduce global timeouts; add scoped timeouts only when the product truly needs it.
- If it only fails in CI, look for concurrency, cold-start, CPU starvation, and environment differences.
Do / Avoid
- Make tests independent and deterministic
- Use network mocking for third-party deps
- Run smoke E2E on PRs; full regression on schedule
- "Test everything E2E" as default
- Weakening assertions to "fix" flakes
- Auto-healing that weakens assertions
Execution Preflight (High ROI)
Run this preflight before expensive E2E runs to prevent avoidable failures.
Preflight Checklist
1. Repository shape:
- Confirm working directory and expected app root exist.
- Verify spec paths before execution (
rg --files tests/e2e | rg <target>).
2. Port/process hygiene:
- Check and clear stale dev server port before run (example:
lsof -i :3001). - Avoid parallel local servers colliding with Playwright
webServer.
3. Command validity:
- Validate CLI flags for current tool versions before batch runs.
- Prefer exact spec paths or
--grepover broad globs during triage.
4. Artifact expectations:
- Confirm result artifact paths exist before reading (
test -f <error-context.md>). - If artifact path missing, inspect latest
test-resultsindex first.
Mandatory Sandbox/Port Decisions
Before running Playwright in constrained environments (sandboxed terminals, CI containers, shared dev hosts), decide and document:
- Bind host/port: confirm whether app server must use
127.0.0.1or0.0.0.0, and verify selected port is free. - Escalation path: if bind attempts fail with
EPERM/EACCES, escalate immediately instead of retry loops. - Long-flow timeout budget: set explicit per-test timeout for API-heavy flows (generation/checkout/report) instead of inflating global timeout.
- Build lock hygiene: clear stale
.next/lockand terminate stale build/dev PIDs before rerun.
Triage Sequence (Fastest Signal)
1. Reproduce one failing test with --workers=1. 2. Capture trace/video/screenshot for that single failure. 3. Fix determinism root cause. 4. Re-run targeted suite. 5. Only then run broad regression.
Failure Patterns to Treat as Environment, Not Product Bugs
EADDRINUSEon Playwright web server port- Missing spec/result paths from stale assumptions
- Shell glob expansion failures for bracketed route segments
Resources
| Resource | Purpose |
|---|---|
| references/playwright-mcp.md | MCP & AI testing |
| references/playwright-patterns.md | Advanced patterns |
| references/playwright-ci.md | CI configurations |
| references/playwright-authentication.md | Auth patterns and session management |
| references/visual-regression-testing.md | Visual regression strategies |
| references/api-testing-playwright.md | API testing with APIRequestContext |
| references/playwright-preflight-sandbox.md | Sandbox/port preflight and escalation decisions |
| data/sources.json | Documentation links |
Templates
| Template | Purpose |
|---|---|
| assets/template-playwright-e2e-review-checklist.md | E2E review checklist |
| assets/template-playwright-fail-on-flaky-reporter.js | Fail CI on rerun-pass flakes |
| assets/template-playwright-preflight-checklist.md | Preflight checklist for port/sandbox/timeouts |
Related Skills
| Skill | Purpose |
|---|---|
| qa-testing-strategy | Overall test strategy |
| software-frontend | Frontend development |
| ops-devops-platform | CI/CD integration |
Fact-Checking
- Use web search/web fetch to verify current external facts, versions, pricing, deadlines, regulations, or platform behavior before final answers.
- Prefer primary sources; report source links and dates for volatile information.
- If web access is unavailable, state the limitation and mark guidance as unverified.
Playwright E2E Review Checklist (Selectors, Parallelization, Flake Rules)
Use this checklist in PR review and when triaging flaky E2E suites.
Core
Scope and ROI
- [ ] Test protects a critical user journey or high-risk integration (not "nice to have").
- [ ] Lower-layer alternatives considered (unit/integration/contract).
- [ ] Test name states user intent and expected outcome.
Selector Strategy (Stability)
- [ ] Uses
getByRole/getByLabel/getByTextby default (Playwright locators: https://playwright.dev/docs/locators). - [ ] Uses
data-testidonly when semantic selectors are not feasible. - [ ] Avoids brittle CSS/XPath selectors.
Assertions and Waiting
- [ ] Uses web-first assertions (
expect(...)) and Playwright auto-wait (https://playwright.dev/docs/best-practices). - [ ] No
sleep/ time-based waits. - [ ] Timeouts are scoped (per action/assert) rather than global increases.
Test Isolation and Data
- [ ] Test is independent (can run alone, in parallel, in any order).
- [ ] Data setup is explicit (fixtures/factories) and cleanup is deterministic.
- [ ] No reliance on shared accounts, shared carts, or global state unless isolated by tenant.
Network and Dependencies
- [ ] Third-party dependencies are mocked at the boundary (route interception) unless explicitly required.
- [ ] Test validates your integration contract, not a third-party UI.
Flake Control
- [ ] Retries configured intentionally; rerun-pass tests are treated as flakes (https://playwright.dev/docs/test-retries).
- [ ] Trace/screenshot/video captured on failure and attached to CI artifacts (trace viewer: https://playwright.dev/docs/trace-viewer).
Parallelization and Sharding (CI Economics)
- [ ] Suite is safe to run with multiple workers (no shared mutable state).
- [ ] Sharding plan exists for large suites (https://playwright.dev/docs/test-sharding).
- [ ] PR gate is a smoke subset; full regression runs on schedule or per-release.
Visual Testing (If Used)
- [ ] Visual checks are limited to stable screens/components with a review workflow.
- [ ] Snapshots are not used as a substitute for functional assertions.
Optional: AI / Automation
Do:
- Use AI to scaffold tests and page objects, then apply this checklist to harden selectors, assertions, and data isolation.
- Use AI to summarize trace artifacts and propose hypotheses; verify with evidence and stable assertions.
Avoid:
- Auto-healing by weakening assertions or switching to brittle selectors.
// Playwright custom reporter: fail CI if any test passes on retry (rerun-pass).
// Usage (example):
// // playwright.config.ts
// reporter: [
// ['html', { open: 'never' }],
// ['./playwright/fail-on-flaky-reporter.js'],
// ],
//
// Notes:
// - Add retries in CI (e.g., retries: 2) to collect traces, but still fail on rerun-pass.
// - Keep artifacts (trace/video/screenshot) to debug the flake quickly.
//
// Reporter API: https://playwright.dev/docs/test-reporters
class FailOnFlakyReporter {
constructor() {
this._rerunPasses = [];
}
onTestEnd(test, result) {
if (result.status === 'passed' && result.retry > 0) {
const titlePath = typeof test.titlePath === 'function' ? test.titlePath() : [test.title];
this._rerunPasses.push({
test: titlePath.join(' > '),
retry: result.retry,
});
}
}
onEnd() {
if (this._rerunPasses.length === 0) return;
// Ensure the run fails, while still allowing CI to upload artifacts.
process.exitCode = 1;
console.error('\nFlaky tests detected (passed on retry):');
for (const item of this._rerunPasses) {
console.error(`- ${item.test} (retry=${item.retry})`);
}
console.error('Fix root cause; do not silence with weaker assertions.');
}
}
module.exports = FailOnFlakyReporter;
Template: Playwright Preflight Checklist
Use this before running expensive Playwright suites.
Run Context
- Workdir:
________________________ - Command:
________________________ - Target spec(s):
________________________ - Operator:
________________________ - Date:
YYYY-MM-DD
Checklist
- [ ] Verified target specs exist (
rg --files e2e/tests | rg <pattern>). - [ ] Confirmed web server port
_____is free or intentionally occupied. - [ ] Confirmed host binding requirement (
127.0.0.1vs0.0.0.0). - [ ] No stale
next/playwrightprocess conflicts. - [ ]
.next/lockchecked and cleaned if stale. - [ ] Per-test timeout set for long API-heavy steps (not global timeout inflation).
- [ ] Escalation decision recorded if sandbox/permission constraints detected.
- [ ] Initial repro uses one test, one worker.
- [ ] Trace/video/screenshot artifacts path confirmed.
Failure Classification
- Environment-level failure?
yes / no - Product-level failure?
yes / no - Evidence:
_____________________________________________
Next Action
- [ ] Targeted rerun
- [ ] Contract/selector fix
- [ ] Escalate permissions
- [ ] Stop and re-scope
{
"metadata": {
"skill": "qa-testing-playwright",
"updated": "2026-01-18",
"version": "3.0",
"total_sources": 15,
"description": "Primary references for Playwright E2E testing: scope control, stable locators, flake control, CI sharding, MCP/AI automation, visual testing, and real device testing."
},
"categories": {
"playwright_official_docs": [
{
"name": "Playwright Documentation - Intro",
"url": "https://playwright.dev/docs/intro",
"description": "Official documentation entry point and setup.",
"add_as_web_search": true,
"optional": false
},
{
"name": "Playwright - Best Practices",
"url": "https://playwright.dev/docs/best-practices",
"description": "High-signal guidance for stable tests and CI execution.",
"add_as_web_search": true,
"optional": false
},
{
"name": "Playwright - Locators",
"url": "https://playwright.dev/docs/locators",
"description": "Selector strategy (roles/labels/test IDs) and stability guidance.",
"add_as_web_search": true,
"optional": false
},
{
"name": "Playwright - Retries",
"url": "https://playwright.dev/docs/test-retries",
"description": "Retries and patterns for handling failures vs identifying flakes.",
"add_as_web_search": true,
"optional": false
},
{
"name": "Playwright - Sharding",
"url": "https://playwright.dev/docs/test-sharding",
"description": "Sharding long-running suites across machines for CI economics.",
"add_as_web_search": true,
"optional": false
},
{
"name": "Playwright - Trace Viewer",
"url": "https://playwright.dev/docs/trace-viewer",
"description": "Debugging tool for triaging failures with traces, screenshots, and steps.",
"add_as_web_search": true,
"optional": false
},
{
"name": "Playwright - Browsers",
"url": "https://playwright.dev/docs/browsers",
"description": "Bundled browsers vs stable channels (Chrome/Edge) and when to use them.",
"add_as_web_search": true,
"optional": false
},
{
"name": "Playwright - Continuous Integration",
"url": "https://playwright.dev/docs/ci",
"description": "CI configuration patterns and artifact capture.",
"add_as_web_search": true,
"optional": false
},
{
"name": "Playwright - Release Notes",
"url": "https://playwright.dev/docs/release-notes",
"description": "Latest features and breaking changes (v1.57+, Chrome for Testing, Speedboard).",
"add_as_web_search": true,
"optional": false
}
],
"mcp_ai_automation": [
{
"name": "Microsoft Playwright MCP",
"url": "https://github.com/microsoft/playwright-mcp",
"description": "Official MCP server for AI-driven browser automation via accessibility tree (package: @playwright/mcp).",
"add_as_web_search": true,
"optional": false
},
{
"name": "Playwright MCP Guide 2026",
"url": "https://www.testleaf.com/blog/playwright-mcp-ai-test-automation-2026/",
"description": "Third-party overview of Playwright MCP; verify against the official README before applying in production.",
"add_as_web_search": true,
"optional": true
}
],
"visual_testing": [
{
"name": "Chromatic - Playwright Integration",
"url": "https://www.chromatic.com/docs/playwright/",
"description": "Visual regression testing with Chromatic for Playwright tests.",
"add_as_web_search": true,
"optional": true
},
{
"name": "Percy - Playwright Integration",
"url": "https://docs.percy.io/docs/playwright",
"description": "AI-powered visual testing with Percy by BrowserStack.",
"add_as_web_search": true,
"optional": true
}
],
"real_device_testing": [
{
"name": "BrowserStack - Playwright iOS",
"url": "https://www.browserstack.com/guide/playwright-ios-automation",
"description": "Running Playwright on real iOS devices via BrowserStack.",
"add_as_web_search": true,
"optional": true
},
{
"name": "LambdaTest - Playwright iOS",
"url": "https://www.lambdatest.com/blog/playwright-testing-on-ios-real-devices/",
"description": "Playwright testing on real iOS devices with LambdaTest.",
"add_as_web_search": true,
"optional": true
}
]
}
}
API Testing with Playwright
API testing using Playwright's APIRequestContext -- standalone API tests without a browser, response validation, combining API setup with UI verification, and advanced patterns for complete API coverage.
Contents
- Standalone API Testing
- APIRequestContext Creation
- Request Methods
- Header and Auth Management
- Response Validation Patterns
- JSON Schema Validation
- Combining API and UI Tests
- File Upload and Download
- Multipart Form Data
- API Test Fixtures
- Parallel API Testing
- Mocking External APIs
- API Test Organization
- Related Resources
---
Standalone API Testing
Playwright can run API tests without launching a browser, making it fast and efficient for backend validation.
import { test, expect } from '@playwright/test';
test('GET /api/users returns user list', async ({ request }) => {
const response = await request.get('/api/users');
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
const users = await response.json();
expect(users).toHaveLength(expect.any(Number));
expect(users[0]).toHaveProperty('id');
expect(users[0]).toHaveProperty('email');
});Why Use Playwright for API Tests?
| Advantage | Description |
|---|---|
| Same toolchain | One framework for UI + API tests |
| Shared auth | Reuse storageState cookies/tokens |
| Mixed tests | API setup + UI verification in one test |
| Parallel execution | Built-in parallelism via workers |
| Rich assertions | expect API works on responses |
| Trace viewer | API calls visible in Playwright traces |
---
APIRequestContext Creation
Using the Built-In request Fixture
// Uses baseURL from playwright.config.ts
test('simple API call', async ({ request }) => {
const response = await request.get('/api/health');
expect(response.ok()).toBeTruthy();
});Creating a Custom Context
import { test, expect, APIRequestContext } from '@playwright/test';
let apiContext: APIRequestContext;
test.beforeAll(async ({ playwright }) => {
apiContext = await playwright.request.newContext({
baseURL: 'https://api.example.com',
extraHTTPHeaders: {
'Authorization': `Bearer ${process.env.API_TOKEN}`,
'Accept': 'application/json',
'X-Request-Source': 'playwright-tests',
},
});
});
test.afterAll(async () => {
await apiContext.dispose();
});
test('custom context API call', async () => {
const response = await apiContext.get('/users/me');
expect(response.ok()).toBeTruthy();
});Config-Level BaseURL
// playwright.config.ts
export default defineConfig({
use: {
baseURL: 'http://localhost:3000',
extraHTTPHeaders: {
'Accept': 'application/json',
},
},
projects: [
{
name: 'api-tests',
testMatch: /.*\.api\.spec\.ts/,
use: {
baseURL: process.env.API_BASE_URL || 'http://localhost:3000',
},
},
],
});---
Request Methods
Full CRUD Example
test.describe('Users API CRUD', () => {
let userId: string;
test('POST - create user', async ({ request }) => {
const response = await request.post('/api/users', {
data: {
name: 'Jane Smith',
email: 'jane@example.com',
role: 'editor',
},
});
expect(response.status()).toBe(201);
const body = await response.json();
expect(body.id).toBeDefined();
expect(body.name).toBe('Jane Smith');
userId = body.id;
});
test('GET - read user', async ({ request }) => {
const response = await request.get(`/api/users/${userId}`);
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.email).toBe('jane@example.com');
});
test('PUT - replace user', async ({ request }) => {
const response = await request.put(`/api/users/${userId}`, {
data: {
name: 'Jane Doe',
email: 'jane.doe@example.com',
role: 'admin',
},
});
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.name).toBe('Jane Doe');
});
test('PATCH - partial update', async ({ request }) => {
const response = await request.patch(`/api/users/${userId}`, {
data: { role: 'viewer' },
});
expect(response.status()).toBe(200);
const body = await response.json();
expect(body.role).toBe('viewer');
expect(body.name).toBe('Jane Doe'); // unchanged
});
test('DELETE - remove user', async ({ request }) => {
const response = await request.delete(`/api/users/${userId}`);
expect(response.status()).toBe(204);
// Verify deletion
const getResponse = await request.get(`/api/users/${userId}`);
expect(getResponse.status()).toBe(404);
});
});---
Header and Auth Management
Per-Request Headers
test('request with custom headers', async ({ request }) => {
const response = await request.get('/api/data', {
headers: {
'Authorization': 'Bearer specific-token',
'X-Request-ID': `test-${Date.now()}`,
'Accept-Language': 'en-US',
},
});
expect(response.ok()).toBeTruthy();
});Auth Patterns
// Pattern 1: Bearer token
test('bearer token auth', async ({ request }) => {
const loginResponse = await request.post('/api/auth/login', {
data: { email: 'user@example.com', password: 'password' },
});
const { token } = await loginResponse.json();
const response = await request.get('/api/protected', {
headers: { Authorization: `Bearer ${token}` },
});
expect(response.ok()).toBeTruthy();
});
// Pattern 2: Cookie-based auth (automatic with request fixture)
test('cookie auth via login', async ({ request }) => {
// Login sets cookies automatically
await request.post('/api/auth/login', {
data: { email: 'user@example.com', password: 'password' },
});
// Subsequent requests include cookies
const response = await request.get('/api/protected');
expect(response.ok()).toBeTruthy();
});
// Pattern 3: API key
test('API key auth', async ({ playwright }) => {
const apiContext = await playwright.request.newContext({
baseURL: 'https://api.example.com',
extraHTTPHeaders: {
'X-API-Key': process.env.API_KEY!,
},
});
const response = await apiContext.get('/data');
expect(response.ok()).toBeTruthy();
await apiContext.dispose();
});---
Response Validation Patterns
Status Code Validation
test('validate error responses', async ({ request }) => {
// 400 Bad Request
const badRequest = await request.post('/api/users', {
data: { email: 'not-an-email' },
});
expect(badRequest.status()).toBe(400);
const errors = await badRequest.json();
expect(errors.errors).toContainEqual(
expect.objectContaining({ field: 'email', message: expect.any(String) })
);
// 401 Unauthorized
const unauthorized = await request.get('/api/protected', {
headers: { Authorization: 'Bearer invalid-token' },
});
expect(unauthorized.status()).toBe(401);
// 404 Not Found
const notFound = await request.get('/api/users/nonexistent-id');
expect(notFound.status()).toBe(404);
// 429 Rate Limited
const responses = await Promise.all(
Array.from({ length: 100 }, () => request.get('/api/data'))
);
const rateLimited = responses.some(r => r.status() === 429);
expect(rateLimited).toBe(true);
});Response Body Assertions
test('validate response structure', async ({ request }) => {
const response = await request.get('/api/users/1');
const user = await response.json();
// Structure validation
expect(user).toMatchObject({
id: expect.any(String),
name: expect.any(String),
email: expect.stringMatching(/.+@.+\..+/),
createdAt: expect.any(String),
role: expect.stringMatching(/^(admin|editor|viewer)$/),
});
// Negative checks
expect(user).not.toHaveProperty('password');
expect(user).not.toHaveProperty('passwordHash');
});
test('validate list response', async ({ request }) => {
const response = await request.get('/api/users?page=1&limit=10');
const body = await response.json();
expect(body.data).toBeInstanceOf(Array);
expect(body.data.length).toBeLessThanOrEqual(10);
expect(body.pagination).toMatchObject({
page: 1,
limit: 10,
total: expect.any(Number),
});
});Response Headers Validation
test('validate response headers', async ({ request }) => {
const response = await request.get('/api/data');
expect(response.headers()['content-type']).toContain('application/json');
expect(response.headers()['cache-control']).toBeDefined();
// Security headers
expect(response.headers()['x-content-type-options']).toBe('nosniff');
expect(response.headers()['x-frame-options']).toBe('DENY');
});---
JSON Schema Validation
Using Ajv
import Ajv from 'ajv';
const ajv = new Ajv();
const userSchema = {
type: 'object',
required: ['id', 'name', 'email', 'role', 'createdAt'],
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
role: { type: 'string', enum: ['admin', 'editor', 'viewer'] },
createdAt: { type: 'string', format: 'date-time' },
},
additionalProperties: false,
};
test('response matches JSON schema', async ({ request }) => {
const response = await request.get('/api/users/1');
const body = await response.json();
const validate = ajv.compile(userSchema);
const valid = validate(body);
if (!valid) {
console.error('Schema validation errors:', validate.errors);
}
expect(valid).toBe(true);
});Schema for List Endpoints
const userListSchema = {
type: 'object',
required: ['data', 'pagination'],
properties: {
data: {
type: 'array',
items: { $ref: '#/$defs/user' },
},
pagination: {
type: 'object',
required: ['page', 'limit', 'total'],
properties: {
page: { type: 'integer', minimum: 1 },
limit: { type: 'integer', minimum: 1, maximum: 100 },
total: { type: 'integer', minimum: 0 },
},
},
},
$defs: {
user: userSchema,
},
};---
Combining API and UI Tests
API Setup, UI Verification
test('create item via API, verify in UI', async ({ request, page }) => {
// Setup: create data via API (fast)
const createResponse = await request.post('/api/products', {
data: {
name: 'Test Product',
price: 29.99,
description: 'Created by API for UI test',
},
});
const product = await createResponse.json();
// Verify: check it appears in UI
await page.goto('/products');
await expect(page.getByText('Test Product')).toBeVisible();
await expect(page.getByText('$29.99')).toBeVisible();
// Cleanup via API
await request.delete(`/api/products/${product.id}`);
});UI Action, API Verification
test('form submission creates correct API record', async ({ page, request }) => {
await page.goto('/products/new');
// UI action
await page.getByRole('textbox', { name: 'Name' }).fill('New Widget');
await page.getByRole('spinbutton', { name: 'Price' }).fill('49.99');
await page.getByRole('button', { name: 'Create' }).click();
await page.waitForURL(/\/products\/[\w-]+/);
// Extract ID from URL
const url = page.url();
const productId = url.split('/').pop();
// Verify via API
const response = await request.get(`/api/products/${productId}`);
const product = await response.json();
expect(product.name).toBe('New Widget');
expect(product.price).toBe(49.99);
});---
File Upload and Download
File Upload via API
import fs from 'fs';
import path from 'path';
test('upload file via API', async ({ request }) => {
const filePath = path.join(__dirname, 'fixtures', 'test-image.png');
const response = await request.post('/api/uploads', {
multipart: {
file: {
name: 'test-image.png',
mimeType: 'image/png',
buffer: fs.readFileSync(filePath),
},
description: 'Test upload',
},
});
expect(response.status()).toBe(201);
const body = await response.json();
expect(body.url).toMatch(/^https?:\/\//);
expect(body.size).toBeGreaterThan(0);
});File Download via API
test('download file via API', async ({ request }) => {
const response = await request.get('/api/exports/report.csv');
expect(response.status()).toBe(200);
expect(response.headers()['content-type']).toContain('text/csv');
const body = await response.body();
expect(body.length).toBeGreaterThan(0);
// Validate CSV structure
const text = body.toString();
const lines = text.split('\n');
expect(lines[0]).toContain('name,email,role'); // header row
expect(lines.length).toBeGreaterThan(1); // at least one data row
});---
Multipart Form Data
test('multipart form submission', async ({ request }) => {
const response = await request.post('/api/feedback', {
multipart: {
name: 'Test User',
email: 'test@example.com',
message: 'This is a test feedback message',
category: 'bug',
screenshot: {
name: 'bug-screenshot.png',
mimeType: 'image/png',
buffer: Buffer.from('fake-image-data'),
},
},
});
expect(response.status()).toBe(201);
const body = await response.json();
expect(body.ticketId).toBeDefined();
});---
API Test Fixtures
Reusable API Client Fixture
// fixtures/api.ts
import { test as base, APIRequestContext } from '@playwright/test';
type ApiFixtures = {
authenticatedApi: APIRequestContext;
adminApi: APIRequestContext;
};
export const test = base.extend<ApiFixtures>({
authenticatedApi: async ({ playwright }, use) => {
const api = await playwright.request.newContext({
baseURL: process.env.API_BASE_URL || 'http://localhost:3000',
extraHTTPHeaders: {
Authorization: `Bearer ${process.env.TEST_USER_TOKEN}`,
},
});
await use(api);
await api.dispose();
},
adminApi: async ({ playwright }, use) => {
const api = await playwright.request.newContext({
baseURL: process.env.API_BASE_URL || 'http://localhost:3000',
extraHTTPHeaders: {
Authorization: `Bearer ${process.env.TEST_ADMIN_TOKEN}`,
},
});
await use(api);
await api.dispose();
},
});
export { expect } from '@playwright/test';// tests/admin.api.spec.ts
import { test, expect } from '../fixtures/api';
test('admin can list all users', async ({ adminApi }) => {
const response = await adminApi.get('/api/admin/users');
expect(response.ok()).toBeTruthy();
const users = await response.json();
expect(users.length).toBeGreaterThan(0);
});Data Cleanup Fixture
export const test = base.extend<{ cleanup: string[] }>({
cleanup: async ({ request }, use) => {
const idsToCleanup: string[] = [];
await use(idsToCleanup);
// Teardown: delete all created resources
for (const id of idsToCleanup) {
await request.delete(`/api/resources/${id}`);
}
},
});
// Usage
test('create and track resource', async ({ request, cleanup }) => {
const response = await request.post('/api/resources', {
data: { name: 'test-resource' },
});
const { id } = await response.json();
cleanup.push(id); // Automatically cleaned up after test
});---
Parallel API Testing
Worker-Isolated Data
// Prevent data collisions in parallel workers
test('parallel-safe API test', async ({ request }, testInfo) => {
const uniqueSuffix = `${testInfo.workerIndex}-${Date.now()}`;
const response = await request.post('/api/users', {
data: {
name: `Test User ${uniqueSuffix}`,
email: `test-${uniqueSuffix}@example.com`,
},
});
expect(response.status()).toBe(201);
});Serial API Tests (When Needed)
// For tests with ordering dependencies
test.describe.configure({ mode: 'serial' });
test.describe('Order lifecycle', () => {
let orderId: string;
test('create order', async ({ request }) => {
const response = await request.post('/api/orders', {
data: { items: [{ sku: 'WIDGET-1', quantity: 2 }] },
});
orderId = (await response.json()).id;
});
test('pay for order', async ({ request }) => {
const response = await request.post(`/api/orders/${orderId}/pay`, {
data: { method: 'card', token: 'test-token' },
});
expect(response.status()).toBe(200);
});
test('ship order', async ({ request }) => {
const response = await request.post(`/api/orders/${orderId}/ship`);
expect(response.status()).toBe(200);
});
});---
Mocking External APIs
Route-Based Mocking for Integration Tests
test('handles payment gateway failure', async ({ page }) => {
// Mock external Stripe API
await page.route('**/api.stripe.com/**', route => {
route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Payment gateway unavailable' }),
});
});
await page.goto('/checkout');
await page.getByRole('button', { name: 'Pay now' }).click();
await expect(page.getByText('Payment service is temporarily unavailable')).toBeVisible();
});
test('handles slow external API', async ({ page }) => {
await page.route('**/api.external-service.com/**', async route => {
await new Promise(resolve => setTimeout(resolve, 5000));
route.fulfill({ status: 200, body: '{}' });
});
await page.goto('/integration');
await expect(page.getByText('Loading external data...')).toBeVisible();
});---
API Test Organization
Recommended File Structure
tests/
├── api/ # Standalone API tests
│ ├── auth.api.spec.ts # Authentication endpoints
│ ├── users.api.spec.ts # Users CRUD
│ ├── products.api.spec.ts # Products CRUD
│ ├── orders.api.spec.ts # Order lifecycle
│ └── health.api.spec.ts # Health checks
├── e2e/ # UI tests (may use API for setup)
│ ├── checkout.spec.ts
│ └── dashboard.spec.ts
├── fixtures/
│ ├── api.ts # API fixtures
│ └── schemas/ # JSON schemas
│ ├── user.schema.json
│ └── product.schema.json
└── playwright.config.tsProject Separation
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'api',
testMatch: /.*\.api\.spec\.ts/,
use: {
baseURL: process.env.API_URL || 'http://localhost:3000',
},
},
{
name: 'e2e',
testMatch: /.*\.spec\.ts/,
testIgnore: /.*\.api\.spec\.ts/,
use: {
baseURL: process.env.APP_URL || 'http://localhost:3000',
...devices['Desktop Chrome'],
},
},
],
});# Run only API tests
npx playwright test --project=api
# Run only E2E tests
npx playwright test --project=e2e
# Run both
npx playwright test---
Related Resources
- playwright-patterns.md -- network interception and mocking patterns
- playwright-authentication.md -- API-based auth for test setup
- playwright-ci.md -- running API tests in CI pipelines
- SKILL.md -- parent Playwright testing skill
- Playwright API Testing
- Playwright APIRequestContext
- Ajv JSON Schema Validator
Playwright Authentication Patterns
Authentication patterns and session management in Playwright tests -- storageState, global setup, multi-user flows, OAuth handling, and API-based login for fast, reliable auth.
Contents
- StorageState Pattern
- Global Setup for Authentication
- Project Dependencies for Auth
- Multi-User Authentication
- Multi-Factor Auth Handling
- OAuth/OIDC Flow Testing
- API-Based Login
- Token Refresh Handling
- Session Expiry Testing
- Auth Fixture Patterns
- Persistent Auth Across Test Files
- Security Considerations
- Auth Pattern Decision Matrix
- Related Resources
---
StorageState Pattern
StorageState saves and restores cookies and localStorage, eliminating redundant login flows across tests.
Save Storage State
// auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import path from 'path';
const authFile = path.join(__dirname, '../.auth/user.json');
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('textbox', { name: 'Password' }).fill('secure-password');
await page.getByRole('button', { name: 'Sign in' }).click();
// Wait for redirect to confirm login succeeded
await page.waitForURL('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
// Save signed-in state
await page.context().storageState({ path: authFile });
});Load Storage State
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
// Setup project runs first
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
// All test projects depend on setup
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: '.auth/user.json',
},
dependencies: ['setup'],
},
],
});What StorageState Captures
| Captured | Not Captured |
|---|---|
| Cookies (all domains) | IndexedDB |
| localStorage (all origins) | sessionStorage |
| Service worker registrations | |
| In-memory state |
---
Global Setup for Authentication
When Global Setup Is Better Than StorageState
Use global setup when authentication requires actions outside the browser (API calls, database seeding).
// global-setup.ts
import { chromium, FullConfig } from '@playwright/test';
async function globalSetup(config: FullConfig) {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('http://localhost:3000/login');
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('textbox', { name: 'Password' }).fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
// Save for all workers
await context.storageState({ path: '.auth/user.json' });
await browser.close();
}
export default globalSetup;// playwright.config.ts
export default defineConfig({
globalSetup: require.resolve('./global-setup'),
use: {
storageState: '.auth/user.json',
},
});---
Project Dependencies for Auth
Project dependencies let you chain setup projects and ensure auth runs before tests.
// playwright.config.ts
export default defineConfig({
projects: [
// Auth setup
{
name: 'auth-setup',
testMatch: /auth\.setup\.ts/,
},
// Tests that need auth
{
name: 'authenticated-tests',
testMatch: /.*\.spec\.ts/,
dependencies: ['auth-setup'],
use: { storageState: '.auth/user.json' },
},
// Tests that run without auth (public pages)
{
name: 'public-tests',
testMatch: /.*\.public\.spec\.ts/,
// No dependencies, no storageState
},
],
});Dependency Chain for Complex Setups
// Setup: database seed → auth → tests
projects: [
{
name: 'db-seed',
testMatch: /db\.setup\.ts/,
},
{
name: 'auth-setup',
testMatch: /auth\.setup\.ts/,
dependencies: ['db-seed'],
},
{
name: 'e2e-tests',
dependencies: ['auth-setup'],
use: { storageState: '.auth/user.json' },
},
],---
Multi-User Authentication
Multiple Auth Files
// auth.setup.ts
import { test as setup } from '@playwright/test';
const users = [
{ name: 'admin', email: 'admin@example.com', password: 'admin-pass', file: '.auth/admin.json' },
{ name: 'regular', email: 'user@example.com', password: 'user-pass', file: '.auth/user.json' },
{ name: 'guest', email: 'guest@example.com', password: 'guest-pass', file: '.auth/guest.json' },
];
for (const user of users) {
setup(`authenticate as ${user.name}`, async ({ page }) => {
await page.goto('/login');
await page.getByRole('textbox', { name: 'Email' }).fill(user.email);
await page.getByRole('textbox', { name: 'Password' }).fill(user.password);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: user.file });
});
}Projects Per User Role
// playwright.config.ts
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'admin-tests',
testMatch: /.*\.admin\.spec\.ts/,
dependencies: ['setup'],
use: { storageState: '.auth/admin.json' },
},
{
name: 'user-tests',
testMatch: /.*\.user\.spec\.ts/,
dependencies: ['setup'],
use: { storageState: '.auth/user.json' },
},
{
name: 'guest-tests',
testMatch: /.*\.guest\.spec\.ts/,
dependencies: ['setup'],
use: { storageState: '.auth/guest.json' },
},
],
});Multi-User Within a Single Test
// collaboration.spec.ts
import { test } from '@playwright/test';
test('admin invites user to project', async ({ browser }) => {
// Admin context
const adminContext = await browser.newContext({
storageState: '.auth/admin.json',
});
const adminPage = await adminContext.newPage();
await adminPage.goto('/projects/123/settings');
await adminPage.getByRole('textbox', { name: 'Invite email' }).fill('user@example.com');
await adminPage.getByRole('button', { name: 'Send invite' }).click();
// User context - check for invitation
const userContext = await browser.newContext({
storageState: '.auth/user.json',
});
const userPage = await userContext.newPage();
await userPage.goto('/notifications');
await expect(userPage.getByText('You have been invited to Project 123')).toBeVisible();
// Cleanup
await adminContext.close();
await userContext.close();
});---
Multi-Factor Auth Handling
TOTP (Time-Based One-Time Password)
import { test as setup } from '@playwright/test';
import { authenticator } from 'otplib';
setup('authenticate with MFA', async ({ page }) => {
// Step 1: Username/password
await page.goto('/login');
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('textbox', { name: 'Password' }).fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
// Step 2: TOTP code
await page.waitForURL('/mfa');
const totpSecret = process.env.TEST_TOTP_SECRET!;
const code = authenticator.generate(totpSecret);
await page.getByRole('textbox', { name: 'Verification code' }).fill(code);
await page.getByRole('button', { name: 'Verify' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: '.auth/mfa-user.json' });
});SMS/Email MFA (Test Environment)
setup('authenticate with email MFA', async ({ page, request }) => {
await page.goto('/login');
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('textbox', { name: 'Password' }).fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/mfa');
// Retrieve MFA code from test API (backend must expose this in test env)
const codeResponse = await request.get('/api/test/mfa-code?email=user@example.com');
const { code } = await codeResponse.json();
await page.getByRole('textbox', { name: 'Verification code' }).fill(code);
await page.getByRole('button', { name: 'Verify' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: '.auth/user.json' });
});---
OAuth/OIDC Flow Testing
Strategy: Bypass OAuth UI in Tests
// RECOMMENDED: Use API-based auth to skip OAuth UI entirely
setup('authenticate via OAuth API', async ({ request }) => {
// Exchange test credentials for token directly with your backend
const response = await request.post('/api/auth/test-login', {
data: {
provider: 'google',
email: 'testuser@example.com',
testSecret: process.env.TEST_AUTH_SECRET,
},
});
const { token } = await response.json();
// Build storageState manually
const storageState = {
cookies: [
{
name: 'session',
value: token,
domain: 'localhost',
path: '/',
httpOnly: true,
secure: false,
sameSite: 'Lax' as const,
expires: Date.now() / 1000 + 86400,
},
],
origins: [],
};
const fs = await import('fs');
fs.writeFileSync('.auth/oauth-user.json', JSON.stringify(storageState));
});When You Must Test the Full OAuth Flow
// Only for OAuth provider integration validation (rare, flaky by nature)
test('full Google OAuth flow', async ({ page }) => {
await page.goto('/login');
await page.getByRole('button', { name: 'Sign in with Google' }).click();
// Google login page
await page.waitForURL(/accounts\.google\.com/);
await page.getByRole('textbox', { name: 'Email' }).fill(process.env.GOOGLE_TEST_EMAIL!);
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('textbox', { name: 'Password' }).fill(process.env.GOOGLE_TEST_PASSWORD!);
await page.getByRole('button', { name: 'Next' }).click();
// Consent screen (may not appear on subsequent logins)
const consentButton = page.getByRole('button', { name: 'Allow' });
if (await consentButton.isVisible({ timeout: 3000 }).catch(() => false)) {
await consentButton.click();
}
// Back to app
await page.waitForURL('/dashboard');
});---
API-Based Login
The fastest and most reliable auth approach. Bypass the login UI entirely.
Direct API Authentication
// fixtures/auth.fixture.ts
import { test as base, APIRequestContext } from '@playwright/test';
async function apiLogin(request: APIRequestContext, email: string, password: string): Promise<string> {
const response = await request.post('/api/auth/login', {
data: { email, password },
});
expect(response.ok()).toBeTruthy();
const { token } = await response.json();
return token;
}
// Use in setup
setup('API-based auth', async ({ request, browser }) => {
const token = await apiLogin(request, 'user@example.com', 'password');
// Create context with auth header
const context = await browser.newContext({
extraHTTPHeaders: {
Authorization: `Bearer ${token}`,
},
});
await context.storageState({ path: '.auth/user.json' });
await context.close();
});Cookie-Based API Auth
setup('API-based cookie auth', async ({ request }) => {
const response = await request.post('/api/auth/login', {
data: { email: 'user@example.com', password: 'password' },
});
// Cookies are automatically captured by the request context
// Save the state directly
await request.storageState({ path: '.auth/user.json' });
});---
Token Refresh Handling
Testing Token Expiry and Refresh
test('handles expired token gracefully', async ({ page, context }) => {
// Start authenticated
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
// Simulate expired token by clearing cookies
await context.clearCookies();
// Trigger a navigation that requires auth
await page.goto('/settings');
// App should redirect to login or refresh silently
// Option A: redirects to login
await expect(page).toHaveURL(/\/login/);
// Option B: refreshes token automatically
// await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
});
test('refresh token flow works', async ({ page }) => {
await page.goto('/dashboard');
// Intercept token refresh
let refreshCalled = false;
await page.route('**/api/auth/refresh', route => {
refreshCalled = true;
route.fulfill({
status: 200,
body: JSON.stringify({ token: 'new-valid-token', expiresIn: 3600 }),
});
});
// Simulate expiry by modifying cookie
await page.evaluate(() => {
document.cookie = 'access_token=expired; path=/; max-age=0';
});
// Trigger authenticated request
await page.getByRole('button', { name: 'Load data' }).click();
await expect(page.getByText('Data loaded')).toBeVisible();
expect(refreshCalled).toBe(true);
});---
Session Expiry Testing
test('session expiry shows re-login prompt', async ({ page, context }) => {
await page.goto('/dashboard');
// Simulate session expiry on the server side
await page.route('**/api/**', route => {
route.fulfill({ status: 401, body: JSON.stringify({ error: 'Session expired' }) });
});
// Trigger an API call
await page.getByRole('button', { name: 'Refresh' }).click();
// Expect session expiry UI
await expect(page.getByText('Your session has expired')).toBeVisible();
await expect(page.getByRole('button', { name: 'Sign in again' })).toBeVisible();
});
test('remember me extends session duration', async ({ page }) => {
await page.goto('/login');
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('textbox', { name: 'Password' }).fill('password');
await page.getByRole('checkbox', { name: 'Remember me' }).check();
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
// Verify long-lived cookie
const cookies = await page.context().cookies();
const sessionCookie = cookies.find(c => c.name === 'session');
expect(sessionCookie).toBeDefined();
// Remember-me cookie should expire in 30 days, not session-scoped
expect(sessionCookie!.expires).toBeGreaterThan(Date.now() / 1000 + 86400 * 7);
});---
Auth Fixture Patterns
Reusable Auth Fixture
// fixtures/auth.ts
import { test as base, Page, BrowserContext } from '@playwright/test';
type AuthFixtures = {
authenticatedPage: Page;
adminPage: Page;
};
export const test = base.extend<AuthFixtures>({
authenticatedPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: '.auth/user.json',
});
const page = await context.newPage();
await use(page);
await context.close();
},
adminPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: '.auth/admin.json',
});
const page = await context.newPage();
await use(page);
await context.close();
},
});
export { expect } from '@playwright/test';// Usage in tests
import { test, expect } from '../fixtures/auth';
test('admin can manage users', async ({ adminPage }) => {
await adminPage.goto('/admin/users');
await expect(adminPage.getByRole('heading', { name: 'User Management' })).toBeVisible();
});
test('regular user sees dashboard', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/dashboard');
await expect(authenticatedPage.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});---
Persistent Auth Across Test Files
.gitignore the Auth State
# .gitignore
.auth/Auth State Lifecycle
1. CI starts → auth.setup.ts runs → writes .auth/*.json
2. Test workers read .auth/*.json via storageState
3. Each worker gets its own browser context with pre-loaded auth
4. Tests run without re-authenticating
5. CI ends → .auth/ discarded (ephemeral)
Key: auth setup runs ONCE per CI run, not per test file.Handling Auth State Staleness
// Validate auth state before test suite
setup('validate auth state', async ({ page }) => {
// Load existing state
const authPath = '.auth/user.json';
const fs = await import('fs');
if (fs.existsSync(authPath)) {
const context = await page.context().browser()!.newContext({
storageState: authPath,
});
const checkPage = await context.newPage();
await checkPage.goto('/api/auth/me');
const response = await checkPage.evaluate(() => document.body.innerText);
if (response.includes('Unauthorized')) {
// State is stale, re-authenticate
await context.close();
// ... perform fresh login and save new state
} else {
await context.close();
}
}
});---
Security Considerations
Test Credentials Management
| Approach | Security | Convenience | Recommendation |
|---|---|---|---|
| Hardcoded in test files | Poor | High | Never in production repos |
.env files (git-ignored) | Moderate | High | Local development only |
| CI secrets (GitHub/GitLab) | Good | Moderate | Standard for CI |
| Vault / secrets manager | Best | Lower | Enterprise / regulated |
Credential Best Practices
// .env.test (git-ignored)
TEST_USER_EMAIL=test@example.com
TEST_USER_PASSWORD=test-password-123
TEST_ADMIN_EMAIL=admin@example.com
TEST_ADMIN_PASSWORD=admin-password-123
TEST_TOTP_SECRET=JBSWY3DPEHPK3PXP
// Usage in tests
const email = process.env.TEST_USER_EMAIL!;
const password = process.env.TEST_USER_PASSWORD!;Security Checklist
- [ ] Test credentials are never committed to version control
- [ ]
.auth/directory is in.gitignore - [ ] Test accounts have minimal permissions (principle of least privilege)
- [ ] Test accounts use separate database/tenant from production
- [ ] OAuth test accounts do not have access to real user data
- [ ] TOTP secrets for test MFA are stored in CI secrets, not code
- [ ] API test keys are scoped to test environment only
- [ ] StorageState files are treated as secrets (contain session tokens)
---
Auth Pattern Decision Matrix
| Scenario | Recommended Pattern | Why |
|---|---|---|
| Single user, simple login | StorageState + project deps | Simple, fast, reliable |
| Multiple user roles | Multiple auth files + projects | Clean separation |
| OAuth/SSO | API-based bypass | Avoids flaky third-party UI |
| MFA enabled | TOTP library + API fallback | Deterministic codes |
| Token refresh needed | Intercepted route tests | Controlled simulation |
| Session expiry testing | Cookie manipulation | Direct state control |
| High security / regulated | Vault + ephemeral credentials | Compliance requirement |
---
Related Resources
- playwright-patterns.md -- advanced Playwright patterns including fixtures and network interception
- playwright-ci.md -- CI/CD integration for authenticated test suites
- SKILL.md -- parent Playwright testing skill
- Playwright Auth Docs
- Playwright Storage State
- Playwright Project Dependencies
Playwright CI/CD Configurations
Production-ready CI/CD configurations for running Playwright tests in various environments.
---
GitHub Actions
Standard Configuration
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30Sharded Configuration (Parallel CI)
name: Playwright Tests (Sharded)
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test --shard=${{ matrix.shard }}/${{ strategy.job-total }}
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: blob-report-${{ matrix.shard }}
path: blob-report/
retention-days: 1
merge-reports:
if: ${{ !cancelled() }}
needs: [test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Download blob reports
uses: actions/download-artifact@v4
with:
path: all-blob-reports
pattern: blob-report-*
merge-multiple: true
- name: Merge reports
run: npx playwright merge-reports --reporter html ./all-blob-reports
- uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 30With Container Service (Database)
name: E2E Tests with Database
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- name: Run migrations
run: npx prisma migrate deploy
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
- name: Seed database
run: npx prisma db seed
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
- run: npx playwright install --with-deps
- name: Run E2E tests
run: npx playwright test
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
BASE_URL: http://localhost:3000---
GitLab CI
# .gitlab-ci.yml
stages:
- test
playwright:
stage: test
image: mcr.microsoft.com/playwright:v1.57.0-jammy
script:
- npm ci
- npx playwright test
artifacts:
when: always
paths:
- playwright-report/
expire_in: 1 week
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH---
Azure DevOps
# azure-pipelines.yml
trigger:
- main
pool:
vmImage: 'ubuntu-latest'
steps:
- task: NodeTool@0
inputs:
versionSpec: '22.x'
displayName: 'Install Node.js'
- script: npm ci
displayName: 'Install dependencies'
- script: npx playwright install --with-deps
displayName: 'Install Playwright browsers'
- script: npx playwright test
displayName: 'Run Playwright tests'
env:
CI: 'true'
- task: PublishTestResults@2
condition: succeededOrFailed()
inputs:
testResultsFiles: 'test-results/results.xml'
testRunTitle: 'Playwright Tests'
- publish: playwright-report
artifact: playwright-report
condition: succeededOrFailed()---
CircleCI
# .circleci/config.yml
version: 2.1
orbs:
node: circleci/node@5
jobs:
playwright:
docker:
- image: mcr.microsoft.com/playwright:v1.57.0-jammy
steps:
- checkout
- node/install-packages
- run:
name: Run Playwright tests
command: |
mkdir -p test-results
npx playwright test
- store_artifacts:
path: playwright-report
- store_test_results:
path: test-results
workflows:
test:
jobs:
- playwright---
Docker Configuration
Dockerfile for CI
# Dockerfile.playwright
FROM mcr.microsoft.com/playwright:v1.57.0-jammy
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npx", "playwright", "test"]Docker Compose for Local CI Simulation
# docker-compose.test.yml
version: '3.8'
services:
playwright:
build:
context: .
dockerfile: Dockerfile.playwright
environment:
- CI=true
- BASE_URL=http://app:3000
depends_on:
- app
- db
volumes:
- ./playwright-report:/app/playwright-report
app:
build: .
ports:
- '3000:3000'
environment:
- DATABASE_URL=postgresql://test:test@db:5432/testdb
depends_on:
- db
db:
image: postgres:15
environment:
- POSTGRES_USER=test
- POSTGRES_PASSWORD=test
- POSTGRES_DB=testdb---
playwright.config.ts for CI
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
// CI-specific settings
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { open: 'never' }],
['junit', { outputFile: 'test-results/results.xml' }],
...(process.env.CI ? [['github'] as const] : []),
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
// Capture traces on first retry
trace: 'on-first-retry',
// Screenshots and video on failure
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
// Start app server before tests
webServer: process.env.CI
? undefined // CI handles app startup separately
: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: true,
},
});---
Trace Viewer in CI
Always enable traces in CI for debugging failures:
// playwright.config.ts
use: {
trace: 'on-first-retry', // Captures trace on first retry
// Or for all failures:
trace: 'retain-on-failure',
}View traces locally:
# Download artifact and run
npx playwright show-trace trace.zip---
Playwright v1.57+ Features
Browser Channels (Chrome/Edge)
Playwright ships bundled browsers optimized for reliability. If you also want parity checks against stable Chrome/Edge, run an extra project using a browser channel:
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'chrome',
use: { ...devices['Desktop Chrome'], channel: 'chrome' },
},
{
name: 'edge',
use: { ...devices['Desktop Edge'], channel: 'msedge' },
},
],
});Speedboard (HTML Reporter)
New "Speedboard" tab shows all tests sorted by execution time:
# Generate report with speedboard
npx playwright test
npx playwright show-report
# Navigate to "Speedboard" tab to identify slow testswebServer wait Option
Wait for specific log output before starting tests:
// playwright.config.ts
export default defineConfig({
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
wait: /ready in \d+ms/, // Wait for this log pattern
},
});Fail CI on Rerun-Pass Flakes (Recommended)
Goal: keep retries in CI (to capture traces) but still fail if a test only passes on retry.
Implementation pattern: 1) Copy assets/template-playwright-fail-on-flaky-reporter.js into your repo (example: playwright/fail-on-flaky-reporter.js). 2) Register it in reporter:
// playwright.config.ts
export default defineConfig({
retries: 2,
reporter: [
['html', { open: 'never' }],
['junit', { outputFile: 'test-results/results.xml' }],
['./playwright/fail-on-flaky-reporter.js'],
],
});Service Worker Network Routing (Chromium)
Network requests from Service Workers are now routable:
test('intercept service worker requests', async ({ context }) => {
await context.route('**/api/**', route => {
route.fulfill({ status: 200, body: 'mocked' });
});
// Service Worker requests are now intercepted
});Opt out with: PLAYWRIGHT_DISABLE_SERVICE_WORKER_NETWORK=1
---
Real Device Testing (2026)
BrowserStack Integration
BrowserStack supports Playwright on real iOS devices:
// browserstack.config.ts
export default defineConfig({
use: {
connectOptions: {
wsEndpoint: `wss://cdp.browserstack.com/playwright?caps=${encodeURIComponent(JSON.stringify({
browser: 'playwright-webkit',
os: 'ios',
os_version: '17',
device: 'iPhone 15 Pro',
'browserstack.username': process.env.BROWSERSTACK_USERNAME,
'browserstack.accessKey': process.env.BROWSERSTACK_ACCESS_KEY,
}))}`,
},
},
});Supported:
Cloud providers support a rotating set of iOS versions and devices. Prefer the newest stable iOS Safari available in the provider's device list and pin the capabilities in CI.
LambdaTest Integration
// lambdatest.config.ts
const caps = {
browserName: 'webkit',
browserVersion: 'latest',
'LT:Options': {
platform: 'ios',
deviceName: 'iPhone 15',
isRealMobile: true,
},
};
export default defineConfig({
use: {
connectOptions: {
wsEndpoint: `wss://cdp.lambdatest.com/playwright?capabilities=${encodeURIComponent(JSON.stringify(caps))}`,
},
},
});Android Real Device via ADB
Connect to real Android devices:
import { _android as android } from 'playwright';
const [device] = await android.devices();
const context = await device.launchBrowser();
const page = await context.newPage();
await page.goto('https://example.com');
await expect(page).toHaveTitle(/Example/);
await context.close();
await device.close();Real Device Testing Decision Matrix
| Platform | Emulation | Real Device (Cloud) | Real Device (Local) |
|---|---|---|---|
| iOS Safari | WARNING: WebKit proxy | PASS BrowserStack/LambdaTest | FAIL Not supported |
| Android Chrome | PASS Full support | PASS Cloud providers | PASS ADB connection |
| Desktop | PASS Full support | PASS Cloud providers | PASS Local browsers |
When to use real devices:
- iOS Safari-specific bugs (WebKit emulation differs)
- Mobile-specific features (camera, GPS, push)
- Performance testing on actual hardware
- Compliance testing requiring real devices
---
Updated Docker Images
Use latest Playwright Docker images:
# GitLab CI
playwright:
image: mcr.microsoft.com/playwright:v1.57.0-jammy
# GitHub Actions
- name: Install Playwright
run: npx playwright install --with-deps
# Or use container
container:
image: mcr.microsoft.com/playwright:v1.57.0-jammy---
Related Resources
Playwright MCP & AI-Powered Testing (2026)
Model Context Protocol (MCP) integration for AI-driven browser automation and test generation.
---
Overview
Playwright MCP is a server that bridges Large Language Models (LLMs) with Playwright-managed browsers. It enables AI agents to control web interactions through structured accessibility snapshots rather than screenshots.
Key characteristics:
- Fast and lightweight (uses accessibility tree, not pixels)
- LLM-friendly (no vision models required)
- Deterministic tool application (structured data, not ambiguous screenshots)
Official repository: https://github.com/microsoft/playwright-mcp (package: @playwright/mcp)
---
How MCP Works
Accessibility Tree Approach
MCP operates on the browser's accessibility tree - a semantic, hierarchical representation of UI elements:
Snapshot mode includes:
- Roles (button, textbox, link, heading)
- Labels ("Submit", "Email address")
- States (disabled, checked, expanded)
- Hierarchy (parent-child relationships)This approach is more reliable than screenshot-based automation because:
- No visual noise or rendering differences
- Consistent across browsers and platforms
- Faster processing (no image analysis)
- Deterministic element identification
Architecture
LLM/agent <-> MCP server <-> Playwright (browser control)---
Agent Roles (Optional Pattern)
These are common roles you can implement in your own workflow when using MCP. Treat outputs as suggestions and always review diffs and assertions.
1. Planner
Explores the application and produces a Markdown test plan:
Input: "Test the checkout flow"
Output: Markdown plan with:
- User journey steps
- Expected assertions
- Edge cases to cover
- Data requirements2. Generator
Transforms Markdown plans into Playwright Test files:
// Generated from plan
import { test, expect } from '@playwright/test';
test('checkout flow - happy path', async ({ page }) => {
await page.goto('/cart');
await page.getByRole('button', { name: 'Checkout' }).click();
// ... generated steps
});3. Healer
Executes test suites and automatically repairs failing tests:
- Identifies broken locators
- Suggests updated selectors
- Fixes timing issues
- Adapts to UI changes
---
Integration with AI Tools
Claude Desktop
Configure in claude_desktop_config.json:
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["-y", "@playwright/mcp@latest"]
}
}
}IDE Agents (MCP-Capable)
For IDE agents that support MCP, configure the Playwright MCP server and ask the agent to use structured snapshots to explore flows, then convert the plan into hardened Playwright tests.
Cursor IDE (Example)
Add to Cursor's MCP configuration:
{
"playwright": {
"command": "npx",
"args": ["-y", "@playwright/mcp@latest"],
"env": {
"HEADLESS": "true"
}
}
}---
Self-Healing Tests
MCP enables AI-driven test maintenance:
Automatic Locator Updates
// Original (broken after UI refactor)
await page.locator('#old-submit-btn').click();
// AI-healed (using stable role locator)
await page.getByRole('button', { name: 'Submit' }).click();Adaptive Flow Detection
When UI flows change, MCP can: 1. Detect the failure pattern 2. Explore the new UI structure 3. Propose updated test steps 4. Validate the fix
---
Natural Language Test Creation
Example Workflow
Human: "Write a test that verifies users can add items to cart"
MCP Process:
1. Navigate to product listing
2. Inspect accessibility tree
3. Identify "Add to Cart" buttons
4. Execute action
5. Verify cart update
6. Generate Playwright test codeGenerated Output
import { test, expect } from '@playwright/test';
test('user can add item to cart', async ({ page }) => {
await page.goto('/products');
// Add first product
await page.getByRole('button', { name: /add to cart/i }).first().click();
// Verify cart badge updated
await expect(page.getByRole('status', { name: /cart/i })).toContainText('1');
// Verify cart contains item
await page.getByRole('link', { name: 'Cart' }).click();
await expect(page.getByRole('list', { name: 'Cart items' })).not.toBeEmpty();
});---
Best Practices
Do
- Use MCP for test scaffolding, then review and harden
- Leverage accessibility tree for stable locators
- Combine with human review for critical tests
- Use healer agent for maintenance, not blind trust
Avoid
- Auto-healing that weakens assertions
- Generating tests without understanding the flow
- Skipping code review of AI-generated tests
- Using MCP for security-sensitive test creation
---
Browser Installation
MCP auto-installs browsers on first use:
# Manual installation if needed
npx playwright install chromium
npx playwright install firefox
npx playwright install webkit---
Configuration Options
{
"playwright": {
"command": "npx",
"args": ["-y", "@playwright/mcp@latest"],
"env": {
"HEADLESS": "true",
"BROWSER": "chromium",
"VIEWPORT_WIDTH": "1280",
"VIEWPORT_HEIGHT": "720"
}
}
}---
Limitations
- Native mobile apps not supported (DOM-based only)
- Complex visual assertions require human verification
- AI suggestions need code review
- Not a replacement for test strategy thinking
---
Related Resources
Advanced Playwright Testing Patterns
Deep-dive reference for complex testing scenarios. Use alongside the main SKILL.md.
---
Role-Based Locators (2026 Best Practice)
Role locators are the recommended primary approach for element selection. They test from the user's perspective and are more resilient to implementation changes.
Priority Order
1. Role locators (primary) - getByRole() 2. Label/text locators - getByLabel(), getByText() 3. Test IDs (fallback) - getByTestId()
Examples
import { test, expect } from '@playwright/test';
test('login with role locators', async ({ page }) => {
await page.goto('/login');
// Primary: Role-based (preferred)
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('textbox', { name: 'Password' }).fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
// Assertions with role locators
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByRole('navigation')).toContainText('Welcome');
});
test('form interactions', async ({ page }) => {
await page.goto('/settings');
// Checkboxes and radios
await page.getByRole('checkbox', { name: 'Email notifications' }).check();
await page.getByRole('radio', { name: 'Dark mode' }).check();
// Dropdowns
await page.getByRole('combobox', { name: 'Language' }).selectOption('en');
// Links
await page.getByRole('link', { name: 'Privacy Policy' }).click();
});When to Use Test IDs
Use data-testid when:
- Element has no accessible role or label
- Multiple identical elements need distinction
- Dynamic content without stable text
// Fallback to test IDs for complex scenarios
await page.getByTestId('user-avatar-dropdown').click();
await page.getByTestId('chart-container').screenshot();---
Advanced Fixtures
Database Seeding Fixture
// fixtures/database.fixture.ts
import { test as base } from '@playwright/test';
import { prisma } from '../lib/prisma';
type DatabaseFixtures = {
seedUser: { id: string; email: string };
cleanupAfterTest: void;
};
export const test = base.extend<DatabaseFixtures>({
seedUser: async ({}, use) => {
// Create user before test
const user = await prisma.user.create({
data: {
email: `test-${Date.now()}@example.com`,
password: 'hashed_password',
},
});
await use({ id: user.id, email: user.email });
// Cleanup after test
await prisma.user.delete({ where: { id: user.id } });
},
cleanupAfterTest: [async ({}, use) => {
await use();
// Cleanup all test data
await prisma.user.deleteMany({
where: { email: { contains: 'test-' } },
});
}, { auto: true }],
});Storage State Fixture
// fixtures/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import path from 'path';
const authFile = path.join(__dirname, '../.auth/user.json');
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await page.getByRole('textbox', { name: 'Password' }).fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await page.context().storageState({ path: authFile });
});
// playwright.config.ts
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
dependencies: ['setup'],
use: { storageState: authFile },
},
],
});---
Network Interception Patterns
Conditional Mocking
test('mock only specific endpoints', async ({ page }) => {
// Mock analytics but let other requests through
await page.route('**/api/analytics/**', route => route.abort());
// Mock specific response
await page.route('**/api/feature-flags', route => {
route.fulfill({
status: 200,
body: JSON.stringify({ newFeature: true }),
});
});
// Let everything else pass
await page.goto('/');
});Request Modification
test('modify request headers', async ({ page }) => {
await page.route('**/api/**', route => {
route.continue({
headers: {
...route.request().headers(),
'X-Test-Mode': 'true',
'Authorization': 'Bearer test-token',
},
});
});
});Response Delay Simulation
test('handle slow network', async ({ page }) => {
await page.route('**/api/data', async route => {
await new Promise(resolve => setTimeout(resolve, 3000));
route.fulfill({
status: 200,
body: JSON.stringify({ data: 'loaded' }),
});
});
await page.goto('/data');
await expect(page.getByRole('progressbar')).toBeVisible();
await expect(page.getByText('loaded')).toBeVisible({ timeout: 5000 });
});---
Parallel Test Sharding
Local Sharding
# Split tests across 4 workers
npx playwright test --shard=1/4
npx playwright test --shard=2/4
npx playwright test --shard=3/4
npx playwright test --shard=4/4CI Sharding Matrix
# .github/workflows/playwright.yml
jobs:
test:
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- run: npx playwright test --shard=${{ matrix.shard }}/4---
Component Testing
// tests/components/Button.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from '../src/components/Button';
test('button renders with text', async ({ mount }) => {
const component = await mount(<Button>Click me</Button>);
await expect(component).toContainText('Click me');
});
test('button handles click', async ({ mount }) => {
let clicked = false;
const component = await mount(
<Button onClick={() => { clicked = true; }}>Click</Button>
);
await component.click();
expect(clicked).toBe(true);
});---
Accessibility Testing
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('page has no accessibility violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
test('form has proper labels', async ({ page }) => {
await page.goto('/signup');
const results = await new AxeBuilder({ page })
.include('form')
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
});---
Visual Testing Integration (2026)
Native Playwright Visual Testing
test('homepage visual regression', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png', {
maxDiffPixels: 100,
});
});Limitation: Headless Chrome renders differently across OS (Mac vs Linux CI). Consider third-party tools for cross-platform consistency.
Percy Integration
Percy by BrowserStack provides AI-powered visual diff detection:
// Install: npm install @percy/playwright
import { test } from '@playwright/test';
import percySnapshot from '@percy/playwright';
test('visual regression with Percy', async ({ page }) => {
await page.goto('/dashboard');
await percySnapshot(page, 'Dashboard');
});Percy Benefits:
- AI filters visual noise (animations, anti-aliasing)
- Cross-browser snapshots (Chrome, Firefox, Safari, Edge)
- CI/CD integration (GitHub Actions, CircleCI, Jenkins)
Chromatic Integration
Chromatic extends Playwright with single-import visual testing:
// Install: npm install chromatic @chromatic-com/playwright
import { test, expect } from '@chromatic-com/playwright';
test('visual test with Chromatic', async ({ page }) => {
await page.goto('/components');
// Chromatic captures automatically
});Run with:
npx chromatic --playwrightChromatic Benefits:
- Single import change transforms E2E into visual tests
- Parallel browser testing (Chrome, Firefox, Safari, Edge)
- Storybook integration for component-level testing
Visual Testing Decision Matrix
| Tool | Best For | Pricing | Integration Effort |
|---|---|---|---|
| Playwright native | Simple projects, single OS | Free | Minimal |
| Percy | Staging environments, cross-browser | $199+/mo | Low |
| Chromatic | Component libraries, Storybook users | Paid | Low |
| Lost Pixel | Open source alternative | Free/Paid | Medium |
---
WebSocket Mocking (v1.49+)
Intercept and mock WebSocket connections:
test('mock WebSocket messages', async ({ page }) => {
await page.routeWebSocket('wss://api.example.com/ws', ws => {
ws.onMessage(message => {
if (message === 'ping') {
ws.send('pong');
}
});
});
await page.goto('/realtime-dashboard');
await expect(page.getByText('Connected')).toBeVisible();
});
test('simulate WebSocket server messages', async ({ page }) => {
const wsRoute = await page.routeWebSocket('wss://api.example.com/ws', ws => {
// Send mock data after connection
setTimeout(() => {
ws.send(JSON.stringify({ type: 'update', data: { value: 42 } }));
}, 100);
});
await page.goto('/realtime-dashboard');
await expect(page.getByText('Value: 42')).toBeVisible();
});---
Playwright vs Cypress (2026 Comparison)
| Feature | Playwright | Cypress |
|---|---|---|
| Cross-browser | Chromium, Firefox, WebKit | Chrome, Firefox, Edge (no Safari) |
| Parallelization | Native, free | Requires Cypress Cloud |
| Language support | JS/TS, Python, Java, C# | JavaScript/TypeScript only |
| Mobile | Emulation + real devices (via cloud) | Limited emulation |
| Cross-origin | Seamless | Requires workarounds |
| Component testing | Experimental | Stable |
| AI/MCP integration | Official MCP server available | Limited |
| Speed | Fast (parallel workers) | Slower (single browser) |
| Learning curve | Moderate | Easy |
| Best for | Enterprise, multi-browser, CI scale | Small teams, JS-only, DX priority |
2026 Recommendation:
- Choose Playwright for cross-browser, multi-language, CI scalability
- Choose Cypress for JavaScript teams prioritizing developer experience
---
Aria Snapshots (v1.49+)
Enhanced accessibility snapshot properties:
test('verify link accessibility', async ({ page }) => {
await page.goto('/nav');
// New /url property for links
await expect(page.getByRole('link', { name: 'Home' })).toHaveAccessibleName('Home');
// New /children for strict matching
const nav = page.getByRole('navigation');
await expect(nav).toMatchAriaSnapshot(`
- navigation:
- /children:
- link "Home" /url: "/"
- link "About" /url: "/about"
- link "Contact" /url: "/contact"
`);
});---
Related Resources
Playwright Preflight: Sandbox, Port, Timeout, Lock
Use this preflight before running expensive E2E suites in local sandboxes or CI containers.
1) Verify Target Specs and Working Directory
pwd
rg --files e2e/tests | rg "<target-spec-or-pattern>"If spec paths are missing, stop and fix path assumptions first.
2) Port and Host Binding Check
# Replace 3000 with configured webServer port
lsof -nP -iTCP:3000 -sTCP:LISTENDecision rules:
EADDRINUSE: free the port or choose a different port.EPERM/EACCESbind errors: escalate immediately; do not retry loops.- If environment disallows
0.0.0.0, force127.0.0.1for local-only runs.
3) Build Lock and Stale Process Hygiene
# Find stale Next.js / test runners
ps aux | rg "next build|next dev|playwright"
# Inspect lock file
ls -la .next/lockIf lock exists and no active build owns it, remove lock and rerun.
4) Timeout Budget (Per-Test, Not Global)
Default policy:
- smoke tests: 30-60s/test
- API-heavy flows (generation/checkout/report): 90-180s/test
Apply timeout only to affected tests/steps. Avoid raising global timeout for the whole suite.
5) Escalation Decision
Escalate run permissions when all are true:
- failure is environment-level (
EPERM, restricted bind, blocked process inspection), and - command is required for requested validation, and
- no safe non-escalated alternative exists.
6) Execution Sequence
1. Reproduce one failing test with --workers=1. 2. Capture trace artifacts. 3. Fix determinism cause. 4. Rerun targeted suite. 5. Run broader regression last.
Visual Regression Testing with Playwright
Visual regression testing strategies -- native screenshots, threshold tuning, cross-platform baselines, third-party integrations, and CI workflows for catching unintended UI changes.
Contents
- Native toHaveScreenshot API
- Full-Page vs Element Screenshots
- Masking Dynamic Content
- Threshold Tuning
- Cross-Platform Baseline Management
- Third-Party Integrations
- Update Workflow
- CI Integration
- Responsive Visual Testing
- Dark Mode Visual Testing
- Component Visual Testing
- Anti-Patterns
- Related Resources
---
Native toHaveScreenshot API
Playwright's built-in visual comparison uses pixel-level diffing with configurable thresholds.
Basic Usage
import { test, expect } from '@playwright/test';
test('homepage visual regression', async ({ page }) => {
await page.goto('/');
// Wait for dynamic content to settle
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('homepage.png');
});
test('login form visual', async ({ page }) => {
await page.goto('/login');
const form = page.getByRole('form');
await expect(form).toHaveScreenshot('login-form.png');
});First Run: Generate Baselines
# Generate baseline screenshots (first time)
npx playwright test --update-snapshots
# Generated files:
# tests/homepage.spec.ts-snapshots/
# homepage-chromium-linux.png
# homepage-firefox-linux.png
# homepage-webkit-linux.pngConfiguration
// playwright.config.ts
export default defineConfig({
expect: {
toHaveScreenshot: {
maxDiffPixels: 50, // Allow up to 50 different pixels
maxDiffPixelRatio: 0.01, // Or 1% of total pixels
threshold: 0.2, // Per-pixel color difference (0-1)
animations: 'disabled', // Disable CSS animations
},
},
use: {
screenshot: 'only-on-failure', // Capture on test failure
},
});---
Full-Page vs Element Screenshots
Full-Page Screenshots
test('full page visual', async ({ page }) => {
await page.goto('/dashboard');
// Entire viewport
await expect(page).toHaveScreenshot('dashboard-viewport.png');
// Full scrollable page
await expect(page).toHaveScreenshot('dashboard-full.png', {
fullPage: true,
});
});Element-Level Screenshots
test('component visual regression', async ({ page }) => {
await page.goto('/components');
// Specific component
const card = page.getByTestId('pricing-card');
await expect(card).toHaveScreenshot('pricing-card.png');
// Navigation bar
const nav = page.getByRole('navigation');
await expect(nav).toHaveScreenshot('navigation.png');
// Footer
const footer = page.getByRole('contentinfo');
await expect(footer).toHaveScreenshot('footer.png');
});When to Use Each
| Approach | Best For | Drawbacks |
|---|---|---|
| Full viewport | Landing pages, marketing pages | Brittle with dynamic content |
| Full page (scrollable) | Long-form content, documentation | Large file size, slow comparison |
| Element-level | Components, forms, cards | Must identify right elements |
| Combined | Critical pages with dynamic areas masked | More test code |
---
Masking Dynamic Content
Mask elements that change between runs to prevent false positives.
Built-In Masking
test('dashboard with masked dynamic content', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveScreenshot('dashboard.png', {
mask: [
page.getByTestId('current-time'), // Clock/timestamp
page.getByTestId('user-avatar'), // Profile image
page.getByTestId('activity-feed'), // Live feed
page.getByRole('img', { name: /avatar/i }), // All avatar images
page.locator('.advertisement'), // Ads
],
maskColor: '#FF00FF', // Visible mask color for debugging
});
});CSS-Based Masking
test('mask via CSS injection', async ({ page }) => {
await page.goto('/dashboard');
// Hide dynamic elements via CSS
await page.addStyleTag({
content: `
.timestamp, .live-indicator, .random-greeting {
visibility: hidden !important;
}
.animated-element {
animation: none !important;
transition: none !important;
}
`,
});
await expect(page).toHaveScreenshot('dashboard-stable.png');
});Common Elements to Mask
ALWAYS MASK:
- Timestamps and clocks
- User avatars (may vary by test account)
- Live activity feeds
- Advertisements
- Random/rotating content (testimonials, tips)
- Analytics badges / counters
- Notification badges
CONSIDER MASKING:
- Charts with real-time data
- Maps (tile loading can vary)
- Video thumbnails
- Relative dates ("3 minutes ago")---
Threshold Tuning
Pixel-Level Thresholds
// Strict: exact match (component library, design system)
await expect(component).toHaveScreenshot('button.png', {
maxDiffPixels: 0,
threshold: 0.1,
});
// Moderate: small rendering differences allowed (most UI tests)
await expect(page).toHaveScreenshot('page.png', {
maxDiffPixels: 50,
threshold: 0.2,
});
// Lenient: layout check only (pages with variable content)
await expect(page).toHaveScreenshot('layout.png', {
maxDiffPixelRatio: 0.05, // 5% of pixels can differ
threshold: 0.3,
});Threshold Reference
| Parameter | Type | Range | Description |
|---|---|---|---|
maxDiffPixels | Absolute | 0-N | Max number of different pixels |
maxDiffPixelRatio | Relative | 0-1 | Max ratio of different pixels |
threshold | Per-pixel | 0-1 | Color difference sensitivity (0 = exact, 1 = any) |
Tuning Strategy
Start strict, loosen as needed:
1. Begin with maxDiffPixels: 0
2. Run tests 10x — note the max diff observed
3. Set threshold to 2x the observed max
4. If still flaky, investigate root cause before loosening further
Red flags for excessive loosening:
- maxDiffPixels > 500 → likely masking real issues
- maxDiffPixelRatio > 0.05 → screenshot probably too broad
- threshold > 0.3 → missing meaningful visual changes---
Cross-Platform Baseline Management
The OS Rendering Problem
Playwright screenshots differ across operating systems due to font rendering, anti-aliasing, and sub-pixel differences. The same page renders differently on Linux, macOS, and Windows.
Strategy 1: Platform-Specific Baselines (Default)
tests/visual.spec.ts-snapshots/
homepage-chromium-linux.png # CI baseline (Linux)
homepage-chromium-darwin.png # macOS baseline
homepage-chromium-win32.png # Windows baseline// Playwright auto-suffixes with platform
// No extra config needed — each OS gets its own baseline
await expect(page).toHaveScreenshot('homepage.png');
// Resolves to: homepage-chromium-{linux|darwin|win32}.pngStrategy 2: Docker for Consistent Baselines
# Dockerfile.playwright
FROM mcr.microsoft.com/playwright:v1.50.0-noble
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npx", "playwright", "test"]# Generate baselines in Docker (matches CI)
docker build -t playwright-tests -f Dockerfile.playwright .
docker run --rm -v $(pwd)/tests:/app/tests playwright-tests npx playwright test --update-snapshots
# Run tests locally in same environment
docker run --rm -v $(pwd):/app playwright-testsStrategy 3: CI-Only Visual Tests
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'visual-regression',
testMatch: /.*\.visual\.spec\.ts/,
// Only run in CI where baselines are generated
...(process.env.CI ? {} : { testIgnore: /.*/ }),
},
],
});---
Third-Party Integrations
Percy (BrowserStack)
npm install --save-dev @percy/cli @percy/playwrightimport { test } from '@playwright/test';
import percySnapshot from '@percy/playwright';
test('homepage Percy snapshot', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
await percySnapshot(page, 'Homepage', {
widths: [375, 768, 1280], // Responsive widths
minHeight: 1024, // Minimum capture height
percyCSS: '.ad-banner { display: none; }', // Hide dynamic elements
});
});# Run with Percy token
PERCY_TOKEN=your_token npx percy exec -- npx playwright testChromatic
npm install --save-dev chromatic @chromatic-com/playwright// Replace Playwright import with Chromatic wrapper
import { test, expect } from '@chromatic-com/playwright';
test('dashboard visual', async ({ page }) => {
await page.goto('/dashboard');
// Chromatic captures automatically at end of test
});# Run visual tests via Chromatic
npx chromatic --playwright -t your_project_tokenArgos CI
npm install --save-dev @argos-ci/playwrightimport { test } from '@playwright/test';
import { argosScreenshot } from '@argos-ci/playwright';
test('product page visual', async ({ page }) => {
await page.goto('/products/1');
await argosScreenshot(page, 'product-page');
});Tool Comparison
| Tool | Approach | Cross-Browser | Pricing | Review UI | CI Integration |
|---|---|---|---|---|---|
| Playwright native | Pixel diff | Per-project | Free | Diff files in PR | Manual |
| Percy | AI-powered diff | Chrome, Firefox, Safari, Edge | $199+/mo | Web dashboard | GitHub, GitLab, Bitbucket |
| Chromatic | TurboSnap (changed only) | Chrome, Firefox, Safari, Edge | Free tier + paid | Web dashboard | GitHub, GitLab |
| Argos CI | Pixel diff + stabilization | Per-project | Free OSS, paid | Web dashboard | GitHub |
| Lost Pixel | Pixel diff | Per-project | Free self-hosted | Web UI | GitHub Actions |
---
Update Workflow
Reviewing and Approving Diffs
# 1. Run tests — failures generate diff images
npx playwright test
# 2. Review diffs in test-results/
# Each failed screenshot produces:
# - expected.png (baseline)
# - actual.png (current)
# - diff.png (highlighted differences)
# 3. If changes are intentional, update baselines
npx playwright test --update-snapshots
# 4. Commit updated baselines
git add tests/**/*-snapshots/
git commit -m "Update visual baselines for redesigned header"PR Workflow
# .github/workflows/visual-tests.yml
name: Visual Regression
on: pull_request
jobs:
visual:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npx playwright install --with-deps
- name: Run visual tests
run: npx playwright test --project=visual-regression
continue-on-error: true
- name: Upload diff artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: visual-diffs
path: test-results/
retention-days: 7
- name: Comment PR with diff summary
if: failure()
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '**Visual regression detected.** Download diff artifacts to review changes. If intentional, run `npx playwright test --update-snapshots` and commit updated baselines.'
})---
CI Integration
Baselines in Git vs Artifact Storage
| Approach | Pros | Cons |
|---|---|---|
| Git (recommended) | Versioned, reviewable in PR, simple | Increases repo size |
| Artifact storage (S3, GCS) | Small repo | Complex setup, harder to review |
| Git LFS | Best of both | Requires LFS setup |
Git LFS for Screenshots
# Track screenshot baselines with Git LFS
git lfs track "tests/**/*-snapshots/*.png"
git add .gitattributes
git commit -m "Track visual baselines with Git LFS"Blob Report with Visual Diffs
// playwright.config.ts
export default defineConfig({
reporter: [
['html', { open: 'never' }],
['blob'], // Merge sharded results including screenshots
],
});---
Responsive Visual Testing
Multiple Viewports
const viewports = [
{ name: 'mobile', width: 375, height: 812 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1280, height: 800 },
{ name: 'wide', width: 1920, height: 1080 },
];
for (const vp of viewports) {
test(`homepage at ${vp.name} (${vp.width}x${vp.height})`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.goto('/');
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot(`homepage-${vp.name}.png`);
});
}Project-Based Viewports
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'mobile-visual',
testMatch: /.*\.visual\.spec\.ts/,
use: { ...devices['iPhone 14'], },
},
{
name: 'tablet-visual',
testMatch: /.*\.visual\.spec\.ts/,
use: { ...devices['iPad Pro 11'], },
},
{
name: 'desktop-visual',
testMatch: /.*\.visual\.spec\.ts/,
use: { viewport: { width: 1280, height: 800 } },
},
],
});---
Dark Mode Visual Testing
test('supports dark mode', async ({ page }) => {
await page.goto('/');
// Light mode baseline
await page.emulateMedia({ colorScheme: 'light' });
await expect(page).toHaveScreenshot('homepage-light.png');
// Dark mode baseline
await page.emulateMedia({ colorScheme: 'dark' });
await expect(page).toHaveScreenshot('homepage-dark.png');
});
test('high contrast mode', async ({ page }) => {
await page.emulateMedia({ forcedColors: 'active' });
await page.goto('/');
await expect(page).toHaveScreenshot('homepage-high-contrast.png');
});---
Component Visual Testing
With Playwright Component Testing
// tests/components/Button.visual.spec.tsx
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from '../../src/components/Button';
const variants = ['primary', 'secondary', 'danger', 'ghost'] as const;
const sizes = ['sm', 'md', 'lg'] as const;
for (const variant of variants) {
for (const size of sizes) {
test(`Button ${variant} ${size}`, async ({ mount }) => {
const component = await mount(
<Button variant={variant} size={size}>Click me</Button>
);
await expect(component).toHaveScreenshot(`button-${variant}-${size}.png`);
});
}
}
test('Button states', async ({ mount }) => {
const component = await mount(<Button disabled>Disabled</Button>);
await expect(component).toHaveScreenshot('button-disabled.png');
});---
Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
| Full-page screenshots of dynamic dashboards | Constant false positives | Element-level screenshots + masking |
No waitForLoadState before screenshot | Captures loading states | Always wait for networkidle or specific element |
| Same baseline for all OS | Cross-platform rendering diffs | Platform-specific baselines or Docker |
| Overly loose thresholds | Misses real regressions | Start strict, loosen intentionally |
| Visual tests blocking every PR | Slow feedback, developer friction | Run visual suite on schedule, smoke on PR |
| No review process for baseline updates | Regressions slip in as "updates" | Require reviewer approval for baseline changes |
---
Related Resources
- playwright-patterns.md -- visual testing integration overview and Percy/Chromatic patterns
- playwright-ci.md -- CI setup for visual test suites
- playwright-authentication.md -- authenticated visual testing
- SKILL.md -- parent Playwright testing skill
- Playwright Visual Comparisons
- Percy Playwright SDK
- Chromatic Playwright
- Argos CI
- Lost Pixel
Related skills
How it compares
Choose this over generic test-writing prompts when the goal is Playwright-specific E2E browser automation executed inside the agent session.
FAQ
What does qa-testing-playwright automate?
qa-testing-playwright automates end-to-end browser testing with Playwright from AI coding agent sessions. Developers use it to create and run UI flow tests in real browsers, catching regressions before release without leaving the agent workflow.
How popular is qa-testing-playwright on skills.sh?
qa-testing-playwright from vasilyu1983/ai-agents-public shows 456 installs and rank 6 on skills.sh. That catalog placement reflects strong adoption among developers running Playwright E2E tests through agent tooling.