Multi-Project Config
Configure three Playwright projects (dev / staging / prod) in one config file, and switch between them with --project= or an environment variable. One codebase, three target environments, zero copy-paste.
1 Goal
You have a suite of end-to-end tests and three environments to run them against: a local dev server, a shared staging build, and production. Instead of maintaining three forked configs, you will define all three as projects in a single playwright.config.ts, each with its own baseURL, timeout profile, and browser. You will then run any subset of them with a flag. When you are done, npx playwright test --project=staging will exercise the staging build only, and TARGET_ENV=prod npx playwright test will route to production.
2 Tool install — Playwright + Node.js (free)
Playwright is open-source (Apache-2.0) and free for any use. Node.js is also free and open-source. No accounts, no licences.
- Install Node.js 20 LTS.
Download the LTS installer from nodejs.org/en/download. Any 20.x or newer release works.
winget install OpenJS.NodeJS.LTS
Or double-click the
.msiinstaller from nodejs.org.brew install node@20
Or download the
.pkginstaller from nodejs.org.curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - sudo apt-get install -y nodejs
- Verify the install.
node --version npm --version
You should see
v20.x.xor newer for Node and10.x.xor newer for npm. - Install Playwright (we will do this inside the project in the next section).
No global install needed — Playwright is installed per project via
npm init playwright@latest, which also downloads Chromium, Firefox, and WebKit browser binaries.
3 Project setup
Create a fresh folder, initialise Playwright, and add two more spec files so each project has something real to run.
mkdir architect-01-projects cd architect-01-projects npm init playwright@latest -- --lang=ts --quiet
When the installer asks where to put tests, accept tests. When it asks about GitHub Actions, answer no (we tackle that in Practice 02). When it asks about installing browsers, say yes.
Your folder should look like this:
Delete the placeholder spec and add three small ones that each hit a different public site:
rm tests/example.spec.ts rm -rf tests-examples
On Windows PowerShell use Remove-Item tests/example.spec.ts and Remove-Item tests-examples -Recurse.
4 Write the config & tests
Replace the generated playwright.config.ts with the version below. Then add three spec files. Everything here is complete and runnable — no placeholders.
import { defineConfig, devices } from '@playwright/test';
// TARGET_ENV lets you pick an environment from the shell instead of a flag.
// Example: TARGET_ENV=staging npx playwright test
const TARGET_ENV = process.env.TARGET_ENV;
// Per-environment settings. In a real project these would come from a
// secrets manager or .env files — here we hard-code public URLs.
const envs = {
dev: {
baseURL: 'https://example.com',
timeout: 10_000,
retries: 0,
},
staging: {
baseURL: 'https://playwright.dev',
timeout: 20_000,
retries: 1,
},
prod: {
baseURL: 'https://www.saucedemo.com',
timeout: 30_000,
retries: 2,
},
} as const;
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
reporter: [['list'], ['html', { open: 'never' }]],
// If TARGET_ENV is set, only run the matching project. Otherwise all three
// are available and you can pick with `--project=dev|staging|prod`.
projects: [
{
name: 'dev',
use: { ...devices['Desktop Chrome'], baseURL: envs.dev.baseURL },
timeout: envs.dev.timeout,
retries: envs.dev.retries,
},
{
name: 'staging',
use: { ...devices['Desktop Chrome'], baseURL: envs.staging.baseURL },
timeout: envs.staging.timeout,
retries: envs.staging.retries,
},
{
name: 'prod',
use: { ...devices['Desktop Firefox'], baseURL: envs.prod.baseURL },
timeout: envs.prod.timeout,
retries: envs.prod.retries,
},
].filter(p => !TARGET_ENV || p.name === TARGET_ENV),
});
import { test, expect } from '@playwright/test';
test.describe('dev smoke', () => {
test('home page loads and shows heading', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/Example Domain/);
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Example Domain');
});
test('more information link is reachable', async ({ page }) => {
await page.goto('/');
const link = page.getByRole('link', { name: /More information/i });
await expect(link).toBeVisible();
});
});
import { test, expect } from '@playwright/test';
test.describe('staging smoke', () => {
test('landing page renders the hero', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/Playwright/);
await expect(page.getByRole('heading', { name: /Playwright enables reliable/i })).toBeVisible();
});
test('docs link navigates to docs root', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'Docs', exact: true }).first().click();
await expect(page).toHaveURL(/\/docs\//);
});
});
import { test, expect } from '@playwright/test';
test.describe('prod smoke', () => {
test('login page shows credentials panel', async ({ page }) => {
await page.goto('/');
await expect(page.locator('#login_credentials')).toBeVisible();
});
test('standard user can sign in', async ({ page }) => {
await page.goto('/');
await page.fill('#user-name', 'standard_user');
await page.fill('#password', 'secret_sauce');
await page.click('#login-button');
await expect(page).toHaveURL(/inventory\.html/);
await expect(page.locator('.inventory_list')).toBeVisible();
});
});
Notice that every spec uses relative URLs (page.goto('/')). The baseURL defined per project is what makes this work — the same spec against a different project hits a different site.
testDir/testMatch per project, or gate it with test.skip(process.env.TARGET_ENV !== 'prod', 'prod-only').5 Run & verify
From the project root, try each of the following and watch the project name in the output change:
# Run everything — all three projects npx playwright test # Run a single project by flag npx playwright test --project=staging # Run two projects by flag npx playwright test --project=dev --project=staging # Run a single project by env var (useful in CI) TARGET_ENV=prod npx playwright test # macOS / Linux $env:TARGET_ENV="prod"; npx playwright test # Windows PowerShell
Expected tail of the output for --project=staging:
Running 2 tests using 2 workers ✓ [staging] › staging.spec.ts:4:7 › staging smoke › landing page renders the hero (1.3s) ✓ [staging] › staging.spec.ts:10:7 › staging smoke › docs link navigates to docs root (1.7s) 2 passed (3.2s) To open last HTML report run: npx playwright show-report
When you run without a project flag, you should see every test line prefixed with [dev], [staging], or [prod]. When you set TARGET_ENV, only that prefix appears — the .filter() in the config has dropped the others.
npx playwright show-report. Each project appears as a separate grouping with its own baseURL recorded in the metadata. If all three show the same baseURL, your config didn't override use.baseURL per project — re-check the projects array.6 Troubleshooting
Error: "browserType.launch: Executable doesn't exist"
Playwright can't find its browser binaries. Run npx playwright install to pull Chromium, Firefox, and WebKit. On Linux CI images you may also need npx playwright install --with-deps to grab system libraries.
Tests run for every project when I only wanted one
You probably ran npx playwright test --project staging (space instead of equals). Playwright CLI accepts both, but a typo like --projects=staging (plural) silently does nothing and falls back to "run all". Use --project=staging or TARGET_ENV=staging and confirm the filter in the config uses the name you expect.
Tests pass locally but fail in CI with "net::ERR_CONNECTION_REFUSED"
Your dev project's baseURL points at localhost and there is no local server running in CI. Either swap the dev baseURL to a deployed URL, or add a webServer block to playwright.config.ts that boots the app before tests (see playwright.dev/docs/test-webserver).
"Cannot find module '@playwright/test'"
You ran playwright test without npx, or you are inside the wrong directory. Always use npx playwright test from the folder containing package.json, or add "test": "playwright test" to the scripts block and run npm test.
7 Challenge
Level up
- Add a fourth project for mobile emulation. Create a
mobileproject that uses...devices['iPhone 14'], points at the same stagingbaseURL, and shares the staging specs. Confirmnpx playwright test --project=mobilerenders pages at mobile viewport width in the HTML report. - Separate per-env credentials. Move the saucedemo username/password out of
prod.spec.tsand intouse.httpCredentialsplus customextraHTTPHeadersper project, loaded from.env.dev,.env.staging, and.env.prodfiles via dotenv. Test that swappingTARGET_ENVchanges which file is loaded.