# 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 = { 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