Make Playwright yours

Basic configuration options for your Playwright setup.

So far, you've been running Playwright with whatever configuration 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, tweak viewports and browser settings, manage flaky tests, and define sane timeouts.

We won't cover every config option in this lesson. The goal is to walk through the options you'll actually touch in your first few weeks.

The shape of the playwright.config file

Open playwright.config.ts at the root of your project. Your entire test configuration 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 each test's browser context like the viewport, locale, headless mode, what to record, the base URL, etc.

Let's look at some of the options.

testDir — where your tests live

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

By default, Playwright walks ./tests looking for *.spec.ts files. If you want to move your tests elsewhere, change this, and you're done.

use.baseURL — stop typing the host

This is the 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");

It's common to use an environment variable to make the base URL configurable:

use: {  baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "https://www.playwright-workshop.online",},

Now your tests can run against preview environments when you define PLAYWRIGHT_BASE_URL.

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

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"], // browser defaults    headless: false,    locale: "en-US",    timezoneId: "Europe/Berlin",    colorScheme: "dark",  },});

If you want to emulate a specific browser or device, Playwright provides a devices object for that.

Learn more about devices

devices is an object that ships with Playwright and holds 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 the right defaultBrowserType. Pixel 5 runs on Chromium and iPhone 13 runs on WebKit without any extra config. Browse the full list. There are a few hundred.

A few common browser options under use are:

  • headless: defaults to true. Flip it to false to follow along or use the --headed CLI flag.
  • viewport: sets the window size. Want a mobile run? Spread devices["iPhone 13"] here.
  • colorScheme: flips the page into dark mode without changing your OS setting.

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

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

The default config ships with three projects, one per browser engine. If you run npx playwright test, all three projects run all defined tests.

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

Every project accepts similar options to the top-level config:

  • name: shows up in the report and lets you filter runs on the command line: 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. These options become useful when you scale up your Playwright project.

Beyond browsers — group tests by area

Projects don't have to be one-per-browser. They can also slice your suite by purpose or application area, for example, auth tests, cart tests, or smoke tests. Each project can have its own scope, configuration 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 here:

  • testMatch scopes a project to a folder (relative to testDir), so you can run only the tests of a specific area (npx playwright test --project=cart).
  • dependencies lets you connect projects. In this example, the auth project runs before the cart project. This is a common way to store session information in storageState or to seed data (more on that later).

When to stop the entire test suite — maxFailures

By default, Playwright doesn't stop when a test fails. With a 200-test suite and a real outage in production or broken auth on staging, that means waiting for all tests to fail before you can look at the results.

maxFailures makes a broken test run fail faster.

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.

A good pattern is to leave it unset locally so you find every issue in one go, and to 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 time budget for one 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 timeouts globally to "fix" flakiness. Most of the time the test is right and the page is really slow. Find the right default values and trust the failures.

There's also a third timeout option that's unset by default: globalTimeout.

export default defineConfig({  globalTimeout: 3_600_000,});

Global timeout sets a time limit for the whole test run. If the total duration of all tests exceeds this limit, Playwright will abort the run.

There are also more low-level timeouts if you want to dive deeper.

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: run tests within the same file in parallel, not just files in parallel. Great for speed, brutal if your tests share state.
  • forbidOnly: !!process.env.CI: fails the run if a test.only(...) slipped into a commit. Cheap insurance against a PR that accidentally runs only one test.
  • retries: retry failed tests N times. A common pattern is 0 locally and 2 in CI, so you see flakiness during development but the pipeline doesn't go red on the first hiccup. If a retry passes, the test is marked flaky in the report. Investigate it instead of ignoring it.
  • workers: how many browsers run in parallel. Locally, Playwright picks a number. In CI, you may want to cap it for stability or because of the runner's CPU budget.
Note

Retries hide flakiness without fixing it. Treat any test that needs retries as a bug to investigate.

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 recommended 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, with minimal noise.
  • "dot": one character per test, great for very long suites.
  • "html": the rich HTML report (npx playwright show-report). The default in a fresh project.
  • "github": emits GitHub Actions annotations on failures.

You can stack them:

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

A common combo is list to see tests scroll by locally and html to 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". This is way faster when you develop and write tests at the same time.
  • 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 to deal with a local server.


Hands on

Make the config yours

Exercise 1 of 1

Apply six config changes

Open playwright.config.ts and apply the following 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, so 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 on track, you will run into test failures on mobile viewports. The "Products" or "Login" link isn't available on mobile. 😱

The isMobile fixture can help out here. You can either skip tests when they run in a mobile emulation:

test("add-to-cart from catalog", async ({ page, isMobile }) => {
  test.skip(isMobile, "Doesn't work on mobile");

  // ...
});

Or you can adjust the tests to work in both scenarios:

test("add-to-cart from catalog", async ({ page, isMobile }) => {
  if (isMobile) {
    // Handle the mobile navigation
  } else {
    // Handle the desktop navigation
  }

  // ...
});

I'll leave it to you to decide how to make your tests pass.