chore: add playwright cursor skill

This commit is contained in:
Anish Sarkar 2026-05-10 04:19:55 +05:30
parent 25aad38ca4
commit d52225c18d
57 changed files with 25244 additions and 0 deletions

View file

@ -0,0 +1,424 @@
# Test Annotations & Organization
## Table of Contents
1. [Skip Annotations](#skip-annotations)
2. [Fixme & Fail Annotations](#fixme--fail-annotations)
3. [Slow Tests](#slow-tests)
4. [Test Steps](#test-steps)
5. [Custom Annotations](#custom-annotations)
6. [Conditional Annotations](#conditional-annotations)
## Skip Annotations
### Basic Skip
```typescript
// Skip unconditionally
test.skip("feature not implemented", async ({ page }) => {
// This test won't run
});
// Skip with reason
test("payment flow", async ({ page }) => {
test.skip(true, "Payment gateway in maintenance");
// Test body won't execute
});
```
### Conditional Skip
```typescript
test("webkit-specific feature", async ({ page, browserName }) => {
test.skip(browserName !== "webkit", "This feature only works in WebKit");
await page.goto("/webkit-feature");
});
test("production only", async ({ page }) => {
test.skip(process.env.ENV !== "production", "Only runs against production");
await page.goto("/prod-feature");
});
```
### Skip by Platform
```typescript
test("windows-specific", async ({ page }) => {
test.skip(process.platform !== "win32", "Windows only");
});
test("not on CI", async ({ page }) => {
test.skip(!!process.env.CI, "Skipped in CI environment");
});
```
### Skip Describe Block
```typescript
test.describe("Admin features", () => {
test.skip(
({ browserName }) => browserName === "firefox",
"Firefox admin bug",
);
test("admin dashboard", async ({ page }) => {
// Skipped in Firefox
});
test("admin settings", async ({ page }) => {
// Skipped in Firefox
});
});
```
## Fixme & Fail Annotations
### Fixme - Known Issues
```typescript
// Mark test as needing fix (skips the test)
test.fixme("broken after refactor", async ({ page }) => {
// Test won't run but is tracked
});
// Conditional fixme
test("flaky on CI", async ({ page }) => {
test.fixme(!!process.env.CI, "Investigate CI flakiness - ticket #123");
await page.goto("/flaky-feature");
});
```
### Fail - Expected Failures
```typescript
// Test is expected to fail (runs but expects failure)
test("known bug", async ({ page }) => {
test.fail();
await page.goto("/buggy-page");
// If this passes, the test fails (bug was fixed!)
await expect(page.getByText("Working")).toBeVisible();
});
// Conditional fail
test("fails on webkit", async ({ page, browserName }) => {
test.fail(browserName === "webkit", "WebKit rendering bug #456");
await page.goto("/render-test");
await expect(page.getByTestId("element")).toHaveCSS("width", "100px");
});
```
### Difference Between Skip, Fixme, Fail
| Annotation | Runs? | Use Case |
| -------------- | ----- | -------------------------------- |
| `test.skip()` | No | Feature not applicable |
| `test.fixme()` | No | Known bug, needs investigation |
| `test.fail()` | Yes | Expected to fail, tracking a bug |
## Slow Tests
### Mark Slow Tests
```typescript
// Triple the default timeout
test("large data import", async ({ page }) => {
test.slow();
await page.goto("/import");
await page.setInputFiles("#file", "large-file.csv");
await page.getByRole("button", { name: "Import" }).click();
await expect(page.getByText("Import complete")).toBeVisible();
});
// Conditional slow
test("video processing", async ({ page, browserName }) => {
test.slow(browserName === "webkit", "WebKit video processing is slow");
await page.goto("/video-editor");
});
```
### Custom Timeout
```typescript
test("very long operation", async ({ page }) => {
// Set specific timeout (in milliseconds)
test.setTimeout(120000); // 2 minutes
await page.goto("/long-operation");
});
// Timeout for describe block
test.describe("Integration tests", () => {
test.describe.configure({ timeout: 60000 });
test("test 1", async ({ page }) => {
// Has 60 second timeout
});
});
```
## Test Steps
### Basic Steps
```typescript
test("checkout flow", async ({ page }) => {
await test.step("Add item to cart", async () => {
await page.goto("/products");
await page.getByRole("button", { name: "Add to Cart" }).click();
});
await test.step("Go to checkout", async () => {
await page.getByRole("link", { name: "Cart" }).click();
await page.getByRole("button", { name: "Checkout" }).click();
});
await test.step("Fill shipping info", async () => {
await page.getByLabel("Address").fill("123 Test St");
await page.getByLabel("City").fill("Test City");
});
await test.step("Complete payment", async () => {
await page.getByLabel("Card").fill("4242424242424242");
await page.getByRole("button", { name: "Pay" }).click();
});
await expect(page.getByText("Order confirmed")).toBeVisible();
});
```
### Nested Steps
```typescript
test("user registration", async ({ page }) => {
await test.step("Fill registration form", async () => {
await page.goto("/register");
await test.step("Personal info", async () => {
await page.getByLabel("Name").fill("John Doe");
await page.getByLabel("Email").fill("john@example.com");
});
await test.step("Security", async () => {
await page.getByLabel("Password").fill("SecurePass123");
await page.getByLabel("Confirm Password").fill("SecurePass123");
});
});
await test.step("Submit and verify", async () => {
await page.getByRole("button", { name: "Register" }).click();
await expect(page.getByText("Welcome")).toBeVisible();
});
});
```
### Steps with Return Values
```typescript
test("verify order", async ({ page }) => {
const orderId = await test.step("Create order", async () => {
await page.goto("/checkout");
await page.getByRole("button", { name: "Place Order" }).click();
// Return value from step
return await page.getByTestId("order-id").textContent();
});
await test.step("Verify order details", async () => {
await page.goto(`/orders/${orderId}`);
await expect(page.getByText(`Order #${orderId}`)).toBeVisible();
});
});
```
### Step in Page Object
```typescript
// pages/checkout.page.ts
export class CheckoutPage {
async fillShippingInfo(address: string, city: string) {
await test.step("Fill shipping information", async () => {
await this.page.getByLabel("Address").fill(address);
await this.page.getByLabel("City").fill(city);
});
}
async completePayment(cardNumber: string) {
await test.step("Complete payment", async () => {
await this.page.getByLabel("Card").fill(cardNumber);
await this.page.getByRole("button", { name: "Pay" }).click();
});
}
}
```
## Custom Annotations
### Add Annotations
```typescript
test("important feature", async ({ page }, testInfo) => {
// Add custom annotation
testInfo.annotations.push({
type: "priority",
description: "high",
});
testInfo.annotations.push({
type: "ticket",
description: "JIRA-123",
});
await page.goto("/feature");
});
```
### Annotation Fixture
```typescript
// fixtures/annotations.fixture.ts
import { test as base, TestInfo } from "@playwright/test";
type AnnotationFixtures = {
annotate: {
ticket: (id: string) => void;
priority: (level: "low" | "medium" | "high") => void;
owner: (name: string) => void;
};
};
export const test = base.extend<AnnotationFixtures>({
annotate: async ({}, use, testInfo) => {
await use({
ticket: (id) => {
testInfo.annotations.push({ type: "ticket", description: id });
},
priority: (level) => {
testInfo.annotations.push({ type: "priority", description: level });
},
owner: (name) => {
testInfo.annotations.push({ type: "owner", description: name });
},
});
},
});
// Usage
test("critical feature", async ({ page, annotate }) => {
annotate.ticket("JIRA-456");
annotate.priority("high");
annotate.owner("Alice");
await page.goto("/critical");
});
```
### Read Annotations in Reporter
```typescript
// reporters/annotation-reporter.ts
import { Reporter, TestCase, TestResult } from "@playwright/test/reporter";
class AnnotationReporter implements Reporter {
onTestEnd(test: TestCase, result: TestResult) {
const ticket = test.annotations.find((a) => a.type === "ticket");
const priority = test.annotations.find((a) => a.type === "priority");
if (ticket) {
console.log(`Test linked to: ${ticket.description}`);
}
if (priority?.description === "high" && result.status === "failed") {
console.log(`HIGH PRIORITY FAILURE: ${test.title}`);
}
}
}
export default AnnotationReporter;
```
## Conditional Annotations
### Annotation Helper
```typescript
// helpers/test-annotations.ts
import { test } from "@playwright/test";
export function skipInCI(reason = "Skipped in CI") {
test.skip(!!process.env.CI, reason);
}
export function skipInBrowser(browser: string, reason: string) {
test.beforeEach(({ browserName }) => {
test.skip(browserName === browser, reason);
});
}
export function onlyInEnv(env: string) {
test.skip(process.env.ENV !== env, `Only runs in ${env}`);
}
```
```typescript
// tests/feature.spec.ts
import { skipInCI, onlyInEnv } from "../helpers/test-annotations";
test("local only feature", async ({ page }) => {
skipInCI("Uses local resources");
await page.goto("/local-feature");
});
test("production check", async ({ page }) => {
onlyInEnv("production");
await page.goto("/prod-only");
});
```
### Describe-Level Conditions
```typescript
test.describe("Mobile features", () => {
test.beforeEach(({ isMobile }) => {
test.skip(!isMobile, "Mobile only tests");
});
test("touch gestures", async ({ page }) => {
// Only runs on mobile
});
});
test.describe("Desktop features", () => {
test.beforeEach(({ isMobile }) => {
test.skip(isMobile, "Desktop only tests");
});
test("hover interactions", async ({ page }) => {
// Only runs on desktop
});
});
```
## Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
| --------------------------- | ---------------------- | -------------------------------- |
| Skipping without reason | Hard to track why | Always provide description |
| Too many skipped tests | Test debt accumulates | Review and clean up regularly |
| Using skip instead of fixme | Loses intent | Use fixme for bugs, skip for N/A |
| Not using steps | Hard to debug failures | Group logical actions in steps |
## Related References
- **Test Tags**: See [test-tags.md](test-tags.md) for tagging and filtering tests with `--grep`
- **Test Organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring tests
- **Debugging**: See [debugging.md](../debugging/debugging.md) for troubleshooting

View file

@ -0,0 +1,361 @@
# Assertions & Waiting
## Table of Contents
1. [Web-First Assertions](#web-first-assertions)
2. [Generic Assertions](#generic-assertions)
3. [Soft Assertions](#soft-assertions)
4. [Waiting Strategies](#waiting-strategies)
5. [Polling & Retrying](#polling--retrying)
6. [Custom Matchers](#custom-matchers)
## Web-First Assertions
Auto-retry until condition is met or timeout. Always prefer these over generic assertions.
### Locator Assertions
```typescript
import { expect } from "@playwright/test";
// Visibility
await expect(page.getByRole("button")).toBeVisible();
await expect(page.getByRole("button")).toBeHidden();
await expect(page.getByRole("button")).not.toBeVisible();
// Enabled/Disabled
await expect(page.getByRole("button")).toBeEnabled();
await expect(page.getByRole("button")).toBeDisabled();
// Text content
await expect(page.getByRole("heading")).toHaveText("Welcome");
await expect(page.getByRole("heading")).toHaveText(/welcome/i);
await expect(page.getByRole("heading")).toContainText("Welcome");
// Count
await expect(page.getByRole("listitem")).toHaveCount(5);
// Attributes
await expect(page.getByRole("link")).toHaveAttribute("href", "/home");
await expect(page.getByRole("img")).toHaveAttribute("alt", /logo/i);
// CSS
await expect(page.getByRole("button")).toHaveClass(/primary/);
await expect(page.getByRole("button")).toHaveCSS("color", "rgb(0, 0, 255)");
// Input values
await expect(page.getByLabel("Email")).toHaveValue("user@example.com");
await expect(page.getByLabel("Email")).toBeEmpty();
// Focus
await expect(page.getByLabel("Email")).toBeFocused();
// Checked state
await expect(page.getByRole("checkbox")).toBeChecked();
await expect(page.getByRole("checkbox")).not.toBeChecked();
// Editable state
await expect(page.getByLabel("Name")).toBeEditable();
```
### Page Assertions
```typescript
// URL
await expect(page).toHaveURL("/dashboard");
await expect(page).toHaveURL(/\/dashboard/);
// Title
await expect(page).toHaveTitle("Dashboard - MyApp");
await expect(page).toHaveTitle(/dashboard/i);
```
### Response Assertions
```typescript
const response = await page.request.get("/api/users");
await expect(response).toBeOK();
await expect(response).not.toBeOK();
```
## Generic Assertions
Use for non-UI values. Do NOT retry - execute immediately.
```typescript
// Equality
expect(value).toBe(5);
expect(object).toEqual({ name: "Test" });
expect(array).toContain("item");
// Truthiness
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
// Numbers
expect(value).toBeGreaterThan(5);
expect(value).toBeLessThanOrEqual(10);
expect(value).toBeCloseTo(5.5, 1);
// Strings
expect(string).toMatch(/pattern/);
expect(string).toContain("substring");
// Arrays/Objects
expect(array).toHaveLength(3);
expect(object).toHaveProperty("key", "value");
// Exceptions
expect(() => fn()).toThrow();
expect(() => fn()).toThrow("error message");
await expect(asyncFn()).rejects.toThrow();
```
## Soft Assertions
Continue test execution after failure, report all failures at end.
```typescript
test("check multiple elements", async ({ page }) => {
await page.goto("/dashboard");
// Won't stop on first failure
await expect.soft(page.getByRole("heading")).toHaveText("Dashboard");
await expect.soft(page.getByRole("button", { name: "Save" })).toBeEnabled();
await expect.soft(page.getByText("Welcome")).toBeVisible();
// Test continues; all failures reported at end
});
```
### Soft Assertions with Early Exit
```typescript
test("check form", async ({ page }) => {
await expect.soft(page.getByRole("form")).toBeVisible();
// Exit early if form not visible (pointless to check fields)
if (expect.soft.hasFailures()) {
return;
}
await expect.soft(page.getByLabel("Name")).toBeVisible();
await expect.soft(page.getByLabel("Email")).toBeVisible();
});
```
## Waiting Strategies
### Auto-Waiting (Default)
Actions automatically wait for:
- Element to be attached to DOM
- Element to be visible
- Element to be stable (no animations)
- Element to be enabled
- Element to receive events
```typescript
// These auto-wait
await page.click("button");
await page.fill("input", "text");
await page.getByRole("button").click();
```
### Wait for Navigation
```typescript
// Wait for URL change
await page.waitForURL("/dashboard");
await page.waitForURL(/\/dashboard/);
// Wait for navigation after action
await Promise.all([
page.waitForURL("**/dashboard"),
page.click('a[href="/dashboard"]'),
]);
// Or without Promise.all
const urlPromise = page.waitForURL("**/dashboard");
await page.click("a");
await urlPromise;
```
### Wait for Network
```typescript
// Wait for specific response
const responsePromise = page.waitForResponse("**/api/users");
await page.click("button");
const response = await responsePromise;
expect(response.status()).toBe(200);
// Wait for request
const requestPromise = page.waitForRequest("**/api/submit");
await page.click("button");
const request = await requestPromise;
// Wait for no network activity
await page.waitForLoadState("networkidle");
```
### Wait for Element State
```typescript
// Wait for element to appear
await page.getByRole("dialog").waitFor({ state: "visible" });
// Wait for element to disappear
await page.getByText("Loading...").waitFor({ state: "hidden" });
// Wait for element to be attached
await page.getByTestId("result").waitFor({ state: "attached" });
// Wait for element to be detached
await page.getByTestId("modal").waitFor({ state: "detached" });
```
### Wait for Function
```typescript
// Wait for arbitrary condition
await page.waitForFunction(() => {
return document.querySelector(".loaded") !== null;
});
// With arguments
await page.waitForFunction(
(selector) => document.querySelector(selector)?.textContent === "Ready",
".status",
);
```
## Polling & Retrying
### toPass() for Polling
Retry until block passes or times out:
```typescript
await expect(async () => {
const response = await page.request.get("/api/status");
expect(response.status()).toBe(200);
const data = await response.json();
expect(data.ready).toBe(true);
}).toPass({
intervals: [1000, 2000, 5000], // Retry intervals
timeout: 30000,
});
```
### expect.poll()
Poll a function until assertion passes:
```typescript
// Poll API until condition met
await expect
.poll(
async () => {
const response = await page.request.get("/api/job/123");
return (await response.json()).status;
},
{
intervals: [1000, 2000, 5000],
timeout: 30000,
},
)
.toBe("completed");
// Poll DOM value
await expect.poll(() => page.getByTestId("counter").textContent()).toBe("10");
```
## Custom Matchers
```typescript
// playwright.config.ts or fixtures
import { expect } from "@playwright/test";
expect.extend({
async toHaveDataLoaded(page: Page) {
const locator = page.getByTestId("data-container");
let pass = false;
let message = "";
try {
await expect(locator).toBeVisible();
await expect(locator).not.toContainText("Loading");
pass = true;
} catch (e) {
message = `Expected data to be loaded but found loading state`;
}
return { pass, message: () => message };
},
});
// Extend TypeScript types
declare global {
namespace PlaywrightTest {
interface Matchers<R> {
toHaveDataLoaded(): Promise<R>;
}
}
}
// Usage
await expect(page).toHaveDataLoaded();
```
## Timeouts
### Configure Timeouts
```typescript
// playwright.config.ts
export default defineConfig({
timeout: 30000, // Test timeout
expect: {
timeout: 5000, // Assertion timeout
},
});
// Per-test timeout
test("long test", async ({ page }) => {
test.setTimeout(60000);
// ...
});
// Per-assertion timeout
await expect(page.getByRole("button")).toBeVisible({ timeout: 10000 });
```
## Best Practices
| Do | Don't |
| ------------------------------ | ------------------------------ |
| Use web-first assertions | Use generic assertions for DOM |
| Let auto-waiting work | Add unnecessary explicit waits |
| Use `toPass()` for polling | Write manual retry loops |
| Configure appropriate timeouts | Use `waitForTimeout()` |
| Check specific conditions | Wait for arbitrary time |
## Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
| --------------------------------------------------------- | ----------------------------- | -------------------------------------------- |
| `await page.waitForTimeout(5000)` | Slow, flaky, arbitrary timing | Use auto-waiting or `waitForResponse` |
| `await new Promise(resolve => setTimeout(resolve, 1000))` | Same as above | Use `waitForResponse` or element state waits |
| Generic assertions on DOM elements | No auto-retry, flaky | Use web-first assertions with `expect()` |
## Related References
- **Debugging timeout issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting
- **Fixing flaky tests**: See [debugging.md](../debugging/debugging.md) for race condition solutions
- **Network interception**: See [test-suite-structure.md](test-suite-structure.md) for API mocking

View file

@ -0,0 +1,452 @@
# Playwright Configuration
## Table of Contents
1. [CLI Quick Reference](#cli-quick-reference)
2. [Decision Guide](#decision-guide)
3. [Production-Ready Config](#production-ready-config)
4. [Patterns](#patterns)
5. [Anti-Patterns](#anti-patterns)
6. [Troubleshooting](#troubleshooting)
7. [Related](#related)
> **When to use**: Setting up a new project, adjusting timeouts, adding browser targets, configuring CI behavior, or managing environment-specific settings.
## CLI Quick Reference
```bash
npx playwright init # scaffold config + first test
npx playwright test --config=custom.config.ts # use alternate config
npx playwright test --project=chromium # run single project
npx playwright test --reporter=html # override reporter
npx playwright test --grep @smoke # run tests tagged @smoke
npx playwright test --grep-invert @slow # exclude @slow tests
npx playwright show-report # open last HTML report
DEBUG=pw:api npx playwright test # verbose logging
```
## Decision Guide
### Timeout Selection
| Symptom | Setting | Default | Recommended |
|---------|---------|---------|-------------|
| Test takes too long overall | `timeout` | 30s | 30-60s (max 120s) |
| Assertion retries too long/short | `expect.timeout` | 5s | 5-10s |
| `page.goto()` or `waitForURL()` times out | `navigationTimeout` | 30s | 10-30s |
| `click()`, `fill()` time out | `actionTimeout` | 0 (unlimited) | 10-15s |
| Dev server slow to start | `webServer.timeout` | 60s | 60-180s |
### Server Management
| Scenario | Approach |
|----------|----------|
| App in same repo | `webServer` with `reuseExistingServer: !process.env.CI` |
| Separate repos | Manual start or Docker Compose |
| Testing deployed environment | No `webServer`; set `baseURL` via env |
| Multiple services | Array of `webServer` entries |
### Single vs Multi-Project
| Scenario | Approach |
|----------|----------|
| Early development | Single project (chromium only) |
| Pre-release validation | Multi-project: chromium + firefox + webkit |
| Mobile-responsive app | Add mobile projects alongside desktop |
| Auth + non-auth tests | Setup project with dependencies |
| Tight CI budget | Chromium on PRs; all browsers on main |
### globalSetup vs Setup Projects vs Fixtures
| Need | Use |
|------|-----|
| One-time DB seed | `globalSetup` |
| Shared browser auth | Setup project with `dependencies` |
| Per-test isolated state | Custom fixture via `test.extend()` |
| Cleanup after all tests | `globalTeardown` |
## Production-Ready Config
```ts
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';
dotenv.config({ path: path.resolve(__dirname, '.env') });
export default defineConfig({
testDir: './e2e',
testMatch: '**/*.spec.ts',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? '50%' : undefined,
reporter: process.env.CI
? [['html', { open: 'never' }], ['github']]
: [['html', { open: 'on-failure' }]],
timeout: 30_000,
expect: { timeout: 5_000 },
use: {
baseURL: process.env.BASE_URL || 'http://localhost:4000',
actionTimeout: 10_000,
navigationTimeout: 15_000,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
locale: 'en-US',
timezoneId: 'America/Los_Angeles',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
{ name: 'mobile-safari', use: { ...devices['iPhone 14'] } },
],
webServer: {
command: 'npm run start',
url: 'http://localhost:4000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
stdout: 'pipe',
stderr: 'pipe',
},
});
```
## Patterns
### Environment-Specific Configuration
**Use when**: Tests run against dev, staging, and production environments.
```ts
// playwright.config.ts
import { defineConfig } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';
const ENV = process.env.TEST_ENV || 'local';
dotenv.config({ path: path.resolve(__dirname, `.env.${ENV}`) });
const envConfig: Record<string, { baseURL: string; retries: number }> = {
local: { baseURL: 'http://localhost:4000', retries: 0 },
staging: { baseURL: 'https://staging.myapp.com', retries: 2 },
prod: { baseURL: 'https://myapp.com', retries: 2 },
};
export default defineConfig({
testDir: './e2e',
retries: envConfig[ENV].retries,
use: { baseURL: envConfig[ENV].baseURL },
});
```
```bash
TEST_ENV=staging npx playwright test
TEST_ENV=prod npx playwright test --grep @smoke
```
### Setup Project with Dependencies
**Use when**: Tests need shared authentication state before running.
```ts
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
projects: [
{
name: 'setup',
testMatch: /auth\.setup\.ts/,
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/session.json',
},
dependencies: ['setup'],
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
storageState: 'playwright/.auth/session.json',
},
dependencies: ['setup'],
},
],
});
```
```ts
// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/session.json';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Username').fill('testuser@example.com');
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByRole('heading', { name: 'Home' })).toBeVisible();
await page.context().storageState({ path: authFile });
});
```
### webServer with Build Step
**Use when**: Tests need a running application server managed by Playwright.
```ts
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
use: { baseURL: 'http://localhost:4000' },
webServer: {
command: process.env.CI
? 'npm run build && npm run preview'
: 'npm run dev',
url: 'http://localhost:4000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
NODE_ENV: 'test',
DB_URL: process.env.DB_URL || 'postgresql://localhost:5432/testdb',
},
},
});
```
### globalSetup / globalTeardown
**Use when**: One-time non-browser work like seeding a database. Runs once per test run.
```ts
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
globalSetup: './e2e/setup.ts',
globalTeardown: './e2e/teardown.ts',
});
```
```ts
// e2e/setup.ts
import { FullConfig } from '@playwright/test';
export default async function globalSetup(config: FullConfig) {
const { execSync } = await import('child_process');
execSync('npx prisma db seed', { stdio: 'inherit' });
process.env.TEST_RUN_ID = `run-${Date.now()}`;
}
```
```ts
// e2e/teardown.ts
import { FullConfig } from '@playwright/test';
export default async function globalTeardown(config: FullConfig) {
const { execSync } = await import('child_process');
execSync('npx prisma db push --force-reset', { stdio: 'inherit' });
}
```
### Environment Variables with .env
**Use when**: Managing secrets, URLs, or feature flags without hardcoding.
```bash
# .env.example (commit this)
BASE_URL=http://localhost:4000
TEST_PASSWORD=
API_KEY=
# .env.local (gitignored)
BASE_URL=http://localhost:4000
TEST_PASSWORD=secret123
API_KEY=dev-key-abc
# .env.staging (gitignored)
BASE_URL=https://staging.myapp.com
TEST_PASSWORD=staging-pass
API_KEY=staging-key-xyz
```
```bash
# .gitignore
.env
.env.local
.env.staging
.env.production
playwright/.auth/
```
Install dotenv:
```bash
npm install -D dotenv
```
### Tag-Based Test Filtering
**Use when**: Running subsets of tests in different CI stages (PR vs nightly).
```ts
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
// Filter by tags in CI
grep: process.env.CI ? /@smoke|@critical/ : undefined,
grepInvert: process.env.CI ? /@flaky/ : undefined,
});
```
**Project-specific filtering:**
```ts
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
projects: [
{
name: 'smoke',
grep: /@smoke/,
use: { ...devices['Desktop Chrome'] },
},
{
name: 'regression',
grepInvert: /@smoke/,
use: { ...devices['Desktop Chrome'] },
},
{
name: 'critical-only',
grep: /@critical/,
use: { ...devices['Desktop Chrome'] },
},
],
});
```
```bash
# Run specific project
npx playwright test --project=smoke
npx playwright test --project=regression
```
### Artifact Collection Strategy
| Setting | Local | CI | Reason |
|---------|-------|-----|--------|
| `trace` | `'off'` | `'on-first-retry'` | Traces are large; collect on failure only |
| `screenshot` | `'off'` | `'only-on-failure'` | Useful for CI debugging |
| `video` | `'off'` | `'retain-on-failure'` | Recording slows tests |
```ts
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
use: {
trace: process.env.CI ? 'on-first-retry' : 'off',
screenshot: process.env.CI ? 'only-on-failure' : 'off',
video: process.env.CI ? 'retain-on-failure' : 'off',
},
});
```
## Anti-Patterns
| Don't | Problem | Do Instead |
|-------|---------|------------|
| `timeout: 300_000` globally | Masks flaky tests; slow CI | Fix root cause; keep 30s default |
| Hardcoded URLs: `page.goto('http://localhost:4000/login')` | Breaks in other environments | Use `baseURL` + relative paths |
| All browsers on every PR | 3x CI time | Chromium on PRs; all on main |
| `trace: 'on'` always | Huge artifacts, slow uploads | `trace: 'on-first-retry'` |
| `video: 'on'` always | Massive storage; slow tests | `video: 'retain-on-failure'` |
| Config in test files: `test.use({ viewport: {...} })` everywhere | Scattered, inconsistent | Define once in project config |
| `retries: 3` locally | Hides flakiness | `retries: 0` local, `retries: 2` CI |
| No `forbidOnly` in CI | Committed `test.only` runs single test | `forbidOnly: !!process.env.CI` |
| `globalSetup` for browser auth | No browser context available | Use setup project with dependencies |
| Committing `.env` with credentials | Security risk | Commit `.env.example` only |
## Troubleshooting
### baseURL Not Working
**Cause**: Using absolute URL in `page.goto()` ignores `baseURL`.
```ts
// Wrong - ignores baseURL
await page.goto('http://localhost:4000/dashboard');
// Correct - uses baseURL
await page.goto('/dashboard');
```
### webServer Starts But Tests Get Connection Refused
**Cause**: `webServer.url` doesn't match actual server address or health check returns non-200.
```ts
webServer: {
command: 'npm run dev',
url: 'http://localhost:4000/api/health', // use real endpoint
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
```
### Tests Pass Locally But Timeout in CI
**Cause**: CI machines are slower. Increase timeouts and reduce workers:
```ts
export default defineConfig({
workers: process.env.CI ? '50%' : undefined,
use: {
navigationTimeout: process.env.CI ? 30_000 : 15_000,
actionTimeout: process.env.CI ? 15_000 : 10_000,
},
});
```
### "Target page, context or browser has been closed"
**Cause**: Test exceeded `timeout` and Playwright tore down browser during action.
**Fix**: Don't increase global timeout. Find slow step using trace:
```bash
npx playwright test --trace on
npx playwright show-report
```
## Related
- [test-tags.md](./test-tags.md) - tagging and filtering tests with `--grep`
- [fixtures-hooks.md](./fixtures-hooks.md) - custom fixtures for per-test state
- [test-suite-structure.md](test-suite-structure.md) - file structure and naming
- [authentication.md](../advanced/authentication.md) - setup projects for shared auth
- [projects-dependencies.md](./projects-dependencies.md) - advanced multi-project patterns

View file

@ -0,0 +1,417 @@
# Fixtures & Hooks
## Table of Contents
1. [Built-in Fixtures](#built-in-fixtures)
2. [Custom Fixtures](#custom-fixtures)
3. [Fixture Scopes](#fixture-scopes)
4. [Hooks](#hooks)
5. [Authentication Patterns](#authentication-patterns)
6. [Database Fixtures](#database-fixtures)
## Built-in Fixtures
### Core Fixtures
```typescript
test("example", async ({
page, // Isolated page instance
context, // Browser context (cookies, localStorage)
browser, // Browser instance
browserName, // 'chromium', 'firefox', or 'webkit'
request, // API request context
}) => {
// Each test gets fresh instances
});
```
### Request Fixture
```typescript
test("API call", async ({ request }) => {
const response = await request.get("/api/users");
await expect(response).toBeOK();
const users = await response.json();
expect(users).toHaveLength(5);
});
```
## Custom Fixtures
### Basic Custom Fixture
```typescript
// fixtures.ts
import { test as base } from "@playwright/test";
// Declare fixture types
type MyFixtures = {
todoPage: TodoPage;
apiClient: ApiClient;
};
export const test = base.extend<MyFixtures>({
// Fixture with setup and teardown
todoPage: async ({ page }, use) => {
const todoPage = new TodoPage(page);
await todoPage.goto();
await use(todoPage); // Test runs here
// Teardown (optional)
await todoPage.clearTodos();
},
// Simple fixture
apiClient: async ({ request }, use) => {
await use(new ApiClient(request));
},
});
export { expect } from "@playwright/test";
```
### Fixture with Options
```typescript
type Options = {
defaultUser: { email: string; password: string };
};
type Fixtures = {
authenticatedPage: Page;
};
export const test = base.extend<Options & Fixtures>({
// Define option with default
defaultUser: [
{ email: "test@example.com", password: "pass123" },
{ option: true },
],
// Use option in fixture
authenticatedPage: async ({ page, defaultUser }, use) => {
await page.goto("/login");
await page.getByLabel("Email").fill(defaultUser.email);
await page.getByLabel("Password").fill(defaultUser.password);
await page.getByRole("button", { name: "Sign in" }).click();
await use(page);
},
});
// Override in config
export default defineConfig({
use: {
defaultUser: { email: "admin@example.com", password: "admin123" },
},
});
```
### Automatic Fixtures
```typescript
export const test = base.extend<{}, { setupDb: void }>({
// Auto-fixture runs for every test without explicit usage
setupDb: [
async ({}, use) => {
await seedDatabase();
await use();
await cleanDatabase();
},
{ auto: true },
],
});
```
## Fixture Scopes
### Test Scope (Default)
Created fresh for each test:
```typescript
test.extend({
page: async ({ browser }, use) => {
const page = await browser.newPage();
await use(page);
await page.close();
},
});
```
### Worker Scope
Shared across tests in the same worker (each worker gets its own instance; tests in different workers do not share it):
```typescript
type WorkerFixtures = {
sharedAccount: Account;
};
export const test = base.extend<{}, WorkerFixtures>({
sharedAccount: [
async ({ browser }, use) => {
// Expensive setup - runs once per worker
const account = await createTestAccount();
await use(account);
await deleteTestAccount(account);
},
{ scope: "worker" },
],
});
```
### Isolate test data between parallel workers
When tests in different workers touch the same backend or DB (e.g. same user, same tenant), they can collide and cause flaky failures. Use `testInfo.workerIndex` (or `process.env.TEST_WORKER_INDEX`) in a worker-scoped fixture to create unique data per worker:
```typescript
import { test as baseTest } from "@playwright/test";
type WorkerFixtures = {
dbUserName: string;
};
export const test = baseTest.extend<{}, WorkerFixtures>({
dbUserName: [
async ({}, use, testInfo) => {
const userName = `user-${testInfo.workerIndex}`;
await createUserInTestDatabase(userName);
await use(userName);
await deleteUserFromTestDatabase(userName);
},
{ scope: "worker" },
],
});
```
Then each worker uses a distinct user (e.g. `user-1`, `user-2`), so parallel workers do not overwrite each others data.
## Hooks
### beforeEach / afterEach
```typescript
test.beforeEach(async ({ page }) => {
// Runs before each test in file
await page.goto("/");
});
test.afterEach(async ({ page }, testInfo) => {
// Runs after each test
if (testInfo.status !== "passed") {
await page.screenshot({ path: `failed-${testInfo.title}.png` });
}
});
```
### beforeAll / afterAll
```typescript
test.beforeAll(async ({ browser }) => {
// Runs once before all tests in file
// Note: Cannot use page fixture here
});
test.afterAll(async () => {
// Runs once after all tests in file
});
```
### Describe-Level Hooks
```typescript
test.describe("User Management", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/users");
});
test("can list users", async ({ page }) => {
// Starts at /users
});
test("can add user", async ({ page }) => {
// Starts at /users
});
});
```
## Authentication Patterns
### Global Setup with Storage State
```typescript
// auth.setup.ts
import { test as setup, expect } from "@playwright/test";
const authFile = ".auth/user.json";
setup("authenticate", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill(process.env.TEST_EMAIL!);
await page.getByLabel("Password").fill(process.env.TEST_PASSWORD!);
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
await page.context().storageState({ path: authFile });
});
```
```typescript
// playwright.config.ts
export default defineConfig({
projects: [
{ name: "setup", testMatch: /.*\.setup\.ts/ },
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
storageState: ".auth/user.json",
},
dependencies: ["setup"],
},
],
});
```
### Multiple Auth States
```typescript
// auth.setup.ts
setup("admin auth", async ({ page }) => {
await login(page, "admin@example.com", "adminpass");
await page.context().storageState({ path: ".auth/admin.json" });
});
setup("user auth", async ({ page }) => {
await login(page, "user@example.com", "userpass");
await page.context().storageState({ path: ".auth/user.json" });
});
```
```typescript
// playwright.config.ts
projects: [
{
name: "admin tests",
testMatch: /.*admin.*\.spec\.ts/,
use: { storageState: ".auth/admin.json" },
dependencies: ["setup"],
},
{
name: "user tests",
testMatch: /.*user.*\.spec\.ts/,
use: { storageState: ".auth/user.json" },
dependencies: ["setup"],
},
];
```
### Auth Fixture
```typescript
// fixtures/auth.fixture.ts
export const test = base.extend<{ adminPage: Page; userPage: Page }>({
adminPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: ".auth/admin.json",
});
const page = await context.newPage();
await use(page);
await context.close();
},
userPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: ".auth/user.json",
});
const page = await context.newPage();
await use(page);
await context.close();
},
});
```
## Database Fixtures
This section covers **per-test database fixtures** (isolation, transaction rollback). For related topics:
- **Test data factories** (builders, Faker): See [test-data.md](test-data.md)
- **One-time database setup** (migrations, snapshots): See [global-setup.md](global-setup.md#database-patterns)
### Transaction Rollback Pattern
```typescript
import { test as base } from "@playwright/test";
import { db } from "../db";
export const test = base.extend<{ dbTransaction: Transaction }>({
dbTransaction: async ({}, use) => {
const transaction = await db.beginTransaction();
await use(transaction);
await transaction.rollback(); // Clean slate for next test
},
});
```
### Seed Data Fixture
```typescript
type TestData = {
testUser: User;
testProducts: Product[];
};
export const test = base.extend<TestData>({
testUser: async ({}, use) => {
const user = await db.users.create({
email: `test-${Date.now()}@example.com`,
name: "Test User",
});
await use(user);
await db.users.delete(user.id);
},
testProducts: async ({ testUser }, use) => {
const products = await db.products.createMany([
{ name: "Product A", ownerId: testUser.id },
{ name: "Product B", ownerId: testUser.id },
]);
await use(products);
await db.products.deleteMany(products.map((p) => p.id));
},
});
```
## Fixture Tips
| Tip | Explanation |
| ------------------ | ------------------------------------------- |
| Fixtures are lazy | Only created when used |
| Compose fixtures | Use other fixtures as dependencies |
| Keep setup minimal | Do heavy lifting in worker-scoped fixtures |
| Clean up resources | Use teardown in fixtures, not afterEach |
| Avoid shared state | Each fixture instance should be independent |
## Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
| ----------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Shared mutable state between tests | Race conditions, order dependencies | Use fixtures for isolation |
| Global variables in tests | Tests depend on execution order | Use fixtures or beforeEach for setup |
| Not cleaning up test data | Tests interfere with each other | Use fixtures with teardown or database transactions |
| Shared `page` or `context` in `beforeAll` | State leak between tests; flaky when tests run in parallel | Use default one-context-per-test, or `beforeEach` + fresh page; if serial is required, prefer `test.describe.configure({ mode: 'serial' })` and document that isolation is sacrificed |
| Backend/DB state shared across workers | Tests in different workers collide on same data | Use worker-scoped fixture with `testInfo.workerIndex` to create unique data per worker |
## Related References
- **Page Objects with fixtures**: See [page-object-model.md](page-object-model.md) for POM patterns
- **Test organization**: See [test-suite-structure.md](test-suite-structure.md) for test structure
- **Debugging fixture issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting

View file

@ -0,0 +1,434 @@
# Global Setup & Teardown
## Table of Contents
1. [Global Setup](#global-setup)
2. [Global Teardown](#global-teardown)
3. [Database Patterns](#database-patterns)
4. [Environment Provisioning](#environment-provisioning)
5. [Setup Projects vs Global Setup](#setup-projects-vs-global-setup)
6. [Parallel Execution Caveats](#parallel-execution-caveats)
## Global Setup
### Basic Global Setup
```typescript
// global-setup.ts
import { FullConfig } from "@playwright/test";
async function globalSetup(config: FullConfig) {
console.log("Running global setup...");
// Perform one-time setup: start services, run migrations, etc.
}
export default globalSetup;
```
### Configure Global Setup
```typescript
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
globalSetup: require.resolve("./global-setup"),
globalTeardown: require.resolve("./global-teardown"),
});
```
> **Authentication in Global Setup**: For authentication patterns using storage state in global setup, see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns). Setup projects are generally preferred for authentication as they provide access to Playwright fixtures.
### Global Setup with Return Value
```typescript
// global-setup.ts
async function globalSetup(config: FullConfig): Promise<() => Promise<void>> {
const server = await startTestServer();
// Return cleanup function (alternative to globalTeardown)
return async () => {
await server.stop();
};
}
export default globalSetup;
```
### Access Config in Global Setup
```typescript
// global-setup.ts
import { FullConfig } from "@playwright/test";
async function globalSetup(config: FullConfig) {
const { baseURL } = config.projects[0].use;
console.log(`Setting up for ${baseURL}`);
// Access custom config
const workers = config.workers;
const timeout = config.timeout;
// Access environment
const isCI = !!process.env.CI;
}
export default globalSetup;
```
## Global Teardown
### Basic Global Teardown
```typescript
// global-teardown.ts
import { FullConfig } from "@playwright/test";
import fs from "fs";
async function globalTeardown(config: FullConfig) {
console.log("Running global teardown...");
// Clean up auth files
if (fs.existsSync(".auth")) {
fs.rmSync(".auth", { recursive: true });
}
// Clean up test data
await cleanupTestDatabase();
// Stop services
await stopTestServices();
}
export default globalTeardown;
```
### Conditional Teardown
```typescript
// global-teardown.ts
async function globalTeardown(config: FullConfig) {
// Skip cleanup in CI (containers are discarded anyway)
if (process.env.CI) {
console.log("Skipping teardown in CI");
return;
}
// Local cleanup
await cleanupLocalTestData();
}
export default globalTeardown;
```
## Database Patterns
This section covers **one-time database setup** (migrations, snapshots, per-worker databases). For related topics:
- **Per-test database fixtures** (isolation, transaction rollback): See [fixtures-hooks.md](fixtures-hooks.md#database-fixtures)
- **Test data factories** (builders, Faker): See [test-data.md](test-data.md)
### Database Migration in Setup
```typescript
// global-setup.ts
import { execSync } from "child_process";
async function globalSetup() {
console.log("Running database migrations...");
// Run migrations
execSync("npx prisma migrate deploy", { stdio: "inherit" });
// Seed test data
execSync("npx prisma db seed", { stdio: "inherit" });
}
export default globalSetup;
```
### Database Snapshot Pattern
```typescript
// global-setup.ts
import { execSync } from "child_process";
import fs from "fs";
const SNAPSHOT_PATH = "./test-db-snapshot.sql";
async function globalSetup() {
// Check if snapshot exists
if (fs.existsSync(SNAPSHOT_PATH)) {
console.log("Restoring database from snapshot...");
execSync(`psql $DATABASE_URL < ${SNAPSHOT_PATH}`, { stdio: "inherit" });
return;
}
// First run: migrate and create snapshot
console.log("Creating database snapshot...");
execSync("npx prisma migrate deploy", { stdio: "inherit" });
execSync("npx prisma db seed", { stdio: "inherit" });
execSync(`pg_dump $DATABASE_URL > ${SNAPSHOT_PATH}`, { stdio: "inherit" });
}
export default globalSetup;
```
### Test Database per Worker
```typescript
// global-setup.ts
async function globalSetup(config: FullConfig) {
const workerCount = config.workers || 1;
// Create a database for each worker
for (let i = 0; i < workerCount; i++) {
const dbName = `test_db_worker_${i}`;
await createDatabase(dbName);
await runMigrations(dbName);
await seedDatabase(dbName);
}
}
// global-teardown.ts
async function globalTeardown(config: FullConfig) {
const workerCount = config.workers || 1;
for (let i = 0; i < workerCount; i++) {
await dropDatabase(`test_db_worker_${i}`);
}
}
```
## Environment Provisioning
### Start Services in Setup
```typescript
// global-setup.ts
import { execSync, spawn } from "child_process";
let serverProcess: any;
async function globalSetup() {
// Start backend server
serverProcess = spawn("npm", ["run", "start:test"], {
stdio: "pipe",
detached: true,
});
// Wait for server to be ready
await waitForServer("http://localhost:3000/health", 30000);
// Store PID for teardown
process.env.SERVER_PID = serverProcess.pid.toString();
}
async function waitForServer(url: string, timeout: number) {
const start = Date.now();
while (Date.now() - start < timeout) {
try {
const response = await fetch(url);
if (response.ok) return;
} catch {
// Server not ready yet
}
await new Promise((r) => setTimeout(r, 1000));
}
throw new Error(`Server did not start within ${timeout}ms`);
}
export default globalSetup;
```
### Docker Compose Setup
```typescript
// global-setup.ts
import { execSync } from "child_process";
async function globalSetup() {
console.log("Starting Docker services...");
execSync("docker-compose -f docker-compose.test.yml up -d", {
stdio: "inherit",
});
// Wait for services to be healthy
execSync("docker-compose -f docker-compose.test.yml exec -T db pg_isready", {
stdio: "inherit",
});
}
export default globalSetup;
```
```typescript
// global-teardown.ts
import { execSync } from "child_process";
async function globalTeardown() {
console.log("Stopping Docker services...");
execSync("docker-compose -f docker-compose.test.yml down -v", {
stdio: "inherit",
});
}
export default globalTeardown;
```
### Environment Variables Setup
```typescript
// global-setup.ts
import dotenv from "dotenv";
import path from "path";
async function globalSetup() {
// Load test-specific environment
const envFile = process.env.CI ? ".env.ci" : ".env.test";
dotenv.config({ path: path.resolve(process.cwd(), envFile) });
// Validate required variables
const required = ["DATABASE_URL", "API_KEY", "TEST_EMAIL"];
for (const key of required) {
if (!process.env[key]) {
throw new Error(`Missing required environment variable: ${key}`);
}
}
}
export default globalSetup;
```
## Setup Projects vs Global Setup
### When to Use Each
| Use Global Setup | Use Setup Projects |
| ------------------------------------- | ---------------------------------------- |
| One-time setup (migrations, services) | Per-project setup (auth states) |
| No access to Playwright fixtures | Need page, request fixtures |
| Runs once before all projects | Can run per-project or have dependencies |
| Shared across all workers | Can be parallelized |
### Setup Project Pattern
```typescript
// playwright.config.ts
export default defineConfig({
projects: [
// Setup project
{
name: "setup",
testMatch: /.*\.setup\.ts/,
},
// Test projects depend on setup
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
dependencies: ["setup"],
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
dependencies: ["setup"],
},
],
});
```
> **For complete authentication setup patterns**, see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns).
### Combining Both
```typescript
// playwright.config.ts
export default defineConfig({
// Global: Start services, run migrations
globalSetup: require.resolve("./global-setup"),
globalTeardown: require.resolve("./global-teardown"),
projects: [
// Setup project: Create auth states
{ name: "setup", testMatch: /.*\.setup\.ts/ },
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
storageState: ".auth/user.json",
},
dependencies: ["setup"],
},
],
});
```
## Parallel Execution Caveats
### Understanding Global Setup Execution
```
┌─────────────────────────────────────────────────────────────┐
│ globalSetup runs ONCE │
│ ↓ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Worker 1│ │ Worker 2│ │ Worker 3│ │ Worker 4│ │
│ │ tests │ │ tests │ │ tests │ │ tests │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ ↓ │
│ globalTeardown runs ONCE │
└─────────────────────────────────────────────────────────────┘
```
**Key implications:**
- Global setup has **no access** to Playwright fixtures (`page`, `request`, `context`)
- State created in global setup is **shared** across all workers
- If tests **modify** shared state, they may conflict with parallel workers
- Global setup **cannot** react to individual test needs
### When to Prefer Worker-Scoped Fixtures
Use **worker-scoped fixtures** instead of globalSetup when:
| Scenario | Why Fixtures Are Better |
| ------------------------------------ | ---------------------------------------------------- |
| Each worker needs isolated resources | Fixtures can create per-worker databases, servers |
| Setup needs Playwright APIs | Fixtures have access to `page`, `request`, `browser` |
| Setup depends on test configuration | Fixtures receive test context and options |
| Resources need cleanup per worker | Worker fixtures auto-cleanup when worker exits |
### Common Parallel Pitfall
```typescript
// ❌ BAD: Global setup creates ONE user, all workers fight over it
async function globalSetup() {
await createUser({ email: "test@example.com" }); // Shared!
}
// ✅ GOOD: Each worker gets its own user via worker-scoped fixture
// Uses workerInfo.workerIndex to create unique data per worker
```
> **For worker-scoped fixture patterns** (per-worker databases, unique test data, `workerIndex` isolation), see [fixtures-hooks.md](fixtures-hooks.md#isolate-test-data-between-parallel-workers).
## Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
| ------------------------------ | -------------------------------- | ------------------------------------------ |
| Heavy setup in globalSetup | Slow test startup | Use setup projects for parallelizable work |
| Not cleaning up in teardown | Leaks resources, flaky CI | Always clean up or use containers |
| Hardcoded URLs in setup | Breaks in different environments | Use config.projects[0].use.baseURL |
| No timeout on service wait | Hangs forever if service fails | Add timeout with clear error |
| Shared mutable state | Race conditions in parallel | Use worker-scoped fixtures for isolation |
| Global setup for per-test data | Tests conflict | Use test-scoped fixtures |
## Related References
- **Fixtures & Auth**: See [fixtures-hooks.md](fixtures-hooks.md) for worker-scoped fixtures and auth patterns
- **CI/CD**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for CI setup patterns
- **Projects**: See [projects-dependencies.md](projects-dependencies.md) for project configuration

View file

@ -0,0 +1,242 @@
# Locator Strategies
## Table of Contents
1. [Priority Order](#priority-order)
2. [User-Facing Locators](#user-facing-locators)
3. [Filtering & Chaining](#filtering--chaining)
4. [Dynamic Content](#dynamic-content)
5. [Shadow DOM](#shadow-dom)
6. [Iframes](#iframes)
## Priority Order
Use locators in this order of preference:
1. **Role-based** (most resilient): `getByRole`
2. **Label-based**: `getByLabel`, `getByPlaceholder`
3. **Text-based**: `getByText`, `getByTitle`
4. **Test IDs** (when semantic locators aren't possible): `getByTestId`
5. **CSS/XPath** (last resort): `locator('css=...')`, `locator('xpath=...')`
## User-Facing Locators
### getByRole
Most robust approach - matches how users and assistive technology perceive the page.
```typescript
// Buttons
page.getByRole("button", { name: "Submit", exact: true }); // exact accessible name
page.getByRole("button", { name: /submit/i }); // flexible case-insensitive match
// Links
page.getByRole("link", { name: "Home" });
// Form elements
page.getByRole("textbox", { name: "Email" });
page.getByRole("checkbox", { name: "Remember me" });
page.getByRole("combobox", { name: "Country" });
page.getByRole("radio", { name: "Option A" });
// Headings
page.getByRole("heading", { name: "Welcome", level: 1 });
// Lists & items
page.getByRole("list").getByRole("listitem");
// Navigation & regions
page.getByRole("navigation");
page.getByRole("main");
page.getByRole("dialog");
page.getByRole("alert");
```
### getByLabel
For form elements with associated labels.
```typescript
// Input with <label for="email">
page.getByLabel("Email address");
// Input with aria-label
page.getByLabel("Search");
// Exact match
page.getByLabel("Email", { exact: true });
```
### getByPlaceholder
```typescript
page.getByPlaceholder("Enter your email");
page.getByPlaceholder(/email/i);
```
### getByText
```typescript
// Partial match (default)
page.getByText("Welcome");
// Exact match
page.getByText("Welcome to our site", { exact: true });
// Regex
page.getByText(/welcome/i);
```
### getByTestId
Configure custom test ID attribute in `playwright.config.ts`:
```typescript
use: {
testIdAttribute: "data-testid"; // default
}
```
Usage:
```typescript
// HTML: <button data-testid="submit-btn">Submit</button>
page.getByTestId("submit-btn");
```
## Filtering & Chaining
### filter()
Narrow down locators:
```typescript
// Filter by text
page.getByRole("listitem").filter({ hasText: "Product" });
// Filter by NOT having text
page.getByRole("listitem").filter({ hasNotText: "Out of stock" });
// Filter by child locator
page.getByRole("listitem").filter({
has: page.getByRole("button", { name: "Buy" }),
});
// Combine filters
page
.getByRole("listitem")
.filter({ hasText: "Product" })
.filter({ has: page.getByText("$9.99") });
```
### Chaining
```typescript
// Navigate down the DOM tree
page.getByRole("article").getByRole("heading");
// Get parent/ancestor
page.getByText("Child").locator("..");
page.getByText("Child").locator("xpath=ancestor::article");
```
### nth() and first()/last()
```typescript
page.getByRole("listitem").first();
page.getByRole("listitem").last();
page.getByRole("listitem").nth(2); // 0-indexed
```
## Dynamic Content
### Waiting for Elements
Locators auto-wait for actionability by default. For explicit state waiting:
```typescript
await page.getByRole("button").waitFor({ state: "visible" });
await page.getByText("Loading").waitFor({ state: "hidden" });
```
> **For comprehensive waiting strategies** (element state, navigation, network, polling with `toPass()`), see [assertions-waiting.md](assertions-waiting.md#waiting-strategies).
### Lists with Dynamic Items
```typescript
// Wait for specific count
await expect(page.getByRole("listitem")).toHaveCount(5);
// Get all matching elements
const items = await page.getByRole("listitem").all();
for (const item of items) {
await expect(item).toBeVisible();
}
```
## Shadow DOM
Playwright pierces shadow DOM by default:
```typescript
// Automatically finds elements inside shadow roots
page.getByRole("button", { name: "Shadow Button" });
// Explicit shadow DOM traversal (if needed)
page.locator("my-component").locator("internal:shadow=button");
```
## Iframes
```typescript
// By frame name or URL
const frame = page.frameLocator('iframe[name="content"]');
await frame.getByRole("button").click();
// By index
const frame = page.frameLocator("iframe").first();
// Nested iframes
const nestedFrame = page.frameLocator("#outer").frameLocator("#inner");
await nestedFrame.getByText("Content").click();
```
## Debugging Locators
```typescript
// Highlight element in headed mode
await page.getByRole("button").highlight();
// Count matches
const count = await page.getByRole("listitem").count();
// Check if exists without waiting
const exists = (await page.getByRole("button").count()) > 0;
// Use Playwright Inspector
// PWDEBUG=1 npx playwright test
```
## Common Issues & Solutions
| Issue | Solution |
| ----------------------- | ------------------------------------------------ |
| Multiple elements match | Add filters or use `nth()`, `first()`, `last()` |
| Element not found | Check visibility, wait for load, verify selector |
| Stale element | Locators are lazy; re-query if DOM changes |
| Dynamic IDs | Use stable attributes like role, text, test-id |
| Hidden elements | Use `{ force: true }` only when necessary |
## Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
| --------------------------------- | --------------------------------- | ------------------------------------------------- |
| `page.locator('.btn-primary')` | Brittle, implementation-dependent | `page.getByRole('button', { name: 'Submit' })` |
| `page.locator('#dynamic-id-123')` | Breaks when IDs change | Use stable attributes like role, text, or test-id |
| Testing implementation details | Breaks on refactoring | Test user-visible behavior |
## Related References
- **Debugging selector issues**: See [debugging.md](../debugging/debugging.md) for troubleshooting
- **Waiting for elements**: See [assertions-waiting.md](assertions-waiting.md) for waiting strategies
- **Using in Page Objects**: See [page-object-model.md](page-object-model.md) for organizing locators

View file

@ -0,0 +1,315 @@
# Page Object Model (POM)
## Table of Contents
1. [Overview](#overview)
2. [Basic Structure](#basic-structure)
3. [Component Objects](#component-objects)
4. [Composition Patterns](#composition-patterns)
5. [Factory Functions](#factory-functions)
6. [Best Practices](#best-practices)
## Overview
Page Object Model encapsulates page structure and interactions, providing:
- **Maintainability**: Change selectors in one place
- **Reusability**: Share page interactions across tests
- **Readability**: Tests express intent, not implementation
## Basic Structure
### Page Class
```typescript
// pages/login.page.ts
import { Page, Locator, expect } from "@playwright/test";
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel("Email");
this.passwordInput = page.getByLabel("Password");
this.submitButton = page.getByRole("button", { name: "Sign in" });
this.errorMessage = page.getByRole("alert");
}
async goto() {
await this.page.goto("/login");
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}
```
### Usage in Tests
```typescript
// tests/login.spec.ts
import { test, expect } from "@playwright/test";
import { LoginPage } from "../pages/login.page";
test.describe("Login", () => {
test("successful login redirects to dashboard", async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login("user@example.com", "password123");
await expect(page).toHaveURL("/dashboard");
});
test("shows error for invalid credentials", async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login("invalid@example.com", "wrong");
await loginPage.expectError("Invalid credentials");
});
});
```
## Component Objects
For reusable UI components:
```typescript
// components/navbar.component.ts
import { Page, Locator } from "@playwright/test";
export class NavbarComponent {
readonly container: Locator;
readonly logo: Locator;
readonly searchInput: Locator;
readonly userMenu: Locator;
constructor(page: Page) {
this.container = page.getByRole("navigation");
this.logo = this.container.getByRole("link", { name: "Home" });
this.searchInput = this.container.getByRole("searchbox");
this.userMenu = this.container.getByRole("button", { name: /user menu/i });
}
async search(query: string) {
await this.searchInput.fill(query);
await this.searchInput.press("Enter");
}
async openUserMenu() {
await this.userMenu.click();
}
}
```
```typescript
// components/modal.component.ts
import { Locator, expect } from "@playwright/test";
export class ModalComponent {
readonly container: Locator;
readonly title: Locator;
readonly closeButton: Locator;
readonly confirmButton: Locator;
constructor(container: Locator) {
this.container = container;
this.title = container.getByRole("heading");
this.closeButton = container.getByRole("button", { name: "Close" });
this.confirmButton = container.getByRole("button", { name: "Confirm" });
}
async expectTitle(title: string) {
await expect(this.title).toHaveText(title);
}
async close() {
await this.closeButton.click();
}
async confirm() {
await this.confirmButton.click();
}
}
```
## Composition Patterns
### Page with Components
```typescript
// pages/dashboard.page.ts
import { Page, Locator } from "@playwright/test";
import { NavbarComponent } from "../components/navbar.component";
import { ModalComponent } from "../components/modal.component";
export class DashboardPage {
readonly page: Page;
readonly navbar: NavbarComponent;
readonly newProjectButton: Locator;
constructor(page: Page) {
this.page = page;
this.navbar = new NavbarComponent(page);
this.newProjectButton = page.getByRole("button", { name: "New Project" });
}
async goto() {
await this.page.goto("/dashboard");
}
async createProject() {
await this.newProjectButton.click();
return new ModalComponent(this.page.getByRole("dialog"));
}
}
```
### Page Navigation
```typescript
// pages/base.page.ts
import { Page } from "@playwright/test";
export abstract class BasePage {
constructor(readonly page: Page) {}
abstract goto(): Promise<void>;
async getTitle(): Promise<string> {
return this.page.title();
}
}
```
```typescript
// Return new page object on navigation
export class LoginPage extends BasePage {
async login(email: string, password: string): Promise<DashboardPage> {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
return new DashboardPage(this.page);
}
}
// Usage
const loginPage = new LoginPage(page);
await loginPage.goto();
const dashboardPage = await loginPage.login("user@example.com", "pass");
await dashboardPage.expectWelcomeMessage();
```
## Factory Functions
Alternative to classes for simpler pages:
```typescript
// pages/login.page.ts
import { Page } from "@playwright/test";
export function createLoginPage(page: Page) {
const emailInput = page.getByLabel("Email");
const passwordInput = page.getByLabel("Password");
const submitButton = page.getByRole("button", { name: "Sign in" });
return {
goto: () => page.goto("/login"),
login: async (email: string, password: string) => {
await emailInput.fill(email);
await passwordInput.fill(password);
await submitButton.click();
},
emailInput,
passwordInput,
submitButton,
};
}
// Usage
const loginPage = createLoginPage(page);
await loginPage.goto();
await loginPage.login("user@example.com", "password");
```
## Best Practices
### Do
- **Keep locators in page objects** - Single source of truth
- **Return new page objects** when navigation occurs
- **Expose elements** for custom assertions in tests
- **Use descriptive method names** - `submitOrder()` not `clickButton()`
- **Keep methods focused** - One action per method
### Don't
- **Don't include assertions in page methods** (usually) - Keep in tests
- **Don't expose implementation details** - Hide complex interactions
- **Don't make page objects too large** - Split into components
- **Don't share state** between page object instances
### Directory Structure
```
tests/
├── pages/
│ ├── base.page.ts
│ ├── login.page.ts
│ ├── dashboard.page.ts
│ └── settings.page.ts
├── components/
│ ├── navbar.component.ts
│ ├── modal.component.ts
│ └── table.component.ts
├── fixtures/
│ └── pages.fixture.ts
└── specs/
├── login.spec.ts
└── dashboard.spec.ts
```
### Using with Fixtures
```typescript
// fixtures/pages.fixture.ts
import { test as base } from "@playwright/test";
import { LoginPage } from "../pages/login.page";
import { DashboardPage } from "../pages/dashboard.page";
type Pages = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
};
export const test = base.extend<Pages>({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
});
// Usage in tests
test("can login", async ({ loginPage }) => {
await loginPage.goto();
await loginPage.login("user@example.com", "password");
});
```
## Related References
- **Locator strategies**: See [locators.md](locators.md) for selecting elements
- **Fixtures**: See [fixtures-hooks.md](fixtures-hooks.md) for advanced fixture patterns
- **Test organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring test suites

View file

@ -0,0 +1,453 @@
# Projects & Dependencies
## Table of Contents
1. [Project Configuration](#project-configuration)
2. [Project Dependencies](#project-dependencies)
3. [Setup Projects](#setup-projects)
4. [Filtering & Running Projects](#filtering--running-projects)
5. [Sharing Configuration](#sharing-configuration)
6. [Advanced Patterns](#advanced-patterns)
## Project Configuration
### Basic Multi-Browser Setup
```typescript
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
],
});
```
### Environment-Based Projects
```typescript
export default defineConfig({
projects: [
{
name: "staging",
use: {
baseURL: "https://staging.example.com",
},
},
{
name: "production",
use: {
baseURL: "https://example.com",
},
},
{
name: "local",
use: {
baseURL: "http://localhost:3000",
},
},
],
});
```
### Test Type Projects
```typescript
export default defineConfig({
projects: [
{
name: "e2e",
testDir: "./tests/e2e",
use: { ...devices["Desktop Chrome"] },
},
{
name: "api",
testDir: "./tests/api",
use: { baseURL: "http://localhost:3000" },
},
{
name: "visual",
testDir: "./tests/visual",
use: {
...devices["Desktop Chrome"],
viewport: { width: 1280, height: 720 },
},
},
],
});
```
## Project Dependencies
### Setup Dependency
```typescript
export default defineConfig({
projects: [
// Setup project runs first
{
name: "setup",
testMatch: /.*\.setup\.ts/,
},
// Browser projects depend on setup
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
storageState: ".auth/user.json",
},
dependencies: ["setup"],
},
{
name: "firefox",
use: {
...devices["Desktop Firefox"],
storageState: ".auth/user.json",
},
dependencies: ["setup"],
},
],
});
```
### Multiple Auth States
```typescript
export default defineConfig({
projects: [
// Auth setup projects
{
name: "setup-admin",
testMatch: /admin\.setup\.ts/,
},
{
name: "setup-user",
testMatch: /user\.setup\.ts/,
},
// Admin tests
{
name: "admin-tests",
testDir: "./tests/admin",
use: { storageState: ".auth/admin.json" },
dependencies: ["setup-admin"],
},
// User tests
{
name: "user-tests",
testDir: "./tests/user",
use: { storageState: ".auth/user.json" },
dependencies: ["setup-user"],
},
// Tests that need both
{
name: "integration-tests",
testDir: "./tests/integration",
dependencies: ["setup-admin", "setup-user"],
},
],
});
```
### Chained Dependencies
```typescript
export default defineConfig({
projects: [
// Step 1: Database setup
{
name: "db-setup",
testMatch: /db\.setup\.ts/,
},
// Step 2: Auth setup (needs DB)
{
name: "auth-setup",
testMatch: /auth\.setup\.ts/,
dependencies: ["db-setup"],
},
// Step 3: Seed data (needs auth)
{
name: "seed-setup",
testMatch: /seed\.setup\.ts/,
dependencies: ["auth-setup"],
},
// Tests (need everything)
{
name: "tests",
testDir: "./tests",
dependencies: ["seed-setup"],
},
],
});
```
## Setup Projects
### Authentication Setup
Setup projects are the recommended way to handle authentication. They run before your main test projects and can use Playwright fixtures.
> **For complete authentication patterns** (storage state, multiple auth states, auth fixtures), see [fixtures-hooks.md](fixtures-hooks.md#authentication-patterns).
### Data Seeding Setup
```typescript
// seed.setup.ts
import { test as setup } from "@playwright/test";
setup("seed test data", async ({ request }) => {
// Create test data via API
await request.post("/api/test/seed", {
data: {
users: 10,
products: 50,
orders: 100,
},
});
});
```
### Cleanup Setup
```typescript
// cleanup.setup.ts
import { test as setup } from "@playwright/test";
setup("cleanup previous run", async ({ request }) => {
// Clean up data from previous test runs
await request.delete("/api/test/cleanup");
});
```
## Filtering & Running Projects
### Run Specific Project
```bash
# Run single project
npx playwright test --project=chromium
# Run multiple projects
npx playwright test --project=chromium --project=firefox
```
### Run by Grep
```bash
# Run tests matching pattern
npx playwright test --grep @smoke
# Run project with grep
npx playwright test --project=chromium --grep @critical
# Exclude pattern
npx playwright test --grep-invert @slow
```
### Project-Specific Grep
```typescript
export default defineConfig({
projects: [
{
name: "smoke",
grep: /@smoke/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "regression",
grepInvert: /@smoke/,
use: { ...devices["Desktop Chrome"] },
},
],
});
```
## Sharing Configuration
### Base Configuration
```typescript
// playwright.config.ts
const baseConfig = {
timeout: 30000,
expect: { timeout: 5000 },
use: {
trace: "on-first-retry",
screenshot: "only-on-failure",
},
};
export default defineConfig({
...baseConfig,
projects: [
{
name: "chromium",
use: {
...baseConfig.use,
...devices["Desktop Chrome"],
},
},
{
name: "firefox",
use: {
...baseConfig.use,
...devices["Desktop Firefox"],
},
},
],
});
```
### Shared Project Settings
```typescript
const sharedBrowserConfig = {
timeout: 60000,
retries: 2,
use: {
video: "on-first-retry",
trace: "on-first-retry",
},
};
export default defineConfig({
projects: [
{
name: "chromium",
...sharedBrowserConfig,
use: {
...sharedBrowserConfig.use,
...devices["Desktop Chrome"],
},
},
{
name: "firefox",
...sharedBrowserConfig,
use: {
...sharedBrowserConfig.use,
...devices["Desktop Firefox"],
},
},
],
});
```
## Advanced Patterns
### Conditional Projects
```typescript
const projects = [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
];
// Add Firefox only in CI
if (process.env.CI) {
projects.push({
name: "firefox",
use: { ...devices["Desktop Firefox"] },
});
}
// Add mobile only for specific test dirs
if (process.env.TEST_MOBILE) {
projects.push({
name: "mobile",
use: { ...devices["iPhone 14"] },
});
}
export default defineConfig({ projects });
```
### Project Metadata
```typescript
export default defineConfig({
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
metadata: {
platform: "desktop",
browser: "chromium",
priority: "high",
},
},
],
});
// Access in test
test("example", async ({ page }, testInfo) => {
const { platform, priority } = testInfo.project.metadata;
console.log(`Running on ${platform} with ${priority} priority`);
});
```
### Teardown Projects
```typescript
export default defineConfig({
projects: [
{
name: "setup",
testMatch: /.*\.setup\.ts/,
teardown: "teardown", // Run teardown after this completes
},
{
name: "teardown",
testMatch: /.*\.teardown\.ts/,
},
{
name: "tests",
dependencies: ["setup"],
},
],
});
```
```typescript
// cleanup.teardown.ts
import { test as teardown } from "@playwright/test";
teardown("cleanup", async ({ request }) => {
await request.delete("/api/test/data");
});
```
## Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
| -------------------------- | ---------------------- | ----------------------------------- |
| Too many browser projects | Slow CI, expensive | Focus on critical browsers |
| Missing setup dependencies | Tests fail randomly | Declare all dependencies explicitly |
| Duplicated configuration | Hard to maintain | Extract shared config |
| Not using setup projects | Repeated auth in tests | Use setup project + storageState |
## Related References
- **Global Setup**: See [global-setup.md](global-setup.md) for globalSetup vs setup projects
- **Fixtures**: See [fixtures-hooks.md](fixtures-hooks.md) for authentication patterns
- **CI/CD**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for running projects in CI

View file

@ -0,0 +1,492 @@
# Test Data Factories & Generators
This file covers **reusable test data builders** (factories, Faker, data generators). For related topics:
- **Per-test database fixtures** (isolation, transaction rollback): See [fixtures-hooks.md](fixtures-hooks.md#database-fixtures)
- **One-time database setup** (migrations, snapshots): See [global-setup.md](global-setup.md#database-patterns)
## Table of Contents
1. [Factory Pattern](#factory-pattern)
2. [Faker Integration](#faker-integration)
3. [Data-Driven Testing](#data-driven-testing)
4. [Test Data Fixtures](#test-data-fixtures)
5. [Database Seeding](#database-seeding)
## Factory Pattern
### Basic Factory
```typescript
// factories/user.factory.ts
interface User {
id: string;
email: string;
name: string;
role: "admin" | "user" | "guest";
createdAt: Date;
}
let userIdCounter = 0;
export function createUser(overrides: Partial<User> = {}): User {
userIdCounter++;
return {
id: `user-${userIdCounter}`,
email: `user${userIdCounter}@test.com`,
name: `Test User ${userIdCounter}`,
role: "user",
createdAt: new Date(),
...overrides,
};
}
// Usage
const user = createUser();
const admin = createUser({ role: "admin", name: "Admin User" });
```
### Factory with Traits
```typescript
// factories/product.factory.ts
interface Product {
id: string;
name: string;
price: number;
stock: number;
category: string;
featured: boolean;
}
type ProductTrait = "outOfStock" | "featured" | "expensive" | "sale";
const traits: Record<ProductTrait, Partial<Product>> = {
outOfStock: { stock: 0 },
featured: { featured: true },
expensive: { price: 999.99 },
sale: { price: 9.99 },
};
let productIdCounter = 0;
export function createProduct(
overrides: Partial<Product> = {},
...traitNames: ProductTrait[]
): Product {
productIdCounter++;
const appliedTraits = traitNames.reduce(
(acc, trait) => ({ ...acc, ...traits[trait] }),
{},
);
return {
id: `prod-${productIdCounter}`,
name: `Product ${productIdCounter}`,
price: 29.99,
stock: 100,
category: "General",
featured: false,
...appliedTraits,
...overrides,
};
}
// Usage
const product = createProduct();
const featuredProduct = createProduct({}, "featured");
const saleItem = createProduct({ name: "Sale Item" }, "sale", "featured");
const soldOut = createProduct({}, "outOfStock");
```
### Factory with Relationships
```typescript
// factories/order.factory.ts
import { createUser, User } from "./user.factory";
import { createProduct, Product } from "./product.factory";
interface OrderItem {
product: Product;
quantity: number;
}
interface Order {
id: string;
user: User;
items: OrderItem[];
total: number;
status: "pending" | "paid" | "shipped" | "delivered";
}
let orderIdCounter = 0;
export function createOrder(overrides: Partial<Order> = {}): Order {
orderIdCounter++;
const user = overrides.user ?? createUser();
const items = overrides.items ?? [{ product: createProduct(), quantity: 1 }];
const total = items.reduce(
(sum, item) => sum + item.product.price * item.quantity,
0,
);
return {
id: `order-${orderIdCounter}`,
user,
items,
total,
status: "pending",
...overrides,
};
}
// Usage
const order = createOrder();
const bigOrder = createOrder({
items: [
{ product: createProduct({ price: 100 }), quantity: 5 },
{ product: createProduct({ price: 50 }), quantity: 2 },
],
});
```
## Faker Integration
### Setup Faker
```bash
npm install -D @faker-js/faker
```
```typescript
// factories/faker-user.factory.ts
import { faker } from "@faker-js/faker";
interface User {
id: string;
email: string;
name: string;
avatar: string;
address: {
street: string;
city: string;
country: string;
zipCode: string;
};
}
export function createFakeUser(overrides: Partial<User> = {}): User {
return {
id: faker.string.uuid(),
email: faker.internet.email(),
name: faker.person.fullName(),
avatar: faker.image.avatar(),
address: {
street: faker.location.streetAddress(),
city: faker.location.city(),
country: faker.location.country(),
zipCode: faker.location.zipCode(),
},
...overrides,
};
}
```
### Seeded Faker for Reproducibility
```typescript
import { faker } from "@faker-js/faker";
// Set seed for reproducible data
faker.seed(12345);
export function createDeterministicUser(): User {
return {
id: faker.string.uuid(),
email: faker.internet.email(),
name: faker.person.fullName(),
// Same seed = same data every time
};
}
// Or seed per test
test("user profile", async ({ page }) => {
faker.seed(42); // Reset seed for this test
const user = createFakeUser();
// user will always have the same data
});
```
### Faker Fixture
```typescript
// fixtures/faker.fixture.ts
import { test as base } from "@playwright/test";
import { faker } from "@faker-js/faker";
type FakerFixtures = {
fake: typeof faker;
};
export const test = base.extend<FakerFixtures>({
fake: async ({}, use, testInfo) => {
// Seed based on test name for reproducibility
faker.seed(testInfo.title.length);
await use(faker);
},
});
// Usage
test("create user with fake data", async ({ page, fake }) => {
await page.goto("/signup");
await page.getByLabel("Name").fill(fake.person.fullName());
await page.getByLabel("Email").fill(fake.internet.email());
await page.getByLabel("Password").fill(fake.internet.password());
await page.getByRole("button", { name: "Sign Up" }).click();
});
```
## Data-Driven Testing
### test.each with Arrays
```typescript
const loginScenarios = [
{ email: "user@example.com", password: "pass123", expected: "Dashboard" },
{ email: "admin@example.com", password: "admin123", expected: "Admin Panel" },
{
email: "invalid@example.com",
password: "wrong",
expected: "Invalid credentials",
},
];
for (const { email, password, expected } of loginScenarios) {
test(`login with ${email}`, async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill(email);
await page.getByLabel("Password").fill(password);
await page.getByRole("button", { name: "Sign In" }).click();
await expect(page.getByText(expected)).toBeVisible();
});
}
```
### Parameterized Tests
```typescript
// data/checkout-scenarios.ts
export const checkoutScenarios = [
{
name: "standard shipping",
shipping: "standard",
expectedDays: "5-7 business days",
expectedCost: "$5.99",
},
{
name: "express shipping",
shipping: "express",
expectedDays: "2-3 business days",
expectedCost: "$14.99",
},
{
name: "overnight shipping",
shipping: "overnight",
expectedDays: "Next business day",
expectedCost: "$29.99",
},
];
```
```typescript
import { checkoutScenarios } from "./data/checkout-scenarios";
test.describe("shipping options", () => {
for (const scenario of checkoutScenarios) {
test(`checkout with ${scenario.name}`, async ({ page }) => {
await page.goto("/checkout");
await page.getByLabel(scenario.shipping, { exact: false }).check();
await expect(page.getByText(scenario.expectedDays)).toBeVisible();
await expect(page.getByText(scenario.expectedCost)).toBeVisible();
});
}
});
```
### CSV/JSON Data Source
```typescript
import fs from "fs";
interface TestCase {
input: string;
expected: string;
}
// Load test data from JSON
const testCases: TestCase[] = JSON.parse(
fs.readFileSync("./data/search-tests.json", "utf-8"),
);
test.describe("search functionality", () => {
for (const { input, expected } of testCases) {
test(`search for "${input}"`, async ({ page }) => {
await page.goto("/search");
await page.getByLabel("Search").fill(input);
await page.getByLabel("Search").press("Enter");
await expect(page.getByText(expected)).toBeVisible();
});
}
});
```
## Test Data Fixtures
### Fixture with Factory
```typescript
// fixtures/data.fixture.ts
import { test as base } from "@playwright/test";
import { createUser, User } from "../factories/user.factory";
import { createProduct, Product } from "../factories/product.factory";
type DataFixtures = {
testUser: User;
testProducts: Product[];
};
export const test = base.extend<DataFixtures>({
testUser: async ({}, use) => {
const user = createUser({ name: "E2E Test User" });
await use(user);
},
testProducts: async ({}, use) => {
const products = [
createProduct({ name: "Test Product 1" }),
createProduct({ name: "Test Product 2" }),
createProduct({ name: "Test Product 3" }),
];
await use(products);
},
});
// Usage
test("add product to cart", async ({ page, testUser, testProducts }) => {
// Mock API with test data
await page.route("**/api/user", (route) => route.fulfill({ json: testUser }));
await page.route("**/api/products", (route) =>
route.fulfill({ json: testProducts }),
);
await page.goto("/products");
await expect(page.getByText(testProducts[0].name)).toBeVisible();
});
```
## Database Seeding
### API-Based Seeding
```typescript
// fixtures/seed.fixture.ts
import { test as base, APIRequestContext } from "@playwright/test";
import { createUser } from "../factories/user.factory";
type SeedFixtures = {
seedUser: (overrides?: Partial<User>) => Promise<User>;
cleanupUsers: string[];
};
export const test = base.extend<SeedFixtures>({
cleanupUsers: [],
seedUser: async ({ request, cleanupUsers }, use) => {
await use(async (overrides = {}) => {
const userData = createUser(overrides);
const response = await request.post("/api/test/users", {
data: userData,
});
const user = await response.json();
cleanupUsers.push(user.id);
return user;
});
},
// Cleanup after test
cleanupUsers: async ({ request }, use) => {
const userIds: string[] = [];
await use(userIds);
// Delete all created users
for (const id of userIds) {
await request.delete(`/api/test/users/${id}`);
}
},
});
// Usage
test("user profile page", async ({ page, seedUser }) => {
const user = await seedUser({ name: "John Doe" });
await page.goto(`/users/${user.id}`);
await expect(page.getByText("John Doe")).toBeVisible();
});
```
### Transaction Rollback Seeding
```typescript
// fixtures/db.fixture.ts
export const test = base.extend<{}, { db: DbTransaction }>({
db: [
async ({}, use) => {
const client = await pool.connect();
await client.query("BEGIN");
await use({
query: (sql: string, params?: any[]) => client.query(sql, params),
seed: async (table: string, data: object) => {
const keys = Object.keys(data);
const values = Object.values(data);
const placeholders = keys.map((_, i) => `$${i + 1}`);
const result = await client.query(
`INSERT INTO ${table} (${keys.join(", ")}) VALUES (${placeholders.join(", ")}) RETURNING *`,
values,
);
return result.rows[0];
},
});
await client.query("ROLLBACK");
client.release();
},
{ scope: "test" },
],
});
```
## Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
| ------------------------------- | ------------------------------- | -------------------------- |
| Hardcoded test data | Brittle, repetitive | Use factories |
| Random data without seed | Non-reproducible failures | Seed faker per test |
| Shared mutable test data | Tests interfere with each other | Create fresh data per test |
| Manual data creation everywhere | Duplication, maintenance burden | Centralize in factories |
## Related References
- **Fixtures**: See [fixtures-hooks.md](fixtures-hooks.md) for fixture patterns
- **API Testing**: See [test-suite-structure.md](test-suite-structure.md) for API mocking

View file

@ -0,0 +1,361 @@
# Test Suite Structure
## Table of Contents
1. [Configuration](#configuration)
2. [E2E Tests](#e2e-tests)
3. [Component Tests](#component-tests)
4. [API Tests](#api-tests)
5. [Visual Regression Tests](#visual-regression-tests)
6. [Directory Structure](#directory-structure)
7. [Tagging & Filtering](#tagging--filtering)
### Project Setup
```bash
npm init playwright@latest
```
## Configuration
### Essential Configuration
```typescript
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [["html"], ["list"]],
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{ name: "setup", testMatch: /.*\.setup\.ts/ },
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
dependencies: ["setup"],
},
],
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});
```
## E2E Tests
Full user journey tests through the browser.
### Structure
```typescript
// tests/e2e/checkout.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Checkout Flow", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/products");
});
test("complete purchase as guest", async ({ page }) => {
// Add to cart
await page.getByRole("button", { name: "Add to Cart" }).first().click();
await expect(page.getByTestId("cart-count")).toHaveText("1");
// Go to checkout
await page.getByRole("link", { name: "Cart" }).click();
await page.getByRole("button", { name: "Checkout" }).click();
// Fill shipping
await page.getByLabel("Email").fill("guest@example.com");
await page.getByLabel("Address").fill("123 Test St");
await page.getByRole("button", { name: "Continue" }).click();
// Payment
await page.getByLabel("Card Number").fill("4242424242424242");
await page.getByRole("button", { name: "Pay Now" }).click();
// Confirmation
await expect(page.getByRole("heading")).toHaveText("Order Confirmed");
});
test("apply discount code", async ({ page }) => {
await page.getByRole("button", { name: "Add to Cart" }).first().click();
await page.getByRole("link", { name: "Cart" }).click();
await page.getByLabel("Discount Code").fill("SAVE10");
await page.getByRole("button", { name: "Apply" }).click();
await expect(page.getByText("10% discount applied")).toBeVisible();
});
});
```
### Best Practices
- Test critical user journeys
- Keep tests independent
- Use realistic data
- Clean up test data in teardown
## Component Tests
Test individual components in isolation using Playwright Component Testing.
```bash
npm init playwright@latest -- --ct
```
For comprehensive component testing patterns including mounting, props, events, slots, mocking, and framework-specific examples (React, Vue, Svelte), see **[component-testing.md](../testing-patterns/component-testing.md)**.
## API Tests
Test backend APIs without browser.
### API Mocking Patterns
For E2E tests that need to mock API responses:
```typescript
// Mock single endpoint
test("displays mocked users", async ({ page }) => {
await page.route("**/api/users", (route) =>
route.fulfill({
status: 200,
json: [{ id: 1, name: "Test User" }],
})
);
await page.goto("/users");
await expect(page.getByText("Test User")).toBeVisible();
});
// Mock with different responses
test("handles API errors", async ({ page }) => {
await page.route("**/api/users", (route) =>
route.fulfill({
status: 500,
json: { error: "Server error" },
})
);
await page.goto("/users");
await expect(page.getByText("Server error")).toBeVisible();
});
// Conditional mocking
test("mocks based on request", async ({ page }) => {
await page.route("**/api/users", (route, request) => {
if (request.method() === "GET") {
route.fulfill({ json: [{ id: 1, name: "User" }] });
} else {
route.continue();
}
});
});
// Mock with delay (simulate slow network)
test("handles slow API", async ({ page }) => {
await page.route("**/api/data", (route) =>
route.fulfill({
json: { data: "test" },
delay: 2000, // 2 second delay
})
);
await page.goto("/dashboard");
await expect(page.getByText("Loading...")).toBeVisible();
await expect(page.getByText("test")).toBeVisible();
});
```
For advanced patterns (GraphQL mocking, HAR recording, request modification, network throttling), see **[network-advanced.md](../advanced/network-advanced.md)**.
## Visual Regression Tests
Compare screenshots to detect visual changes.
### Basic Visual Test
```typescript
// tests/visual/homepage.spec.ts
import { test, expect } from "@playwright/test";
test("homepage visual", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveScreenshot("homepage.png");
});
test("component visual", async ({ page }) => {
await page.goto("/components");
const button = page.getByRole("button", { name: "Primary" });
await expect(button).toHaveScreenshot("primary-button.png");
});
```
### Visual Test Options
```typescript
test("dashboard visual", async ({ page }) => {
await page.goto("/dashboard");
await expect(page).toHaveScreenshot("dashboard.png", {
fullPage: true, // Capture entire scrollable page
maxDiffPixels: 100, // Allow up to 100 different pixels
maxDiffPixelRatio: 0.01, // Or 1% difference
threshold: 0.2, // Pixel comparison threshold
animations: "disabled", // Disable animations
mask: [page.getByTestId("date")], // Mask dynamic content
});
});
```
### Handling Dynamic Content
```typescript
test("page with dynamic content", async ({ page }) => {
await page.goto("/profile");
// Mask elements that change
await expect(page).toHaveScreenshot("profile.png", {
mask: [
page.getByTestId("timestamp"),
page.getByTestId("avatar"),
page.getByRole("img"),
],
});
});
// Or hide elements via CSS
test("page hiding dynamic elements", async ({ page }) => {
await page.goto("/profile");
await page.addStyleTag({
content: `
.dynamic-content { visibility: hidden !important; }
[data-testid="ad-banner"] { display: none !important; }
`,
});
await expect(page).toHaveScreenshot("profile-stable.png");
});
```
### Visual Test Configuration
```typescript
// playwright.config.ts
export default defineConfig({
expect: {
toHaveScreenshot: {
maxDiffPixels: 50,
animations: "disabled",
},
},
projects: [
{
name: "visual-chrome",
use: {
...devices["Desktop Chrome"],
viewport: { width: 1280, height: 720 },
},
testMatch: /.*visual.*\.spec\.ts/,
},
],
});
```
### Update Snapshots
```bash
# Update all snapshots
npx playwright test --update-snapshots
# Update specific test
npx playwright test homepage.spec.ts --update-snapshots
```
## Directory Structure
```
tests/
├── e2e/ # End-to-end tests
│ ├── auth.spec.ts
│ ├── checkout.spec.ts
│ └── dashboard.spec.ts
├── component/ # Component tests
│ ├── Button.spec.tsx
│ └── Modal.spec.tsx
├── api/ # API tests
│ ├── users.spec.ts
│ └── products.spec.ts
├── visual/ # Visual regression tests
│ └── homepage.spec.ts
├── fixtures/ # Custom fixtures
│ ├── auth.fixture.ts
│ └── api.fixture.ts
└── pages/ # Page objects
├── login.page.ts
└── dashboard.page.ts
```
## Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
| ------------------------------------- | ---------------------------------- | ------------------------- |
| Long test files | Hard to maintain, slow to navigate | Split by feature, use POM |
| Tests depend on execution order | Flaky, hard to debug | Keep tests independent |
| Testing multiple features in one test | Hard to debug failures | One feature per test |
## Related References
- **Component Testing**: See [component-testing.md](../testing-patterns/component-testing.md) for comprehensive CT patterns
- **Projects**: See [projects-dependencies.md](projects-dependencies.md) for project-based filtering
- **Page Objects**: See [page-object-model.md](page-object-model.md) for organizing page interactions
- **Test Data**: See [fixtures-hooks.md](fixtures-hooks.md) for managing test data
## Tagging & Filtering
### Using Tags
```typescript
test("user login @smoke @auth", async ({ page }) => {
// ...
});
test("checkout flow @e2e @critical", async ({ page }) => {
// ...
});
test.describe("API tests @api", () => {
test("create user", async ({ request }) => {
// ...
});
});
```
### Running Tagged Tests
```bash
# Run smoke tests
npx playwright test --grep @smoke
# Run all except slow tests
npx playwright test --grep-invert @slow
# Combine tags
npx playwright test --grep "@smoke|@critical"
```
For project-based filtering and advanced project configuration, see **[projects-dependencies.md](projects-dependencies.md)**.

View file

@ -0,0 +1,298 @@
# Test Tags
## Table of Contents
1. [Basic Tagging](#basic-tagging)
2. [Tagging Describe Blocks](#tagging-describe-blocks)
3. [Running Tagged Tests](#running-tagged-tests)
4. [Filtering by Tags](#filtering-by-tags)
5. [Configuration-Based Filtering](#configuration-based-filtering)
6. [Tag Organization Patterns](#tag-organization-patterns)
7. [Common Tag Categories](#common-tag-categories)
8. [Anti-Patterns to Avoid](#anti-patterns-to-avoid)
9. [Related References](#related-references)
## Basic Tagging
### Tag via Details Object
```typescript
import { test, expect } from "@playwright/test";
test(
"test login page",
{
tag: "@fast",
},
async ({ page }) => {
await page.goto("/login");
await expect(page.getByRole("heading")).toBeVisible();
}
);
test(
"test dashboard",
{
tag: "@slow",
},
async ({ page }) => {
await page.goto("/dashboard");
await expect(page.getByTestId("charts")).toBeVisible();
}
);
```
### Tag via Title (not recommended)
```typescript
test("test full report @slow", async ({ page }) => {
await page.goto("/reports/full");
await expect(page.getByText("Report loaded")).toBeVisible();
});
test("quick validation @fast @smoke", async ({ page }) => {
await page.goto("/");
await expect(page.locator("body")).toBeVisible();
});
```
## Tagging Describe Blocks
### Tag All Tests in Group
```typescript
test.describe(
"report tests",
{
tag: "@report",
},
() => {
test("test report header", async ({ page }) => {
// Inherits @report tag
});
test("test report footer", async ({ page }) => {
// Inherits @report tag
});
}
);
```
### Combine Group and Test Tags
```typescript
test.describe(
"admin features",
{
tag: "@admin",
},
() => {
test("admin dashboard", async ({ page }) => {
// Has @admin tag
});
test(
"admin settings",
{
tag: ["@slow", "@critical"],
},
async ({ page }) => {
// Has @admin, @slow, @critical tags
}
);
}
);
```
## Running Tagged Tests
### Run Tests with Specific Tag
```bash
# Run all @fast tests
npx playwright test --grep @fast
```
### Exclude Tests with Tag
```bash
# Run all tests except @slow
npx playwright test --grep-invert @slow
```
## Filtering by Tags
### Logical OR (Either Tag)
```bash
# Run tests with @fast OR @smoke
npx playwright test --grep "@fast|@smoke"
```
### Logical AND (Both Tags)
```bash
# Run tests with both @fast AND @critical
npx playwright test --grep "(?=.*@fast)(?=.*@critical)"
```
### Complex Patterns
```bash
# Run @e2e tests that are also @critical
npx playwright test --grep "(?=.*@e2e)(?=.*@critical)"
# Run @api tests excluding @slow
npx playwright test --grep "@api" --grep-invert "@slow"
```
## Configuration-Based Filtering
### Filter in playwright.config.ts
```typescript
import { defineConfig } from "@playwright/test";
export default defineConfig({
grep: /@smoke/,
grepInvert: /@flaky/,
});
```
### Project-Specific Tags
```typescript
import { defineConfig } from "@playwright/test";
export default defineConfig({
projects: [
{
name: "smoke",
grep: /@smoke/,
},
{
name: "regression",
grepInvert: /@smoke/,
},
{
name: "critical-only",
grep: /@critical/,
},
],
});
```
### Environment-Based Filtering
```typescript
import { defineConfig } from "@playwright/test";
const isCI = !!process.env.CI;
export default defineConfig({
grep: isCI ? /@smoke|@critical/ : undefined,
grepInvert: isCI ? /@flaky/ : undefined,
});
```
## Tag Organization Patterns
### By Test Type
```typescript
// Smoke tests - quick validation
test("homepage loads", { tag: "@smoke" }, async ({ page }) => {});
test("login works", { tag: "@smoke" }, async ({ page }) => {});
// Regression tests - comprehensive
test("full checkout flow", { tag: "@regression" }, async ({ page }) => {});
test("all payment methods", { tag: "@regression" }, async ({ page }) => {});
// E2E tests - user journeys
test("complete user journey", { tag: "@e2e" }, async ({ page }) => {});
```
### By Priority
```typescript
test(
"payment processing",
{
tag: ["@critical", "@p0"],
},
async ({ page }) => {}
);
test(
"user preferences",
{
tag: ["@p1"],
},
async ({ page }) => {}
);
test(
"theme customization",
{
tag: ["@p2"],
},
async ({ page }) => {}
);
```
### By Feature Area
```typescript
test.describe(
"authentication",
{
tag: "@auth",
},
() => {
test("login @smoke", async ({ page }) => {});
test("logout", async ({ page }) => {});
test("password reset @slow", async ({ page }) => {});
}
);
test.describe(
"payments",
{
tag: "@payments",
},
() => {
test("credit card @critical", async ({ page }) => {});
test("paypal @critical", async ({ page }) => {});
}
);
```
## Common Tag Categories
| Category | Tags | Purpose |
| --------------- | --------------------------------------------- | ----------------------------- |
| **Speed** | `@fast`, `@slow` | Execution time classification |
| **Priority** | `@critical`, `@p0`, `@p1`, `@p2` | Business importance |
| **Type** | `@smoke`, `@regression`, `@e2e` | Test suite categorization |
| **Feature** | `@auth`, `@payments`, `@settings` | Feature area grouping |
| **Pipeline** | `@pr`, `@nightly`, `@release` | CI/CD execution timing |
| **Status** | `@flaky`, `@wip`, `@quarantine` | Test health tracking |
| **Environment** | `@local`, `@staging`, `@prod` | Target environment |
| **Team** | `@team-frontend`, `@team-backend`, `@team-qa` | Team assignment |
## Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
| ------------------------ | ------------------------ | ---------------------------------------------- |
| Too many tags per test | Hard to maintain | Limit to 2-3 relevant tags |
| Inconsistent naming | Confusing filtering | Establish naming conventions |
| Missing `@` prefix | Tags won't match filters | Always prefix with `@` |
| Overlapping tag meanings | Ambiguous categorization | Define clear tag semantics |
| Not using tags | Can't selectively run | Tag by type, priority, or feature |
| Tags in test title | Hard to parse/filter | Use the details object for tags, not the title |
## Related References
- **Test Organization**: See [test-suite-structure.md](test-suite-structure.md) for structuring tests
- **Annotations**: See [annotations.md](annotations.md) for skip, fixme, fail, slow
- **CI/CD Integration**: See [ci-cd.md](../infrastructure-ci-cd/ci-cd.md) for pipeline setup