Playwright's config sets the rules for the whole suite, and it is easy to turn into a dump of options copied from the internet whose meaning nobody on the team remembers. A professional playwright.config.ts reads as a set of deliberate decisions: where the tests live, how the run behaves locally and on CI, what is recorded on failure and in which browsers everything is checked. Each option should answer a concrete operational question, not stand there "just in case".
The config begins with the basic run rules. testDir points to the tests directory, fullyParallel enables parallelism at the file level, and forbidOnly, tied to the CI variable, fails the build if a forgotten test.only slipped into the code - otherwise CI silently runs one test instead of all. This is not cosmetics but insurance: one forgotten marker can quietly disable the whole suite in the pipeline.
Behavior on CI and locally is deliberately different. retries on CI are set to 2, so rare infrastructure instability does not fail the pipeline, but locally they are kept at zero so flakiness is visible at once. workers on CI are fixed to the machine's resources. The reporter also branches: on CI - html without auto-open plus github for annotations right in the PR, locally - html plus list for live terminal output.
The use section sets the shared context for all tests. baseURL lets you write page.goto('/login') instead of a full address and change the environment in one line. The three artifact options work in tandem with failure: trace: 'on-first-retry' records a detailed trace only on a retry, screenshot: 'only-on-failure' and video: 'retain-on-failure' keep the image and video only for failed ones. So diagnostics are there where needed but do not bloat every passing run.
A separate block is webServer: Playwright itself starts the application before the tests. command branches - a built start on CI, dev locally - url sets what to wait for before starting the scenarios, reuseExistingServer outside CI reuses an already running server, and timeout gives the app time to come up. This removes a whole class of races where "the tests started before the server".
The projects array describes the browsers the run goes through: chromium, firefox and webkit via ready devices. Each project is a named environment configuration, and later devices, locales and dependencies like an auth setup project are added here. It helps to assemble the whole config once to see how these parts add up into one explainable run behavior.
An important principle: a timeout is not a cure. The temptation to fix one slow or unstable scenario by raising the global timeout is almost always harmful: it masks the real cause and slows the whole suite, stretching the wait for all tests at once. The right path is to first find out what exactly the scenario is waiting for, and if the operation is objectively long, set a local limit for it alone, not for the whole run.
The typical config failures grow from thoughtless copying. A globally raised timeout hiding flakiness instead of fixing it; identical retries locally and on CI, because of which instability is either invisible or fails the pipeline; the absence of forbidOnly, letting a forgotten only disable the suite. Keep the config minimal and explainable: each option is an answer to a real operational question, not a line from someone else's example.
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: process.env.CI
? [['html', { open: 'never' }], ['github']]
: [['html', { open: 'never' }], ['list']],
use: {
baseURL: 'http://127.0.0.1:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
webServer: {
command: process.env.CI ? 'npm run start' : 'npm run dev',
url: 'http://127.0.0.1:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
})