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 (+ your first tests). That works, but playwright.config.ts is where the test runner becomes yours. Set base URls for shorter goto calls, the tweak viewports and browser settings, manage flakes, and wrangle hopefully sane timeouts.

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://www.playwright-workshop.online",
    trace: "on",
  },

  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://www.playwright-workshop.online",
  },
});

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://www.playwright-workshop.online/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://www.playwright-workshop.online",
},

Browser defaults under use

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

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

export default defineConfig({
  use: {
    ...devices["Desktop Chrome"], // sensible browser defaults
    headless: false, // see the browser locally
    locale: "en-US",
    timezoneId: "Europe/Berlin",
    colorScheme: "dark", // emulate prefers-color-scheme: dark
  },
});

Order matters: spread the device preset first, then override the fields you want different. Anything you set after the spread wins.

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

devices is an object Playwright ships with realistic presets for desktop browsers, tablets, and phones:

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

console.log(devices["Desktop Chrome"]);
// { viewport: { width: 1280, height: 720 }, userAgent: "...", ... }

console.log(devices["Pixel 5"]);
// { viewport: { width: 393, height: 851 }, deviceScaleFactor: 2.75,
//   isMobile: true, hasTouch: true, defaultBrowserType: "chromium",
//   userAgent: "...Mobile..." }

Spread one into use to get a matching viewport, userAgent, and — importantly — the right defaultBrowserType: Pixel 5 runs on Chromium, iPhone 13 runs on WebKit, no extra config needed. Browse the full list — there are a few hundred.

projects — split your suite by browser, device, or area

A project is a group of tests. They have their own browser config, its own test scope, and its own dependencies.

The default config ships three projects to set up the three browser engines. All engines run all tests.

projects: [
  { name: "chromium", use: { ...devices["Desktop Chrome"] } },
  { name: "firefox", use: { ...devices["Desktop Firefox"] } },
  { name: "webkit", use: { ...devices["Desktop Safari"] } },
],

Every project has similar configuration as the general playwright.config.

  • name — shows up in the report and lets you filter runs: npx playwright test --project=chrome-mobile.
  • use — same shape as the top-level use block, but overrides it for that project.
  • Projects also accept testMatch, testIgnore, dependencies, and teardown — useful when you want a setup project to run before everything else.

Beyond browsers — group tests by area

Projects don't have to be one-per-browser. They can also slice your suite by purpose — auth tests, cart tests, smoke tests — each with its own scope and setup:

projects: [  {    name: "auth",    testMatch: "auth/**/*.spec.ts",  },  {    name: "cart",    testMatch: "cart/**/*.spec.ts",    dependencies: ["auth"],    use: { storageState: "playwright/.auth/user.json" },  },],

What's going on:

  • testMatch scopes a project to a folder (relative to testDir). Run a single slice with npx playwright test --project=cart.
  • dependencies: ["auth"] makes the auth project run first. The auth tests sign in and write a session to storageState (more on that later); the cart project picks it up so every cart test starts logged in instead of repeating the login dance.

When to stop the entire test suite - maxFailures

By default Playwright runs every test, even when the first one fails. With a 200-test suite and a real outage in production or broken auth on staging, that means watching most of your suite turn red before the run is done. maxFailures speeds up the failure.

export default defineConfig({
  maxFailures: process.env.CI ? 5 : undefined,
});

Once the failure count hits the threshold, Playwright stops scheduling new tests and exits with a non-zero status. You hear about the breakage fast and you don't burn CI minutes confirming what the first five failures already told you.

The pattern that tends to fit: leave it unset locally so you find every issue in one go, cap it in CI so a global outage doesn't tie up the build queue.

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.
Tip

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."

Tip

In local environments, you usually don't need to specify the workers property because you're running on a powerful machine. If workers isn't specified Playwright falls back to 50% of available CPU cores.

In CI/CD environments it's recommend to set workers to 1 to avoid increasing flakiness by running too many things at once.

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.
Note

In our case, the shop is deployed and running at https://www.playwright-workshop.online/ so we don't need this. I only listed it in case your tests live in your application directory


Hands on

Make the config yours

Exercise 1 of 1

Apply four config changes

Open playwright.config.ts and apply four changes:

  1. Set baseURL to the workshop URL under use, then update your two existing tests to use relative paths (e.g. page.goto("/") instead of the full URL).
  2. Remove the webServer block. The shop is already deployed — Playwright doesn't need to start anything.
  3. Trim projects. Remove firefox and webkit and add a chrome-mobile project that spreads devices["Pixel 5"].
  4. Find and set your favorite reporter.
  5. Check that traces are really turned on by setting trace: "on" under use.
  6. Re-run the suite — your tests "should" pass on desktop Chrome and chrome-mobile. Open the HTML report and double-check.
Tip

If you're one track with the tasks you might run into an issue. The "Products" link isn't available on mobile. 😱 Maybe isMobile can help out here:

test("add-to-cart from catalog", async ({ page, isMobile }) => {
  /* ... */
});