SurfSense/.cursor/skills/playwright-testing/test-organization.md
2026-05-04 13:54:13 +05:30

946 lines
29 KiB
Markdown
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (20200 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 13 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
├── 20200 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, 515 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