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
useblock 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 totrue. Flip it tofalseto follow along or use the--headedCLI flag.viewport: sets the window size. Want a mobile run? Spreaddevices["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-leveluseblock, 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:
testMatchscopes a project to a folder (relative totestDir), so you can run only the tests of a specific area (npx playwright test --project=cart).dependencieslets you connect projects. In this example, theauthproject runs before thecartproject. This is a common way to store session information instorageStateor 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. 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 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 atest.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 is0locally and2in 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.
Retries hide flakiness without fixing it. Treat any test that needs retries as a bug to investigate.
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 untilurlresponds 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
reuseExistingServerfalse.
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.
Make the config yours
Apply six config changes
Open playwright.config.ts and apply the following 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, so 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 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.