Make Playwright yours

Basic configuration options for your Playwright setup.

Up to here you've been running Playwright with whatever npm init playwright handed you. That works, but playwright.config.ts is where the test runner becomes yours — shorter goto calls, the right viewport, fewer flakes, sane timeouts, an auto-started dev server.

We won't cover everything in this file (the full reference is huge). The goal here is to walk through the options you'll actually touch in your first few weeks.


The shape of the file

Open playwright.config.ts at the root of your project. The whole thing is one call to defineConfig:

import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./tests",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: "html",

  use: {
    baseURL: "https://next-example-store-stefan-judis.vercel.app",
    trace: "on-first-retry",
  },

  projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});

Two things to notice:

  • The top-level keys (testDir, retries, workers, …) configure the runner.
  • The use block configures the browser context every test gets — viewport, locale, headless mode, what to record, the base URL.

Everything below is just a tour of those keys.


testDir — where your tests live

export default defineConfig({
  testDir: "./tests",
});

By default Playwright walks ./tests looking for *.spec.ts files. Move your tests, change this, done. There's also testMatch / testIgnore if you need to be picky — we'll come back to those when we talk about projects.


use.baseURL — stop typing the host

This is the single most useful option in the file.

export default defineConfig({
  use: {
    baseURL: "https://next-example-store-stefan-judis.vercel.app",
  },
});

With a baseURL set, every relative page.goto() and every relative URL in await expect(page).toHaveURL(...) resolves against it:

// before
await page.goto("https://next-example-store-stefan-judis.vercel.app/cart");

// after
await page.goto("/cart");

You also get to swap environments from the outside without touching a single test:

$ PLAYWRIGHT_BASE_URL=https://staging.example.com npx playwright test

…if you wire it up:

use: {
  baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "https://next-example-store-stefan-judis.vercel.app",
},
Todo

Browser defaults under use

The use block decides what kind of browser context every test starts with.

use: {
  headless: false,        // see the browser locally
  viewport: { width: 1280, height: 720 },
  locale: "en-US",
  timezoneId: "Europe/Berlin",
  colorScheme: "dark",    // emulate prefers-color-scheme: dark
},

A few that come up often:

  • headless — set false while you're developing so you can watch the test. CI keeps it true.
  • viewport — sets the window size. Want a mobile run? Spread devices["iPhone 13"] here.
  • locale / timezoneId — important if your site formats dates, currencies, or numbers. The default is whatever your machine reports, which makes "works on my laptop" a real failure mode.
  • colorScheme — flips the page into dark mode without a real OS setting.
Note

Anything you set under top-level use can be overridden per-project (mobile vs. desktop) or per-test (test.use({ viewport: ... })). We'll cover that layering in a later lesson.


How long to wait — timeout & expect.timeout

There are two timeouts you'll hit early:

export default defineConfig({
  timeout: 30_000, // each test gets 30s total
  expect: {
    timeout: 5_000, // each expect() gets 5s to become true
  },
});
  • timeout — the budget for a whole test. If beforeEach + the test body together take longer, the test fails.
  • expect.timeout — how long any single web-first assertion (await expect(locator).toBeVisible()) keeps retrying before giving up.

Don't bump these globally to "fix" flakiness. Most of the time the test is right and the page is genuinely slow — the timeout is just the messenger.


Reliability — retries, workers, fullyParallel, forbidOnly

export default defineConfig({
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
});
  • fullyParallel: true — runs tests within the same file in parallel, not just files in parallel. Great for speed, brutal if your tests share state. Default since recent Playwright versions.
  • forbidOnly: !!process.env.CI — fails the run if a test.only(...) slipped into a commit. Cheap insurance against accidentally landing a one-test PR.
  • retries — retry failed tests N times. Pattern is 0 locally, 2 in CI so you see flake during development but the pipeline doesn't go red on the first hiccup. If the second run passes, the test is marked flaky in the report — investigate it, don't ignore it.
  • workers — how many browsers run in parallel. Locally Playwright picks a number; in CI you may want to cap it for stability or runner CPU budget.
Note

Retries hide flakiness, they don't fix it. Treat any test that needs retries as a bug to investigate, not a test that "works now."


reporter — what you see when tests run

export default defineConfig({
  reporter: "html",
});

Built-in reporters worth knowing:

  • "list" — one line per test, good for local runs.
  • "line" — single-line counter that overwrites itself; minimal noise.
  • "dot" — one character per test; great for very long suites.
  • "html" — the rich HTML report (npx playwright show-report). Default when you bootstrap.
  • "github" — emits GitHub Actions annotations on failures.

You can stack them:

reporter: [["list"], ["html", { open: "never" }], ["github"]],

A common combo: list so you see tests scroll by locally, html so you can dig in afterwards.


webServer — start your app for the tests

If your tests need your app running, you don't have to remember to npm run dev in another terminal. Let Playwright start it:

export default defineConfig({
  webServer: {
    command: "npm run dev",
    url: "http://localhost:3000",
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
});
  • Playwright runs command, then waits until url responds before kicking off any test.
  • reuseExistingServer: true (locally) means "if it's already running, just use it" — way faster while you iterate.
  • In CI, force a fresh start by leaving reuseExistingServer false.
Todo

Hands-on — make the config yours

Exercise 1 of 2

Tighten one thing

  1. Pick one option from this lesson that bugs you about your current setup (most common: no baseURL).
  2. Change it.
  3. Update the affected tests if needed (e.g. switch page.goto("https://...") to page.goto("/...")).
  4. Re-run the suite — should still be green.
Exercise 2 of 2

Fail on stray `test.only`

  1. Add forbidOnly: !!process.env.CI to your config.
  2. Add a test.only(...) to one of your spec files.
  3. Run CI=1 npx playwright test — Playwright should refuse to run.
  4. Remove the .only and the run is green again.