Test Organization
When to use: Structuring test files, naming tests, grouping with
describe, tagging, filtering, and deciding parallel vs serial execution. Prerequisites: core/configuration.md
Quick Reference
| Concept | Rule |
|---|---|
| File suffix | .spec.ts / .spec.js — always |
| Grouping | By feature/domain, not by page or URL |
| Test names | test('should ...') or test('user can ...') — describe behavior |
| Nesting | Max 2 levels of test.describe() |
| Default execution | fullyParallel: true — tests run in parallel by default |
| Serial tests | Almost never use test.describe.serial() |
| Test dependencies | Avoid — each test sets up its own state |
| Tags | @smoke, @regression, @slow — filter with --grep |
Patterns
Pattern 1: Feature-Based File Structure
Use when: Any project — this is the default layout. Avoid when: Never. This is always correct.
Group tests by feature or domain, not by page class or test type.
Small project (< 20 tests):
tests/
├── auth.spec.ts
├── dashboard.spec.ts
├── settings.spec.ts
└── checkout.spec.ts
playwright.config.tsMedium project (20–200 tests):
tests/
├── auth/
│ ├── login.spec.ts
│ ├── signup.spec.ts
│ ├── password-reset.spec.ts
│ └── mfa.spec.ts
├── dashboard/
│ ├── widgets.spec.ts
│ ├── filters.spec.ts
│ └── export.spec.ts
├── checkout/
│ ├── cart.spec.ts
│ ├── payment.spec.ts
│ └── confirmation.spec.ts
├── settings/
│ ├── profile.spec.ts
│ └── notifications.spec.ts
└── fixtures/
├── auth.fixture.ts
└── test-data.ts
playwright.config.tsLarge project (200+ tests):
tests/
├── e2e/
│ ├── auth/
│ │ ├── login.spec.ts
│ │ ├── signup.spec.ts
│ │ ├── password-reset.spec.ts
│ │ └── mfa.spec.ts
│ ├── checkout/
│ │ ├── cart.spec.ts
│ │ ├── payment.spec.ts
│ │ ├── promo-codes.spec.ts
│ │ └── confirmation.spec.ts
│ ├── admin/
│ │ ├── user-management.spec.ts
│ │ ├── reports.spec.ts
│ │ └── audit-log.spec.ts
│ └── inventory/
│ ├── product-list.spec.ts
│ ├── product-detail.spec.ts
│ └── stock-management.spec.ts
├── api/
│ ├── users.spec.ts
│ ├── orders.spec.ts
│ └── products.spec.ts
├── visual/
│ ├── homepage.spec.ts
│ └── product-page.spec.ts
├── fixtures/
│ ├── auth.fixture.ts
│ ├── db.fixture.ts
│ └── base.fixture.ts
├── page-objects/
│ ├── login.page.ts
│ ├── dashboard.page.ts
│ └── checkout.page.ts
└── helpers/
├── test-data.ts
└── api-client.ts
playwright.config.tsPattern 2: Naming Conventions
Use when: Writing any test or file. Avoid when: Never.
TypeScript:
// tests/checkout/cart.spec.ts
import { test, expect } from "@playwright/test"
// Good: group by feature, describe behavior
test.describe("Shopping Cart", () => {
test("should add item to empty cart", async ({ page }) => {
await page.goto("/products/widget-a")
await page.getByRole("button", { name: "Add to cart" }).click()
await expect(page.getByTestId("cart-count")).toHaveText("1")
})
test("should update quantity when same item added twice", async ({ page }) => {
await page.goto("/products/widget-a")
await page.getByRole("button", { name: "Add to cart" }).click()
await page.getByRole("button", { name: "Add to cart" }).click()
await expect(page.getByTestId("cart-count")).toHaveText("2")
})
test("user can remove item from cart", async ({ page }) => {
await page.goto("/cart")
// ... setup item in cart via API or fixture
await page.getByRole("button", { name: "Remove" }).first().click()
await expect(page.getByText("Your cart is empty")).toBeVisible()
})
})JavaScript:
// tests/checkout/cart.spec.js
const { test, expect } = require("@playwright/test")
test.describe("Shopping Cart", () => {
test("should add item to empty cart", async ({ page }) => {
await page.goto("/products/widget-a")
await page.getByRole("button", { name: "Add to cart" }).click()
await expect(page.getByTestId("cart-count")).toHaveText("1")
})
test("should update quantity when same item added twice", async ({ page }) => {
await page.goto("/products/widget-a")
await page.getByRole("button", { name: "Add to cart" }).click()
await page.getByRole("button", { name: "Add to cart" }).click()
await expect(page.getByTestId("cart-count")).toHaveText("2")
})
test("user can remove item from cart", async ({ page }) => {
await page.goto("/cart")
await page.getByRole("button", { name: "Remove" }).first().click()
await expect(page.getByText("Your cart is empty")).toBeVisible()
})
})Naming rules:
| Element | Convention | Example |
|---|---|---|
| File name | kebab-case.spec.ts | password-reset.spec.ts |
test.describe() | Title Case, feature name | 'Password Reset' |
test() | Sentence starting with should or user can | 'should send reset email' |
| Page objects | PascalCase.page.ts | login.page.ts / LoginPage |
| Fixtures | kebab-case.fixture.ts | auth.fixture.ts |
Pattern 3: test.describe() Grouping
Use when: A file has multiple related tests that share context or setup. Avoid when: A file has only 1–3 tests with no shared setup — skip the describe wrapper.
TypeScript:
// tests/auth/login.spec.ts
import { test, expect } from "@playwright/test"
test.describe("Login", () => {
// Shared setup for all tests in this describe block
test.beforeEach(async ({ page }) => {
await page.goto("/login")
})
test("should login with valid credentials", async ({ page }) => {
await page.getByLabel("Email").fill("user@example.com")
await page.getByLabel("Password").fill("securepass123")
await page.getByRole("button", { name: "Sign in" }).click()
await expect(page).toHaveURL("/dashboard")
})
test("should show error for invalid password", async ({ page }) => {
await page.getByLabel("Email").fill("user@example.com")
await page.getByLabel("Password").fill("wrongpassword")
await page.getByRole("button", { name: "Sign in" }).click()
await expect(page.getByRole("alert")).toHaveText("Invalid credentials")
})
// One level of nesting — acceptable
test.describe("Rate Limiting", () => {
test("should lock account after 5 failed attempts", async ({ page }) => {
for (let i = 0; i < 5; i++) {
await page.getByLabel("Email").fill("user@example.com")
await page.getByLabel("Password").fill("wrong")
await page.getByRole("button", { name: "Sign in" }).click()
}
await expect(page.getByRole("alert")).toContainText("Account locked")
})
})
})JavaScript:
// tests/auth/login.spec.js
const { test, expect } = require("@playwright/test")
test.describe("Login", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/login")
})
test("should login with valid credentials", async ({ page }) => {
await page.getByLabel("Email").fill("user@example.com")
await page.getByLabel("Password").fill("securepass123")
await page.getByRole("button", { name: "Sign in" }).click()
await expect(page).toHaveURL("/dashboard")
})
test("should show error for invalid password", async ({ page }) => {
await page.getByLabel("Email").fill("user@example.com")
await page.getByLabel("Password").fill("wrongpassword")
await page.getByRole("button", { name: "Sign in" }).click()
await expect(page.getByRole("alert")).toHaveText("Invalid credentials")
})
test.describe("Rate Limiting", () => {
test("should lock account after 5 failed attempts", async ({ page }) => {
for (let i = 0; i < 5; i++) {
await page.getByLabel("Email").fill("user@example.com")
await page.getByLabel("Password").fill("wrong")
await page.getByRole("button", { name: "Sign in" }).click()
}
await expect(page.getByRole("alert")).toContainText("Account locked")
})
})
})Nesting limit: 2 levels max. If you need a third level, split into a separate file.
Pattern 4: Tags and Annotations
Use when: You need to categorize tests for selective runs (smoke suites, CI pipelines, skip known issues). Avoid when: All tests always run together and you have < 20 tests.
TypeScript:
// tests/checkout/payment.spec.ts
import { test, expect } from "@playwright/test"
// Tag in the test title — simplest approach
test("should process credit card payment @smoke", async ({ page }) => {
await page.goto("/checkout")
await page.getByLabel("Card number").fill("4242424242424242")
await page.getByLabel("Expiry").fill("12/28")
await page.getByLabel("CVC").fill("123")
await page.getByRole("button", { name: "Pay now" }).click()
await expect(page.getByText("Payment successful")).toBeVisible()
})
test("should handle declined card @regression", async ({ page }) => {
await page.goto("/checkout")
await page.getByLabel("Card number").fill("4000000000000002")
await page.getByLabel("Expiry").fill("12/28")
await page.getByLabel("CVC").fill("123")
await page.getByRole("button", { name: "Pay now" }).click()
await expect(page.getByRole("alert")).toContainText("Card declined")
})
// Tag via test.describe for group-level tagging
test.describe("Payment Edge Cases @regression", () => {
test("should handle network timeout during payment", async ({ page }) => {
await page.goto("/checkout")
// ... test implementation
await expect(page.getByText("Please try again")).toBeVisible()
})
})
// Annotations for test lifecycle control
test("should render 3D Secure iframe @slow", async ({ page }) => {
test.slow() // Triples the default timeout
await page.goto("/checkout/3ds")
// ... long-running 3D Secure flow
await expect(page.getByText("Verified")).toBeVisible()
})
test("should apply loyalty points discount", async ({ page }) => {
test.skip(process.env.CI !== "true", "Only run in CI — needs loyalty service")
await page.goto("/checkout")
// ...
})
test("should handle Apple Pay on Safari", async ({ page, browserName }) => {
test.skip(browserName !== "webkit", "Apple Pay only works in Safari")
await page.goto("/checkout")
// ...
})
test("should display PayPal button", async ({ page }) => {
test.fixme() // Known broken — tracked in JIRA-1234
await page.goto("/checkout")
await expect(page.getByRole("button", { name: "PayPal" })).toBeVisible()
})
test("should fail when submitting empty card form", async ({ page }) => {
test.fail() // This test is expected to fail — documents known bug
await page.goto("/checkout")
await page.getByRole("button", { name: "Pay now" }).click()
// Bug: currently no validation shown — this assertion will fail
await expect(page.getByRole("alert")).toBeVisible()
})JavaScript:
// tests/checkout/payment.spec.js
const { test, expect } = require("@playwright/test")
test("should process credit card payment @smoke", async ({ page }) => {
await page.goto("/checkout")
await page.getByLabel("Card number").fill("4242424242424242")
await page.getByLabel("Expiry").fill("12/28")
await page.getByLabel("CVC").fill("123")
await page.getByRole("button", { name: "Pay now" }).click()
await expect(page.getByText("Payment successful")).toBeVisible()
})
test("should handle declined card @regression", async ({ page }) => {
await page.goto("/checkout")
await page.getByLabel("Card number").fill("4000000000000002")
await page.getByLabel("Expiry").fill("12/28")
await page.getByLabel("CVC").fill("123")
await page.getByRole("button", { name: "Pay now" }).click()
await expect(page.getByRole("alert")).toContainText("Card declined")
})
test.describe("Payment Edge Cases @regression", () => {
test("should handle network timeout during payment", async ({ page }) => {
await page.goto("/checkout")
await expect(page.getByText("Please try again")).toBeVisible()
})
})
test("should render 3D Secure iframe @slow", async ({ page }) => {
test.slow()
await page.goto("/checkout/3ds")
await expect(page.getByText("Verified")).toBeVisible()
})
test("should apply loyalty points discount", async ({ page }) => {
test.skip(process.env.CI !== "true", "Only run in CI — needs loyalty service")
await page.goto("/checkout")
})
test("should handle Apple Pay on Safari", async ({ page, browserName }) => {
test.skip(browserName !== "webkit", "Apple Pay only works in Safari")
await page.goto("/checkout")
})
test("should display PayPal button", async ({ page }) => {
test.fixme() // Known broken — tracked in JIRA-1234
await page.goto("/checkout")
await expect(page.getByRole("button", { name: "PayPal" })).toBeVisible()
})
test("should fail when submitting empty card form", async ({ page }) => {
test.fail()
await page.goto("/checkout")
await page.getByRole("button", { name: "Pay now" }).click()
await expect(page.getByRole("alert")).toBeVisible()
})Annotation cheat sheet:
| Annotation | Effect | When to use |
|---|---|---|
test.skip() | Skips the test entirely | Feature not available in this env/browser |
test.skip(condition, reason) | Conditional skip | Browser-specific or env-specific tests |
test.fixme() | Skips and marks as "needs fix" | Known bug, not yet fixed |
test.slow() | Triples the timeout | Tests with inherently slow workflows |
test.fail() | Expects the test to fail; fails if it passes | Documenting a known bug with a regression guard |
test.info().annotations | Add custom annotations | Custom metadata for reports |
Pattern 5: Test Filtering
Use when: Running a subset of tests from the CLI or CI. Avoid when: Never — know these commands.
# By tag (in test title)
npx playwright test --grep @smoke
npx playwright test --grep @regression
npx playwright test --grep-invert @slow # everything except @slow
# By file
npx playwright test tests/auth/
npx playwright test tests/checkout/payment.spec.ts
# By test name
npx playwright test --grep "should login"
# By project (from playwright.config)
npx playwright test --project=chromium
npx playwright test --project=mobile-safari
# Combine filters
npx playwright test --grep @smoke --project=chromium
npx playwright test tests/checkout/ --grep @smoke
# By line number — run a single test
npx playwright test tests/auth/login.spec.ts:15Using tags with test.describe.configure():
TypeScript:
// playwright.config.ts
import { defineConfig } from "@playwright/test"
export default defineConfig({
projects: [
{
name: "smoke",
testMatch: "**/*.spec.ts",
grep: /@smoke/,
},
{
name: "regression",
testMatch: "**/*.spec.ts",
grep: /@regression/,
},
{
name: "all",
testMatch: "**/*.spec.ts",
grepInvert: /@slow/,
},
],
})JavaScript:
// playwright.config.js
const { defineConfig } = require("@playwright/test")
module.exports = defineConfig({
projects: [
{
name: "smoke",
testMatch: "**/*.spec.js",
grep: /@smoke/,
},
{
name: "regression",
testMatch: "**/*.spec.js",
grep: /@regression/,
},
{
name: "all",
testMatch: "**/*.spec.js",
grepInvert: /@slow/,
},
],
})Pattern 6: Parallel vs Serial Execution
Use when: Deciding how tests run relative to each other. Avoid when: You have < 5 tests (parallelism doesn't matter).
Default: always parallel. Set fullyParallel: true in config.
TypeScript:
// playwright.config.ts
import { defineConfig } from "@playwright/test"
export default defineConfig({
fullyParallel: true, // All tests run in parallel by default
workers: process.env.CI ? 1 : undefined, // Use all cores locally, 1 in CI (or adjust)
})JavaScript:
// playwright.config.js
const { defineConfig } = require("@playwright/test")
module.exports = defineConfig({
fullyParallel: true,
workers: process.env.CI ? 1 : undefined,
})Serial execution — use only when tests share external state you cannot isolate:
TypeScript:
// tests/onboarding/wizard.spec.ts
import { test, expect } from "@playwright/test"
// ONLY use serial when steps truly depend on prior state that cannot be set up independently
test.describe.serial("Onboarding Wizard", () => {
test("step 1: user enters company name", async ({ page }) => {
await page.goto("/onboarding")
await page.getByLabel("Company name").fill("Acme Corp")
await page.getByRole("button", { name: "Next" }).click()
await expect(page).toHaveURL("/onboarding/step-2")
})
test("step 2: user selects plan", async ({ page }) => {
await page.goto("/onboarding/step-2")
await page.getByRole("radio", { name: "Pro plan" }).check()
await page.getByRole("button", { name: "Next" }).click()
await expect(page).toHaveURL("/onboarding/step-3")
})
test("step 3: user confirms and completes", async ({ page }) => {
await page.goto("/onboarding/step-3")
await page.getByRole("button", { name: "Complete setup" }).click()
await expect(page.getByText("Welcome to Acme Corp")).toBeVisible()
})
})JavaScript:
// tests/onboarding/wizard.spec.js
const { test, expect } = require("@playwright/test")
test.describe.serial("Onboarding Wizard", () => {
test("step 1: user enters company name", async ({ page }) => {
await page.goto("/onboarding")
await page.getByLabel("Company name").fill("Acme Corp")
await page.getByRole("button", { name: "Next" }).click()
await expect(page).toHaveURL("/onboarding/step-2")
})
test("step 2: user selects plan", async ({ page }) => {
await page.goto("/onboarding/step-2")
await page.getByRole("radio", { name: "Pro plan" }).check()
await page.getByRole("button", { name: "Next" }).click()
await expect(page).toHaveURL("/onboarding/step-3")
})
test("step 3: user confirms and completes", async ({ page }) => {
await page.goto("/onboarding/step-3")
await page.getByRole("button", { name: "Complete setup" }).click()
await expect(page.getByText("Welcome to Acme Corp")).toBeVisible()
})
})Better alternative: test the full flow in one test with test.step():
TypeScript:
// tests/onboarding/wizard.spec.ts
import { test, expect } from "@playwright/test"
test("user completes full onboarding wizard", async ({ page }) => {
await test.step("enter company name", async () => {
await page.goto("/onboarding")
await page.getByLabel("Company name").fill("Acme Corp")
await page.getByRole("button", { name: "Next" }).click()
await expect(page).toHaveURL("/onboarding/step-2")
})
await test.step("select plan", async () => {
await page.getByRole("radio", { name: "Pro plan" }).check()
await page.getByRole("button", { name: "Next" }).click()
await expect(page).toHaveURL("/onboarding/step-3")
})
await test.step("confirm and complete", async () => {
await page.getByRole("button", { name: "Complete setup" }).click()
await expect(page.getByText("Welcome to Acme Corp")).toBeVisible()
})
})JavaScript:
// tests/onboarding/wizard.spec.js
const { test, expect } = require("@playwright/test")
test("user completes full onboarding wizard", async ({ page }) => {
await test.step("enter company name", async () => {
await page.goto("/onboarding")
await page.getByLabel("Company name").fill("Acme Corp")
await page.getByRole("button", { name: "Next" }).click()
await expect(page).toHaveURL("/onboarding/step-2")
})
await test.step("select plan", async () => {
await page.getByRole("radio", { name: "Pro plan" }).check()
await page.getByRole("button", { name: "Next" }).click()
await expect(page).toHaveURL("/onboarding/step-3")
})
await test.step("confirm and complete", async () => {
await page.getByRole("button", { name: "Complete setup" }).click()
await expect(page.getByText("Welcome to Acme Corp")).toBeVisible()
})
})Pattern 7: Monorepo Testing
Use when: A single repository contains multiple apps or packages that each need E2E tests. Avoid when: Single app repo.
TypeScript:
// playwright.config.ts (root of monorepo)
import { defineConfig } from "@playwright/test"
export default defineConfig({
projects: [
{
name: "web-app",
testDir: "./apps/web/tests",
use: {
baseURL: "http://localhost:3000",
},
},
{
name: "admin-panel",
testDir: "./apps/admin/tests",
use: {
baseURL: "http://localhost:3001",
},
},
{
name: "marketing-site",
testDir: "./apps/marketing/tests",
use: {
baseURL: "http://localhost:4000",
},
},
],
})JavaScript:
// playwright.config.js (root of monorepo)
const { defineConfig } = require("@playwright/test")
module.exports = defineConfig({
projects: [
{
name: "web-app",
testDir: "./apps/web/tests",
use: {
baseURL: "http://localhost:3000",
},
},
{
name: "admin-panel",
testDir: "./apps/admin/tests",
use: {
baseURL: "http://localhost:3001",
},
},
{
name: "marketing-site",
testDir: "./apps/marketing/tests",
use: {
baseURL: "http://localhost:4000",
},
},
],
})Monorepo directory layout:
monorepo/
├── apps/
│ ├── web/
│ │ ├── src/
│ │ └── tests/
│ │ ├── auth/
│ │ │ └── login.spec.ts
│ │ └── dashboard/
│ │ └── widgets.spec.ts
│ ├── admin/
│ │ ├── src/
│ │ └── tests/
│ │ └── user-management.spec.ts
│ └── marketing/
│ ├── src/
│ └── tests/
│ └── landing-page.spec.ts
├── packages/
│ └── shared-fixtures/
│ ├── auth.fixture.ts
│ └── index.ts
└── playwright.config.tsRun only one app's tests:
npx playwright test --project=web-app
npx playwright test --project=admin-panelDecision Guide
How many tests do you have?
│
├── < 20 tests (small)
│ ├── Flat structure: all .spec.ts files in tests/
│ ├── No subdirectories needed
│ ├── Tags optional — just run everything
│ └── fullyParallel: true, single config project
│
├── 20–200 tests (medium)
│ ├── Feature subdirectories: tests/auth/, tests/checkout/
│ ├── Use @smoke tag for critical-path subset (< 15 min)
│ ├── Shared fixtures in tests/fixtures/
│ ├── Page objects in tests/page-objects/ (if you use them)
│ └── Consider separate projects for different browsers
│
└── 200+ tests (large)
├── Top-level split: tests/e2e/, tests/api/, tests/visual/
├── Feature subdirectories under each
├── @smoke, @regression, @slow tags — multiple CI pipelines
├── Sharding in CI for faster runs
├── Project-based filtering: smoke project, full project
└── Shared fixtures as a package in monorepoShould I use test.describe.serial()?
│
├── Can each test set up its own state via API/fixture?
│ └── YES → Do NOT use serial. Use independent parallel tests.
│
├── Is this a multi-step wizard where each step changes server state
│ that cannot be reset between tests?
│ └── Write it as ONE test with test.step() instead.
│
└── Is this genuinely stateful (e.g., database migration sequence)?
└── Use serial as last resort. Add a comment explaining why.Anti-Patterns
One giant test file
// BAD: tests/all-tests.spec.ts with 80 tests and 2000+ lines
test.describe("Everything", () => {
test("login works", async ({ page }) => {
/* ... */
})
test("signup works", async ({ page }) => {
/* ... */
})
test("cart works", async ({ page }) => {
/* ... */
})
// ... 77 more tests
})Fix: Split by feature — one file per feature area, 5–15 tests per file.
Meaningless test names
// BAD
test("test1", async ({ page }) => {
/* ... */
})
test("test2", async ({ page }) => {
/* ... */
})
test("payment test", async ({ page }) => {
/* ... */
})
test("it works", async ({ page }) => {
/* ... */
})Fix: Describe behavior. When a test fails, the name should tell you what broke.
// GOOD
test("should reject expired credit card", async ({ page }) => {
/* ... */
})
test("user can update shipping address during checkout", async ({ page }) => {
/* ... */
})Deep describe nesting
// BAD: 3+ levels deep — hard to read, hard to find tests in reports
test.describe("Auth", () => {
test.describe("Login", () => {
test.describe("With MFA", () => {
test.describe("SMS", () => {
test("should send code", async ({ page }) => {
/* ... */
})
})
})
})
})Fix: Max 2 levels. Split deeper nesting into separate files.
// GOOD: tests/auth/login-mfa-sms.spec.ts
test.describe("Login with SMS MFA", () => {
test("should send verification code", async ({ page }) => {
/* ... */
})
test("should reject invalid code", async ({ page }) => {
/* ... */
})
})Using test.describe.serial() as the default
// BAD: serial for no reason — kills parallelism, creates hidden dependencies
test.describe.serial("User Profile", () => {
test("should display profile page", async ({ page }) => {
/* ... */
})
test("should update display name", async ({ page }) => {
/* ... */
})
test("should upload avatar", async ({ page }) => {
/* ... */
})
})Fix: Each test should be independent. Use beforeEach or fixtures to set up state.
// GOOD
test.describe("User Profile", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/profile")
})
test("should display profile page", async ({ page }) => {
/* ... */
})
test("should update display name", async ({ page }) => {
/* ... */
})
test("should upload avatar", async ({ page }) => {
/* ... */
})
})Relying on test execution order
// BAD: test 2 depends on test 1 creating data
test("should create a product", async ({ page }) => {
// creates "Widget X" in the database
})
test("should edit the product", async ({ page }) => {
// assumes "Widget X" exists — breaks if run alone or in parallel
})Fix: Each test creates its own data via API calls or fixtures.
// GOOD
test("should edit a product", async ({ page, request }) => {
// Set up test data independently
const response = await request.post("/api/products", {
data: { name: "Widget X", price: 9.99 },
})
const product = await response.json()
await page.goto(`/products/${product.id}/edit`)
await page.getByLabel("Name").fill("Widget X Updated")
await page.getByRole("button", { name: "Save" }).click()
await expect(page.getByText("Widget X Updated")).toBeVisible()
})Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| Tests pass alone but fail together | Shared state between tests (cookies, DB rows, global variables) | Isolate each test — use fixtures for setup/teardown |
--grep @smoke matches nothing | Tag not in test title string | Verify the tag appears literally in test('... @smoke', ...) |
| Serial tests cascade-fail | One test fails, all subsequent tests skip | Rewrite as independent tests or use test.step() in a single test |
| Tests run slower than expected | fullyParallel not set | Add fullyParallel: true in playwright.config |
| Wrong tests run in CI | testMatch or testDir misconfigured | Check playwright.config — print resolved config with npx playwright test --list |
| Monorepo tests pick up wrong files | Default testDir is . | Set explicit testDir per project |
Related
- core/configuration.md —
testMatch,testDir,fullyParallel,workers - core/fixtures-and-hooks.md — shared setup via fixtures instead of
beforeAll - core/test-architecture.md — when to write E2E vs API vs component tests
- ci/parallel-and-sharding.md — CI sharding for large suites
- ci/projects-and-dependencies.md — multi-project config