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
useblock 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— setfalsewhile you're developing so you can watch the test. CI keeps ittrue.viewport— sets the window size. Want a mobile run? Spreaddevices["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.
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-leveluseblock, but overrides it for that project.- Projects also accept
testMatch,testIgnore,dependencies, andteardown— 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:
testMatchscopes a project to a folder (relative totestDir). Run a single slice withnpx playwright test --project=cart.dependencies: ["auth"]makes theauthproject run first. The auth tests sign in and write a session tostorageState(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. IfbeforeEach+ 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 atest.only(...)slipped into a commit. Cheap insurance against accidentally landing a one-test PR.retries— retry failed tests N times. Pattern is0 locally, 2 in CIso 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.
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."
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 untilurlresponds 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
reuseExistingServerfalse.
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
Make the config yours
Apply four config changes
Open playwright.config.ts and apply four changes:
- Set
baseURLto the workshop URL underuse, then update your two existing tests to use relative paths (e.g.page.goto("/")instead of the full URL). - Remove the
webServerblock. The shop is already deployed — Playwright doesn't need to start anything. - Trim
projects. Removefirefoxandwebkitand add achrome-mobileproject that spreadsdevices["Pixel 5"]. - Find and set your favorite reporter.
- Check that traces are really turned on by setting
trace: "on"underuse. - Re-run the suite — your tests "should" pass on desktop Chrome and
chrome-mobile. Open the HTML report and double-check.
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 }) => {
/* ... */
});