Configuration is a shared control panel for browsers, URLs, timeouts, and evidence rules.
test suite + chosen settings → configured run
Series: Playwright Zero to Expert | Demo app used throughout this series: saucedemo.com
Part 6 walked through a trimmed playwright.config.ts to get your first test running. This part goes back through that same file properly, covering the settings that matter once you’re running a real suite against real, varying conditions — multiple browsers, multiple environments, and the secrets that shouldn’t be hardcoded into any of it.
Projects — Running Against Multiple Browsers
Recall the projects array from Part 6, which had a single Chromium entry. Each entry in projects is really an independent configuration — its own browser, its own settings — all run as part of the same overall test suite:
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
With this configuration, npx playwright test runs your entire test suite three times over — once per project — against Chromium, Firefox, and WebKit respectively. This is exactly what Part 26 (cross-browser testing) will build on properly, but the mechanism itself belongs here in configuration: you write your tests once, and the projects array is what actually determines how many browser/device combinations they run against, without duplicating a single line of test code.
npx playwright test --project=chromium # run just one project's tests
Timeouts, Retries, and Workers
export default defineConfig({
timeout: 30000, // maximum time a single TEST is allowed to run, in milliseconds
expect: {
timeout: 5000, // maximum time a single ASSERTION is allowed to retry for (Part 9)
},
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
// ...
});
Two timeouts here are genuinely different things, worth not confusing: timeout (top-level) bounds an entire test’s total run time — if a test takes longer than this to finish altogether, it’s killed and reported as failed, no matter what it was doing. expect.timeout specifically bounds how long a single assertion’s auto-retry (Part 9) is allowed to keep polling for. A test can contain many assertions, each individually bound by expect.timeout, all within the test’s overall timeout ceiling.
Analogy: The Stage Manager’s Control Panel Imagine managing a live theater play:
- Global Test Timeout (The Play Timer): You set a hard ceiling of 30 minutes for the entire play (
timeout). If the curtain is still up at minute 31, the fire marshal shuts down the theater immediately, regardless of what scene the actors are performing.- Expect/Assertion Timeout (The Actor’s Wait): During Scene 2, the script says the actor expects a doorbell to ring within 5 seconds (
expect.timeout). They stand waiting by the door. If it rings at second 3, they open it and continue (assertion passes). If 5 seconds elapse and the bell remains silent, the actor walks off the stage and the play is declared a failure.- Action/Navigation Timeout: The script says: “Walk across the stage within 10 seconds.” If the actor’s path is blocked by a misplaced prop (overlay), they wait. If they can’t cross in time, the play aborts.
📊 Visual Flowchart: Timeout Hierarchy
Here is how individual actionability checks and assertion retry loops reside within the global test execution ceiling:
graph TD
subgraph GlobalTimeout ["Global Test Timeout Ceiling (e.g. 30,000ms)"]
subgraph ActionClick ["Action 1: click()"]
Act1["Actionability Check (default 30,000ms limit)"]
end
subgraph AssertVisible ["Assertion 1: expect().toBeVisible()"]
Assert1["Web-First Polling Loop (default 5,000ms limit)"]
end
Act1 --> Assert1
end
retries: process.env.CI ? 2 : 0 is worth reading carefully, because it demonstrates something genuinely useful: conditional configuration based on environment. process.env.CI is an environment variable that CI systems (Part 32) conventionally set automatically to true — so this single line means “retry failing tests twice when running in CI, but never retry locally.” This connects directly back to Part 6’s honest warning about retries potentially masking flakiness: the reasoning here is that a developer running tests locally wants to see a failure immediately and investigate it, while a CI pipeline might reasonably tolerate a small amount of retry-based resilience against genuinely transient infrastructure issues (a momentarily slow network on the CI runner itself, for instance) — though, as Part 28 will stress, retries should never become a substitute for actually fixing genuine flakiness.
workers controls how many tests run in true parallel (a full explanation in Part 27) — often deliberately set lower and more predictable in CI (a fixed number like 4) than locally, where leaving it undefined lets Playwright automatically decide based on your machine’s available CPU cores.
Screenshots, Video, and Trace
use: {
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'on-first-retry',
},
Each of these three (covered fully, with real debugging workflows, in Part 23 and Part 24) has the same underlying design philosophy worth naming explicitly: capture rich debugging information only when something actually goes wrong, rather than unconditionally for every single test.
Recording video and detailed traces for every passing test would be wasteful — real, unnecessary storage and time cost, for information nobody will ever actually look at, since nobody investigates a test that passed. only-on-failure and retain-on-failure mean this valuable debugging data is available exactly when you need it, without paying its cost when you don’t.
Environment Variables and Multiple Environments
Real applications exist in more than one place — a local development environment, a staging environment for pre-release testing, and production. A single, hardcoded baseURL would force you to manually edit the config file every time you wanted to point your suite somewhere else — a genuinely fragile, error-prone habit. Environment variables solve this properly:
export default defineConfig({
use: {
baseURL: process.env.BASE_URL || "https://www.saucedemo.com",
},
});
BASE_URL=https://staging.saucedemo.com npx playwright test
This single line means the exact same test suite, completely unchanged, can run against production (using the fallback default) or staging (by setting the environment variable), just by how it’s invoked — genuinely important for real QA workflows, where the same suite is routinely expected to validate a staging environment before a release, then re-confirm production afterward.
Managing Secrets Safely with .env
Some configuration is genuinely sensitive — API keys, test account passwords for privileged accounts, database credentials — and shouldn’t be hardcoded directly into a config file that gets committed to Git (recall Part 5’s explicit warning about exactly this). The common, safe pattern uses a .env file, loaded with the dotenv package:
npm install dotenv --save-dev
# .env (this file is in .gitignore — see Part 5 — and never committed)
BASE_URL=https://staging.saucedemo.com
API_KEY=sk_test_abc123
// playwright.config.ts
import "dotenv/config";
import { defineConfig } from "@playwright/test";
export default defineConfig({
use: {
baseURL: process.env.BASE_URL,
},
});
import 'dotenv/config' loads the .env file’s contents into process.env automatically, the moment the config file runs — meaning every value in .env becomes available exactly the same way process.env.CI or process.env.BASE_URL were used above, without ever needing to type the actual secret value anywhere inside a file that Git tracks. Each environment (a developer’s own machine, staging CI, production CI) can maintain its own separate, untracked .env file with the values appropriate to it — the code referencing process.env.API_KEY never changes at all between environments, only the actual value being supplied does.
How It Works in a Real Test Run
Configuration has two major scopes. Runner settings such as testDir, workers, retries, reporter, and projects control how tests are scheduled; use settings such as baseURL, trace, storageState, viewport, and permissions become defaults for browser contexts and pages.
A project is a named configuration variant, not merely a browser. It can represent Chromium, an authenticated role, a locale, or a mobile profile. Environment variables should select public configuration, while secrets come from protected CI storage and must not be printed or committed.
Interview Questions
Q: What’s the difference between the top-level timeout and expect.timeout in playwright.config.ts?
Ans: The top-level timeout bounds the total allowed run time for an entire test — if the whole test takes longer than this, it’s killed and reported as failed. expect.timeout specifically bounds how long a single assertion’s auto-retry polling is allowed to continue before that particular assertion gives up and fails. A test can contain multiple assertions, each individually governed by expect.timeout, all nested within the test’s broader overall timeout.
Q: Why might a team configure retries: 2 for CI but retries: 0 for local runs?
Ans: Locally, a developer generally wants to see a test failure immediately, so they can investigate and fix it right away — silently retrying would delay that feedback. In CI, a small number of retries can reasonably absorb genuinely transient infrastructure issues, like a momentarily slow network on the CI runner itself, without derailing an entire pipeline over something unrelated to the actual test or application. This shouldn’t be treated as a substitute for genuinely fixing real flakiness, though — it’s meant to absorb occasional environmental noise, not systematically mask a broken or unstable test.
Q: Why do settings like screenshot: 'only-on-failure' and video: 'retain-on-failure' exist, rather than always capturing this data for every test?
Ans: Capturing detailed debugging artifacts unconditionally for every passing test would waste real storage and time on information nobody will actually need, since a passing test typically isn’t investigated further. Capturing this data only on failure means it’s available exactly when it’s actually useful — during debugging — without paying that cost for the vast majority of test runs that succeed without issue.
Q: Why is using an environment variable for baseURL better than hardcoding a single URL directly in playwright.config.ts?
Ans: Real applications typically exist in multiple environments — local, staging, production — and a hardcoded URL would require manually editing the config file every time the suite needed to target a different one, which is fragile and error-prone. An environment variable lets the exact same, unchanged test suite be pointed at any environment simply by how it’s invoked, without touching the code at all.
Q: Why should secrets like API keys be loaded from a .env file rather than written directly into playwright.config.ts?
Ans: playwright.config.ts is source code, typically committed to Git and visible to the whole team (and its full history, as Part 5 covered). A .env file, excluded from Git via .gitignore, keeps actual secret values out of version control entirely, while the code itself only ever references process.env.SOME_VARIABLE — meaning the code needs no changes between environments or machines, only the untracked .env file’s actual contents differ.
Q: A test suite behaves correctly on a developer’s machine but a teammate reports it’s pointing at the wrong environment on theirs. What would you ask them to check first?
Ans: I’d first ask them to check what value BASE_URL (or the equivalent environment variable) actually resolves to on their machine — whether their local .env file has a different, perhaps outdated or misconfigured value, or whether it’s missing entirely, causing the config’s fallback default to be used instead of what they intended. Since the actual URL used depends entirely on an untracked, per-machine value, this kind of environment-specific mismatch is one of the first, most common places to look.
Exercises — Part 16
Understand:
Explain, in your own words, the practical difference between the top-level timeout and expect.timeout, using a concrete example of a test with three assertions in it.
Simple Practice:
Add a second and third project (Firefox and WebKit) to a Playwright config you’ve already set up, and run your existing SauceDemo login test with npx playwright test (no --project flag) to confirm it now runs against all three browsers.
Real-World Scenario:
Set up a .env file with a BASE_URL variable, install and configure dotenv in your playwright.config.ts, and confirm — by deliberately setting BASE_URL to an incorrect value temporarily — that your test suite’s navigation actually changes based on it, proving the environment variable is genuinely being used rather than a hardcoded fallback.
Challenge: Research how to define multiple named configuration presets for genuinely different purposes (for instance, a fast, Chromium-only “dev” config versus a full three-browser “ci” config) using separate config files or config overrides, and write down, in your own words, how you’d invoke each one from the command line.
Next: Part 17 — API Testing
— HTTP fundamentals, Playwright’s request context, and combining API and UI testing in the same suite.
- Author
- TechByteByByte Editorial Team
- Reviewed by
- TechByteByByte Admin
- Published
- Last reviewed