# 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](foundations/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.ts
```

**Medium 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.ts
```

**Large 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.ts
```

---

### Pattern 2: Naming Conventions

**Use when**: Writing any test or file.
**Avoid when**: Never.

**TypeScript:**

```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:**

```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:**

```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:**

```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:**

```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:**

```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.

```bash
# 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:15
```

**Using tags with `test.describe.configure()`:**

**TypeScript:**

```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:**

```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:**

```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:**

```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:**

```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:**

```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:**

```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:**

```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:**

```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:**

```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.ts
```

Run only one app's tests:

```bash
npx playwright test --project=web-app
npx playwright test --project=admin-panel
```

## Decision 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 monorepo
```

```
Should 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

```typescript
// 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

```typescript
// 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.

```typescript
// GOOD
test("should reject expired credit card", async ({ page }) => {
  /* ... */
})
test("user can update shipping address during checkout", async ({ page }) => {
  /* ... */
})
```

---

### Deep describe nesting

```typescript
// 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.

```typescript
// 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

```typescript
// 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.

```typescript
// 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

```typescript
// 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.

```typescript
// 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](foundations/configuration.md) — `testMatch`, `testDir`, `fullyParallel`, `workers`
- [core/fixtures-and-hooks.md](foundations/fixtures-and-hooks.md) — shared setup via fixtures instead of `beforeAll`
- [core/test-architecture.md](decisions/test-architecture.md) — when to write E2E vs API vs component tests
- [ci/parallel-and-sharding.md](infrastructure/parallel-and-sharding.md) — CI sharding for large suites
- [ci/projects-and-dependencies.md](infrastructure/projects-and-dependencies.md) — multi-project config
