📖 The Story: The Mystery of the Flaky CI

Imagine a team where tests always passed on laptops but failed randomly on CI. The lead, confused, increased timeouts to 60 seconds. Still flaky. Added --workers=1. Still flaky. Finally, a senior engineer opened playwright.config.ts and found: no retries configured, no base URL set, headless mode mismatched, and screenshots disabled on failure. Five lines of config changes later, the CI was green and stable.

The config file is not bureaucracy—it's your control tower. Every decision about how tests run lives here. Master it, and you control your entire test infrastructure from one file.

🎯 Why the Config File Matters

🎯

Single Source of Truth

All settings in one place—no scattered CLI flags or environment variables.

🔄

Consistency

Every developer and CI pipeline runs tests with identical configuration.

🧩

Multi-Browser

Define Chromium, Firefox, and WebKit projects with device-specific settings.

🌍 Real-World Config File

typescript · playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  // Where to find tests
  testDir: './tests',

  // Global test timeout
  timeout: 30000,

  // Assertion timeout
  expect: { timeout: 5000 },

  // Retry failed tests once
  retries: 1,

  // Parallel workers
  workers: process.env.CI ? 2 : 4,

  // Reporters
  reporter: 'html',

  // Shared settings for all tests
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },

  // Browser projects
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox',  use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit',   use: { ...devices['Desktop Safari'] } },
    { name: 'mobile',   use: { ...devices['Pixel 5'] } },
  ],

  // Local dev server
  webServer: {
    command: 'npm run start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

This single file replaces dozens of CLI flags, environment variable hacks, and inconsistent local setups. Every team member runs tests the exact same way.

🧒 Explain Like I'm 10

Think of the config file like the rules of a board game. Before you play Monopoly, everyone agrees: start with $1500, pass Go to collect $200, 4 houses before a hotel. The config file is those rules for your tests: how long before a test "gives up" (timeout), how many times to retry a failing test (retries), and which browsers to play on (projects).

🎓 Professional Explanation

playwright.config.ts uses the defineConfig() helper from @playwright/test to create a typed configuration object. The Playwright test runner reads this file at startup to determine test discovery, execution, and reporting behavior. Configuration follows a cascading override model: global use settings are overridden by project-level use settings, which are overridden by test.use() calls in test files.

📋 Config Options Reference

OptionDefaultWhat It Controls
testDir'./'Directory where test files are located
timeout30000Max time per test in milliseconds
expect.timeout5000Max time for each assertion
retries0Number of retries for failed tests
workersCPU coresNumber of parallel test workers
reporter'list'Test output format (html, json, list, dot)
use.baseURLBase URL for page.goto() relative paths
use.trace'off'Trace recording (on, off, on-first-retry)
use.screenshot'off'Screenshot capture (on, off, only-on-failure)
use.video'off'Video recording (on, off, retain-on-failure)
use.viewport1280×720Browser viewport dimensions
use.storageStatePre-loaded auth cookies/localStorage

🏠 baseURL: Save Typing, Reduce Errors

Setting baseURL is the single highest-impact config change for new teams. It lets you use relative paths in page.goto():

typescript
// In config: baseURL: 'http://localhost:3000'

// ❌ Without baseURL — full URL every time
await page.goto('http://localhost:3000/login');
await page.goto('http://localhost:3000/dashboard');

// ✅ With baseURL — clean relative paths
await page.goto('/login');
await page.goto('/dashboard');

When you change environments (staging → production), you change baseURL in one place instead of editing every test file.

🧪 Projects: Multi-Browser Configuration

Each project in the projects array defines a separate test configuration. Playwright runs all projects by default:

typescript
projects: [
  // Desktop browsers
  { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  { name: 'firefox',  use: { ...devices['Desktop Firefox'] } },
  { name: 'webkit',   use: { ...devices['Desktop Safari'] } },

  // Mobile emulation
  { name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
  { name: 'mobile-safari', use: { ...devices['iPhone 13'] } },

  // Branded browser (requires separate install)
  { name: 'google-chrome', use: { channel: 'chrome' } },
],

Run a specific project: npx playwright test --project=chromium. This is how CI pipelines run cross-browser tests in parallel across multiple machines.

🔄 Retries & Traces: The Flaky Test Armor

💡 Production Recommendation

Always set retries: 1 in CI. This gives flaky tests one extra chance without masking real failures. Combine with trace: 'on-first-retry' to capture a full trace only when a test fails the first time—giving you debugging data without the storage cost of tracing every passing test.

🌐 webServer: Auto-Start Your App

typescript
webServer: {
  command: 'npm run start',
  url: 'http://localhost:3000',
  reuseExistingServer: !process.env.CI,
  timeout: 120000,
},

What this does: Playwright starts your app server before tests and waits for the URL to respond. On CI, it always starts a fresh server. Locally, it reuses an already-running server so you don't have to restart it every time you run tests.

⚠️ Common Mistakes

Mistake 1: Zero Retries in CI

typescript · ❌ Fragile
// ❌ Any timing glitch = red build
retries: 0,

// ✅ Give flaky tests one more chance
retries: process.env.CI ? 2 : 0,

Why: In CI, network latency, container cold starts, and resource contention cause intermittent failures. Retrying once or twice separates real bugs from environmental noise.

Mistake 2: Hardcoding Workers

typescript · ❌ Inflexible
// ❌ Always uses 8 workers — overloads CI
workers: 8,

// ✅ Scale workers based on environment
workers: process.env.CI ? 2 : 4,

✅ Best Practices

  1. Always set baseURL — eliminates full URLs in every test.
  2. Use retries: process.env.CI ? 2 : 0 — retry in CI only.
  3. Enable trace: 'on-first-retry' — debugging data when you need it, no overhead when you don't.
  4. Use devices presets — don't manually configure viewport, userAgent, etc.
  5. Separate CI and local settings — use process.env.CI to conditionally configure.
  6. Keep projects lean in CI — run chromium on every PR, full cross-browser on nightly builds.

🏗️ Senior Engineer Deep Dive

Cascading Override Model

Playwright's config follows a strict precedence: CLI flags > test.use() > project use > global use. This means a test.use({ timeout: 10000 }) in a test file overrides the project's use.timeout, which overrides the global timeout. Understanding this cascade prevents confusing "why is my timeout being ignored?" bugs.

StorageState for Authentication

Rather than logging in before every test, extract auth state once and inject it via config:

typescript
projects: [
  {
    name: 'authenticated',
    use: {
      ...devices['Desktop Chrome'],
      storageState: '.auth/admin.json',
    },
  },
],

How to generate the auth file: Create a global-setup.ts that logs in once and saves storageState. Every test in this project starts already authenticated—zero login overhead.

💼 Interview Questions

Beginner
What is the purpose of playwright.config.ts?
Show answer
It is the central configuration file that controls how tests are discovered, executed, and reported. It defines browsers, timeouts, retries, parallelism, base URL, reporting, and web server settings.
Intermediate
What is the cascade order for Playwright configuration overrides?
Show answer
CLI flags > test.use() in test files > project-level use > global use. More specific scopes always override broader ones.
Advanced
How would you configure Playwright to run authenticated tests without a login step?
Show answer
Create a global setup script that performs login once and saves storageState (cookies + localStorage) to a JSON file. Then reference that file in the project's use.storageState option. Each test in that project starts with auth pre-injected—no login step needed.
Scenario-Based
Your CI tests fail intermittently. Locally they always pass. What config changes would you investigate?
Show answer
1) Add retries: 2 for CI. 2) Enable trace: 'on-first-retry' to capture failure data. 3) Reduce workers — CI containers have fewer resources. 4) Check if webServer timeout is too low for CI cold starts. 5) Verify baseURL points to the correct CI environment.

🏋️ Practical Exercises

🛠️ Hands-On Exercise Beginner

Customize Your Config

  1. Open your project's playwright.config.ts.
  2. Set baseURL to 'https://playwright.dev'.
  3. Update your existing tests to use relative paths like page.goto('/') instead of full URLs.
  4. Add trace: 'on-first-retry' and retries: 1.
  5. Run tests with --project=chromium to test only Chromium.
🔥 Challenge Intermediate

Multi-Browser + Mobile Project

  1. Add 4 projects to your config: chromium, firefox, webkit, and mobile (Pixel 5).
  2. Run all projects: npx playwright test. Observe the separate results per project.
  3. Run only mobile: npx playwright test --project=mobile.
  4. Deliberately use a CSS feature that fails on WebKit. Run --project=webkit and observe the failure.

🧠 Quiz

1. What function is used to create a Playwright config?
createConfig()
defineConfig()
configure()
setup()
2. What is the default test timeout?
10 seconds
60 seconds
30 seconds
5 seconds
3. What does baseURL allow you to do?
Set the browser's homepage
Use relative paths in page.goto()
Define the CI server address
Configure the web server port
4. What does trace: 'on-first-retry' do?
Records a trace for every test
Records a trace only when a test fails and is retried
Disables tracing
Records a trace only in headed mode
5. What is the cascade order for config overrides?
Global use > Project use > test.use() > CLI
CLI > test.use() > Project use > Global use
Project use > CLI > Global use > test.use()
test.use() > CLI > Project use > Global use
6. What does devices['Desktop Chrome'] provide?
Chrome browser binary
Pre-configured viewport, userAgent, and other browser settings
Chrome extension support
Chrome DevTools protocol access
7. Why set retries: process.env.CI ? 2 : 0?
Tests always pass locally
CI has more environmental flakiness; retries are unnecessary overhead locally
CI requires at least 2 retries
Local tests can't be retried
8. What does webServer config option do?
Configures the production server
Starts a local dev server before tests and waits for it to be ready
Sets the API endpoint
Defines the browser's proxy server
9. How do you run tests only on the Chromium project?
npx playwright test --browser=chromium
npx playwright test --project=chromium
npx playwright test chromium
npx playwright test --only=chromium
10. What does storageState in a project's use option do?
Saves the browser's memory state
Pre-loads cookies and localStorage so tests start already authenticated
Stores the test results
Caches browser downloads

❓ FAQ

Can I use JavaScript instead of TypeScript for the config?
Show answer
Yes. Use playwright.config.js with module.exports = defineConfig({...}). However, TypeScript is recommended because it provides autocompletion and type checking for all config options.
What happens if I don't create a config file?
Show answer
Playwright uses sensible defaults: test directory is the current folder, timeout is 30s, no retries, single Chromium project. Running npm init playwright@latest creates a config file automatically.
Can I have different configs for different environments?
Show answer
Yes. Use process.env to conditionally set values, or create separate config files and specify which to use: npx playwright test --config=playwright.ci.config.ts.

📌 Summary

🎯 Key Takeaways

  • defineConfig() creates a typed config object—always use it over raw exports.
  • baseURL eliminates full URLs from every test—set it immediately.
  • retries: process.env.CI ? 2 : 0 is the production standard for handling CI flakiness.
  • trace: 'on-first-retry' captures debugging data only when needed.
  • projects define browser/device configurations—use devices presets.
  • webServer auto-starts your app before tests.
  • storageState eliminates login steps from every test.
  • Cascade order: CLI > test.use() > project use > global use.