# Configuration

> **When to use**: Setting up a new Playwright project, adjusting timeouts, adding browser targets, configuring CI behavior, or connecting environment-specific settings.

## Quick Reference

```
npx playwright init                    # scaffold config + first test
npx playwright test --config=custom.config.ts  # use non-default config
npx playwright test --project=chromium # run single project
npx playwright test --reporter=html    # override reporter
npx playwright show-report             # open last HTML report
DEBUG=pw:api npx playwright test       # verbose Playwright logging
```

## Production-Ready Config (Copy-Paste Starter)

### TypeScript

```ts
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test"
import dotenv from "dotenv"
import path from "path"

// Load environment variables from .env file
dotenv.config({ path: path.resolve(__dirname, ".env") })

export default defineConfig({
  // ── Test discovery ──────────────────────────────────────────────
  testDir: "./tests",
  testMatch: "**/*.spec.ts",

  // ── Execution ───────────────────────────────────────────────────
  fullyParallel: true,
  forbidOnly: !!process.env.CI, // fail CI if test.only left in
  retries: process.env.CI ? 2 : 0, // retry flakes in CI only
  workers: process.env.CI ? "50%" : undefined, // half CPU in CI, auto locally

  // ── Reporting ───────────────────────────────────────────────────
  reporter: process.env.CI
    ? [["html", { open: "never" }], ["github"]]
    : [["html", { open: "on-failure" }]],

  // ── Timeouts ────────────────────────────────────────────────────
  timeout: 30_000, // per-test timeout
  expect: {
    timeout: 5_000, // per-assertion retry timeout
  },

  // ── Shared browser context options ──────────────────────────────
  use: {
    baseURL: process.env.BASE_URL || "http://localhost:3000",
    actionTimeout: 10_000, // click, fill, etc.
    navigationTimeout: 15_000, // goto, waitForURL, etc.

    // Artifact collection
    trace: "on-first-retry", // full trace on first retry only
    screenshot: "only-on-failure", // screenshot on failure
    video: "retain-on-failure", // video only kept for failures

    // Sensible defaults
    locale: "en-US",
    timezoneId: "America/New_York",
    extraHTTPHeaders: {
      "x-test-automation": "playwright",
    },
  },

  // ── Projects (browser targets) ─────────────────────────────────
  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"] },
    },
  ],

  // ── Dev server ──────────────────────────────────────────────────
  webServer: {
    command: "npm run dev",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
    timeout: 120_000, // 2 min for cold builds
    stdout: "pipe",
    stderr: "pipe",
  },
})
```

### JavaScript

```js
// playwright.config.js
const { defineConfig, devices } = require("@playwright/test")
const dotenv = require("dotenv")
const path = require("path")

dotenv.config({ path: path.resolve(__dirname, ".env") })

module.exports = defineConfig({
  testDir: "./tests",
  testMatch: "**/*.spec.js",

  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:3000",
    actionTimeout: 10_000,
    navigationTimeout: 15_000,

    trace: "on-first-retry",
    screenshot: "only-on-failure",
    video: "retain-on-failure",

    locale: "en-US",
    timezoneId: "America/New_York",
    extraHTTPHeaders: {
      "x-test-automation": "playwright",
    },
  },

  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 dev",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
    stdout: "pipe",
    stderr: "pipe",
  },
})
```

## Patterns

### Pattern 1: Environment-Specific Configuration

**Use when**: Tests run against dev, staging, and production environments.
**Avoid when**: Single-environment local-only projects.

#### TypeScript

```ts
// playwright.config.ts
import { defineConfig } from "@playwright/test"
import dotenv from "dotenv"
import path from "path"

// Load environment-specific .env file: .env.staging, .env.production, etc.
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:3000", retries: 0 },
  staging: { baseURL: "https://staging.example.com", retries: 2 },
  production: { baseURL: "https://www.example.com", retries: 2 },
}

const env = envConfig[ENV]

export default defineConfig({
  testDir: "./tests",
  testMatch: "**/*.spec.ts",
  retries: env.retries,
  use: {
    baseURL: env.baseURL,
  },
})
```

```bash
# Run against staging
TEST_ENV=staging npx playwright test

# Run against production (subset of smoke tests)
TEST_ENV=production npx playwright test --grep @smoke
```

#### JavaScript

```js
// playwright.config.js
const { defineConfig } = require("@playwright/test")
const dotenv = require("dotenv")
const path = require("path")

const ENV = process.env.TEST_ENV || "local"
dotenv.config({ path: path.resolve(__dirname, `.env.${ENV}`) })

const envConfig = {
  local: { baseURL: "http://localhost:3000", retries: 0 },
  staging: { baseURL: "https://staging.example.com", retries: 2 },
  production: { baseURL: "https://www.example.com", retries: 2 },
}

const env = envConfig[ENV]

module.exports = defineConfig({
  testDir: "./tests",
  testMatch: "**/*.spec.js",
  retries: env.retries,
  use: {
    baseURL: env.baseURL,
  },
})
```

### Pattern 2: Multi-Project with Setup Dependencies

**Use when**: Tests need shared authentication state or database seeding before running.
**Avoid when**: Tests are fully independent with no shared setup phase.

#### TypeScript

```ts
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test"

export default defineConfig({
  testDir: "./tests",
  testMatch: "**/*.spec.ts",

  projects: [
    // Setup project runs first, saves auth state
    {
      name: "setup",
      testMatch: /global\.setup\.ts/,
    },

    // Browser projects depend on setup
    {
      name: "chromium",
      use: {
        ...devices["Desktop Chrome"],
        storageState: "playwright/.auth/user.json",
      },
      dependencies: ["setup"],
    },
    {
      name: "firefox",
      use: {
        ...devices["Desktop Firefox"],
        storageState: "playwright/.auth/user.json",
      },
      dependencies: ["setup"],
    },
  ],
})
```

```ts
// tests/global.setup.ts
import { test as setup, expect } from "@playwright/test"

const authFile = "playwright/.auth/user.json"

setup("authenticate", async ({ page }) => {
  await page.goto("/login")
  await page.getByLabel("Email").fill("user@example.com")
  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 })
})
```

#### JavaScript

```js
// playwright.config.js
const { defineConfig, devices } = require("@playwright/test")

module.exports = defineConfig({
  testDir: "./tests",
  testMatch: "**/*.spec.js",

  projects: [
    {
      name: "setup",
      testMatch: /global\.setup\.js/,
    },
    {
      name: "chromium",
      use: {
        ...devices["Desktop Chrome"],
        storageState: "playwright/.auth/user.json",
      },
      dependencies: ["setup"],
    },
    {
      name: "firefox",
      use: {
        ...devices["Desktop Firefox"],
        storageState: "playwright/.auth/user.json",
      },
      dependencies: ["setup"],
    },
  ],
})
```

```js
// tests/global.setup.js
const { test: setup, expect } = require("@playwright/test")

const authFile = "playwright/.auth/user.json"

setup("authenticate", async ({ page }) => {
  await page.goto("/login")
  await page.getByLabel("Email").fill("user@example.com")
  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 })
})
```

### Pattern 3: `webServer` with Build Step

**Use when**: Tests need a running application server. Let Playwright manage the server lifecycle.
**Avoid when**: Testing against an already-deployed environment (staging/prod).

#### TypeScript

```ts
// playwright.config.ts
import { defineConfig } from "@playwright/test"

export default defineConfig({
  testDir: "./tests",
  testMatch: "**/*.spec.ts",
  use: {
    baseURL: "http://localhost:3000",
  },
  webServer: {
    command: process.env.CI
      ? "npm run build && npm run start" // production build in CI
      : "npm run dev", // dev server locally
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
    stdout: "pipe",
    stderr: "pipe",
    env: {
      NODE_ENV: "test",
      DATABASE_URL: process.env.DATABASE_URL || "postgresql://localhost:5432/test",
    },
  },
})
```

#### JavaScript

```js
// playwright.config.js
const { defineConfig } = require("@playwright/test")

module.exports = defineConfig({
  testDir: "./tests",
  testMatch: "**/*.spec.js",
  use: {
    baseURL: "http://localhost:3000",
  },
  webServer: {
    command: process.env.CI ? "npm run build && npm run start" : "npm run dev",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
    stdout: "pipe",
    stderr: "pipe",
    env: {
      NODE_ENV: "test",
      DATABASE_URL: process.env.DATABASE_URL || "postgresql://localhost:5432/test",
    },
  },
})
```

### Pattern 4: `globalSetup` / `globalTeardown`

**Use when**: One-time non-browser work: seeding a database, starting a service, setting env vars. Runs once per `npx playwright test` invocation.
**Avoid when**: You need browser context (use a setup project instead) or per-test isolation (use fixtures).

#### TypeScript

```ts
// playwright.config.ts
import { defineConfig } from "@playwright/test"

export default defineConfig({
  testDir: "./tests",
  testMatch: "**/*.spec.ts",
  globalSetup: "./tests/global-setup.ts",
  globalTeardown: "./tests/global-teardown.ts",
})
```

```ts
// tests/global-setup.ts
import { FullConfig } from "@playwright/test"

async function globalSetup(config: FullConfig) {
  // Seed test database
  const { execSync } = await import("child_process")
  execSync("npx prisma db seed", { stdio: "inherit" })

  // Store data for tests to use via environment variables
  process.env.TEST_RUN_ID = `run-${Date.now()}`
}

export default globalSetup
```

```ts
// tests/global-teardown.ts
import { FullConfig } from "@playwright/test"

async function globalTeardown(config: FullConfig) {
  const { execSync } = await import("child_process")
  execSync("npx prisma db push --force-reset", { stdio: "inherit" })
}

export default globalTeardown
```

#### JavaScript

```js
// playwright.config.js
const { defineConfig } = require("@playwright/test")

module.exports = defineConfig({
  testDir: "./tests",
  testMatch: "**/*.spec.js",
  globalSetup: "./tests/global-setup.js",
  globalTeardown: "./tests/global-teardown.js",
})
```

```js
// tests/global-setup.js
const { execSync } = require("child_process")

async function globalSetup(config) {
  execSync("npx prisma db seed", { stdio: "inherit" })
  process.env.TEST_RUN_ID = `run-${Date.now()}`
}

module.exports = globalSetup
```

```js
// tests/global-teardown.js
const { execSync } = require("child_process")

async function globalTeardown(config) {
  execSync("npx prisma db push --force-reset", { stdio: "inherit" })
}

module.exports = globalTeardown
```

### Pattern 5: `.env` File Setup

**Use when**: Managing secrets, URLs, or feature flags without hardcoding.
**Avoid when**: Never commit `.env` files with real secrets. Provide `.env.example` instead.

```bash
# .env.example (commit this)
BASE_URL=http://localhost:3000
TEST_PASSWORD=
API_KEY=

# .env.local (gitignored)
BASE_URL=http://localhost:3000
TEST_PASSWORD=s3cret
API_KEY=test-key-abc123

# .env.staging (gitignored)
BASE_URL=https://staging.example.com
TEST_PASSWORD=staging-password
API_KEY=staging-key-xyz789
```

```bash
# .gitignore
.env
.env.local
.env.staging
.env.production
playwright/.auth/
```

Install dotenv:

```bash
npm install -D dotenv
```

### Pattern 6: Trace, Screenshot, and Video Settings

**Use when**: Deciding artifact collection strategy for local development vs CI.

| Setting      | Local                         | CI                    | Why                                         |
| ------------ | ----------------------------- | --------------------- | ------------------------------------------- |
| `trace`      | `'off'` or `'on-first-retry'` | `'on-first-retry'`    | Traces are large; only collect on failure   |
| `screenshot` | `'off'`                       | `'only-on-failure'`   | Useful for CI debugging only                |
| `video`      | `'off'`                       | `'retain-on-failure'` | Video is slow to record; keep only failures |

#### TypeScript

```ts
// playwright.config.ts
import { defineConfig } from "@playwright/test"

export default defineConfig({
  testDir: "./tests",
  testMatch: "**/*.spec.ts",
  use: {
    // CI: capture everything on failure; Local: minimal overhead
    trace: process.env.CI ? "on-first-retry" : "off",
    screenshot: process.env.CI ? "only-on-failure" : "off",
    video: process.env.CI ? "retain-on-failure" : "off",
  },
})
```

#### JavaScript

```js
// playwright.config.js
const { defineConfig } = require("@playwright/test")

module.exports = defineConfig({
  testDir: "./tests",
  testMatch: "**/*.spec.js",
  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",
  },
})
```

## Decision Guide

### Which Timeout to Adjust

| Symptom                                                         | Timeout to Change   | Default      | Recommended Range         |
| --------------------------------------------------------------- | ------------------- | ------------ | ------------------------- |
| Test takes too long overall                                     | `timeout`           | 30s          | 30-60s (never above 120s) |
| Assertion `expect()` keeps retrying too long or not long enough | `expect.timeout`    | 5s           | 5-10s                     |
| `page.goto()` or `waitForURL()` times out                       | `navigationTimeout` | 30s          | 10-30s                    |
| `click()`, `fill()`, `check()` time out                         | `actionTimeout`     | 0 (no limit) | 10-15s                    |
| Dev server slow to start                                        | `webServer.timeout` | 60s          | 60-180s                   |

### Server Management

| Scenario                                  | Approach                                                | Why                                             |
| ----------------------------------------- | ------------------------------------------------------- | ----------------------------------------------- |
| Local dev + CI, app in same repo          | `webServer` with `reuseExistingServer: !process.env.CI` | Playwright manages server; reuses yours locally |
| Separate frontend/backend repos           | Manual start or Docker Compose                          | `webServer` can only run one command            |
| Testing deployed staging/production       | No `webServer`; set `baseURL` via env var               | Server already running remotely                 |
| Multiple services needed (API + frontend) | Array of `webServer` entries                            | Each gets its own command and URL health check  |

### Single vs Multi-Project Config

| Scenario                              | Approach                                             | Why                                        |
| ------------------------------------- | ---------------------------------------------------- | ------------------------------------------ |
| Starting out, early development       | Single project (chromium only)                       | Faster feedback, simpler config            |
| Pre-release cross-browser validation  | Multi-project: chromium + firefox + webkit           | Catch rendering/API differences            |
| Mobile-responsive app                 | Add mobile projects alongside desktop                | Viewport + touch differences matter        |
| Authenticated + unauthenticated tests | Setup project + dependent projects                   | Share auth state without re-login per test |
| CI pipeline with tight time budget    | Chromium in PR checks; all browsers on merge to main | Balance speed vs coverage                  |

### globalSetup vs Setup Projects vs Fixtures

| Need                                              | Use                                | Why                                           |
| ------------------------------------------------- | ---------------------------------- | --------------------------------------------- |
| One-time DB seed or external service prep         | `globalSetup`                      | Runs once, no browser needed                  |
| Shared browser auth (login once, reuse cookies)   | Setup project with `dependencies`  | Needs browser context; `globalSetup` has none |
| Per-test isolated state (unique user, fresh data) | Custom fixture via `test.extend()` | Each test gets its own instance with teardown |
| Cleanup after all tests                           | `globalTeardown`                   | Runs once at the end regardless of pass/fail  |

## Anti-Patterns

| Don't Do This                                                                                            | Problem                                                                                 | Do This Instead                                                                      |
| -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `timeout: 300_000` globally                                                                              | Masks flaky tests; CI runs take forever                                                 | Fix the root cause; keep timeout at 30s; raise `navigationTimeout` only if warranted |
| Hardcoded URLs in tests: `page.goto('http://localhost:3000/login')`                                      | Breaks in every non-local environment                                                   | Use `baseURL` in config, then `page.goto('/login')`                                  |
| Running chromium + firefox + webkit on every PR                                                          | 3x CI time for marginal benefit on most PRs                                             | Chromium on PRs; all browsers on main branch merges                                  |
| `trace: 'on'` always in CI                                                                               | Huge artifacts, slow uploads, disk full                                                 | `trace: 'on-first-retry'` -- only captures when a test fails and retries             |
| `video: 'on'` always in CI                                                                               | Massive CI storage; recording slows tests                                               | `video: 'retain-on-failure'` -- records all but only keeps failures                  |
| Config values inline in test files: `test.use({ viewport: { width: 1280, height: 720 } })` in every file | Scattered, hard to maintain, inconsistent                                               | Define once in project config; override per-file only when genuinely needed          |
| `retries: 3` locally                                                                                     | Hides flakiness during development                                                      | `retries: 0` locally, `retries: 2` in CI                                             |
| No `forbidOnly` in CI                                                                                    | Accidentally committed `test.only` runs a single test, everything else silently skipped | `forbidOnly: !!process.env.CI`                                                       |
| `globalSetup` for browser auth                                                                           | No browser context available; complex workarounds needed                                | Use a setup project with `dependencies`                                              |
| Committing `.env` files with real credentials                                                            | Security risk                                                                           | Commit `.env.example` only; gitignore real `.env` files                              |

## Troubleshooting

### "baseURL" not working -- tests navigate to full URL

**Cause**: Using `page.goto('http://localhost:3000/path')` instead of `page.goto('/path')`. When `goto` receives an absolute URL, it ignores `baseURL`.

**Fix**: Always pass relative paths to `page.goto()`:

```ts
// Wrong -- ignores baseURL
await page.goto("http://localhost:3000/dashboard")

// Correct -- uses baseURL from config
await page.goto("/dashboard")
```

### webServer starts but tests still fail with connection refused

**Cause**: The `url` in `webServer` does not match what the server actually serves, or the health check endpoint returns non-200.

**Fix**: Ensure `webServer.url` matches the actual server address. Add a health check route if needed:

```ts
// playwright.config.ts
import { defineConfig } from "@playwright/test"

export default defineConfig({
  testDir: "./tests",
  testMatch: "**/*.spec.ts",
  webServer: {
    command: "npm run dev",
    url: "http://localhost:3000/api/health", // use a real endpoint
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
})
```

### Tests pass locally but timeout in CI

**Cause**: CI machines are slower. Default timeouts too tight for CI hardware.

**Fix**: Increase `navigationTimeout` for CI, reduce `workers` to avoid resource contention:

```ts
// playwright.config.ts
import { defineConfig } from "@playwright/test"

export default defineConfig({
  testDir: "./tests",
  testMatch: "**/*.spec.ts",
  workers: process.env.CI ? "50%" : undefined,
  use: {
    navigationTimeout: process.env.CI ? 30_000 : 15_000,
    actionTimeout: process.env.CI ? 15_000 : 10_000,
  },
})
```

### "Error: page.goto: Target page, context or browser has been closed"

**Cause**: Test exceeded its `timeout` and Playwright tore down the browser while an action was still running.

**Fix**: Do not increase the global timeout. Instead, find the slow step using `--trace on` and fix it. Common causes: waiting for a slow API, unresolved network request, or missing `await`.

```bash
# Record a trace for debugging
npx playwright test --trace on
npx playwright show-report
```

## Related

- [core/fixtures-and-hooks.md](fixtures-and-hooks.md) -- custom fixtures that replace `globalSetup` for per-test state
- [core/test-organization.md](test-organization.md) -- file structure, naming conventions, test grouping
- [core/authentication.md](authentication.md) -- setup projects for shared auth state
- [ci/ci-github-actions.md](../ci/ci-github-actions.md) -- CI-specific config and caching
- [ci/projects-and-dependencies.md](../ci/projects-and-dependencies.md) -- advanced multi-project patterns
