Introduction
Your test suite runs differently depending on whether you're testing locally, in CI, or on a staging environment—and that's by design. The playwright.config.ts file is where you encode those differences, ensuring your tests behave consistently across contexts while adapting to each environment's constraints. Understanding playwright.config.ts: a step-by-step guide means learning how to configure the settings that shape every test run, from parallelization and retries to which browsers you target and how your application starts.
Without a thoughtful config, you'll find yourself fighting flaky tests, waiting for browsers to launch, and struggling to coordinate test runs across your team. This guide walks you through the core configuration decisions that separate solid test suites from brittle ones.
What playwright.config.ts Does
At its core, playwright.config.ts is a TypeScript module that exports a single config object. Playwright reads this file when you run npx playwright test, and uses it to determine how to discover, configure, and execute your tests. Think of it as your test suite's operating manual—it tells Playwright where your tests live, how to run them, and what to do when they fail.
A minimal config looks like this:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: true,
retries: 1,
workers: process.env.CI ? 1 : undefined,
use: {
baseURL: 'http://localhost:3000',
},
});
Each property here controls a specific aspect of how tests run. Let's break down the most impactful ones.
baseURL Keeps Tests Short
One of the highest-impact settings is baseURL. Instead of typing the full URL in every test, you define the base once and reference pages by their path.
Without baseURL, your tests look like this:
await page.goto('https://staging.myapp.com/login');
await page.goto('https://staging.myapp.com/dashboard');
await page.goto('https://staging.myapp.com/settings');
With baseURL, they become:
await page.goto('/login');
await page.goto('/dashboard');
await page.goto('/settings');
This matters more than it first appears. When you deploy to a new environment, you change one line in playwright.config.ts instead of hunting through dozens of test files. Here's how you set it up across environments:
const baseURL = process.env.TEST_BASE_URL || 'http://localhost:3000';
export default defineConfig({
use: {
baseURL,
},
});
Now your CI environment can set TEST_BASE_URL=https://staging.myapp.com, and your tests automatically point there. Local development defaults to your dev server. This single property eliminates a whole class of environment-related bugs.
Projects Define Browsers and Devices
Playwright can test against Chromium, Firefox, WebKit, and mobile emulations of iOS and Android. You don't need to test all of them for every suite, but you should define which combinations matter to your product.
The projects property lets you specify exactly that. Each project is a named test configuration that runs independently:
export default defineConfig({
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
],
});
When you run npx playwright test, Playwright executes every test file against each project sequentially (or in parallel, depending on your worker config). This means a single test suite validates your app across five different browser and device contexts.
In practice, you'll often want a lighter suite for local development and a comprehensive one for CI. You can use the fullyParallel flag and conditionally define projects:
const isCI = !!process.env.CI;
export default defineConfig({
projects: isCI
? [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
]
: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});
Now developers test locally against one browser (fast feedback), while CI validates all four. This balances coverage with developer experience.
Retries and Workers per Environment
Test flakiness is real. Network hiccups, race conditions, and timing issues cause intermittent failures. Playwright handles this with the retries setting, which automatically re-runs failed tests.
The strategy changes between environments. Locally, you want fast feedback, so retries should be low or zero. In CI, where you're already waiting and paying for compute time anyway, retries make sense:
export default defineConfig({
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
});
The workers property controls parallelization. Setting it to undefined tells Playwright to use the default (typically the number of CPU cores). In CI, you often want workers: 1 to avoid resource contention and simplify debugging when tests fail. Some teams use workers: 4 in CI as a middle ground—fast enough to unblock deploys, but stable enough to catch real issues.
Here's a more nuanced example for a larger team:
const isCI = !!process.env.CI;
const isStagingTest = process.env.TEST_ENV === 'staging';
export default defineConfig({
retries: isCI ? (isStagingTest ? 3 : 2) : 0,
workers: isCI ? (isStagingTest ? 1 : 4) : undefined,
timeout: isCI ? 60000 : 30000,
});
Staging tests are slower and less reliable (external dependencies, shared data, eventual consistency), so they get more retries and fewer workers. Production-like environments get more parallelism. Local tests are quick and snappy.
webServer Starts Your App Before Tests
Most test suites depend on a running application. You could require developers to manually start their dev server before running tests, but that's fragile and error-prone. Instead, use the webServer property to start your app automatically:
export default defineConfig({
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: true,
},
});
Playwright waits for your server to respond at the specified URL before running any tests. If it doesn't start within 30 seconds, the test run fails with a clear error message. Once tests finish, Playwright leaves the server running (thanks to reuseExistingServer: true), so you can iterate without restarting the server between test runs.
In CI, you might start your server differently:
const isCI = !!process.env.CI;
export default defineConfig({
webServer: isCI
? {
command: 'npm run build && npm run start',
url: 'http://localhost:3000',
timeout: 120000,
reuseExistingServer: false,
}
: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: true,
},
});
CI builds and starts a production-like server, turning off server reuse. Local development runs the faster dev server with reuse enabled. Both wait for the server to be ready before tests begin.
Common Mistakes
Forgetting baseURL in use. Without it, relative URLs in your tests fail silently or behave unexpectedly. Always set baseURL at the top level or in each project's use block.
Using too many workers locally. A developer's laptop can't always handle the same parallelism as a CI machine. Keep workers at 1 or 2 locally unless you have the resources.
Setting retries too high. If a test flakes 50% of the time, retries will hide the issue instead of fixing it. Use retries for rare, environmental flakiness, not for broken tests. If a test fails repeatedly, investigate and fix it.
Not reusing the dev server. Restarting your dev server between test runs wastes time. Enable reuseExistingServer: true locally so developers iterate quickly.
Hardcoding URLs. Don't put http://localhost:3000 directly in test files. Use baseURL in the config so environment changes are centralized.
Key Takeaways
baseURLinuseeliminates repeated URL prefixes and centralizes environment configuration.projectslet you define once which browsers and devices to test, keeping your test files browser-agnostic.retriesandworkersshould differ between local and CI: developers want speed, CI wants stability.webServerautomatically starts your app so tests never run against a dead server, andreuseExistingServerkeeps local iteration fast.- Use environment variables to branch config logic, keeping a single file that adapts to different contexts.
- Think of
playwright.config.tsas the source of truth for how your tests run—changes here cascade across your entire suite.






Comments
Loading comments…