Level 5 · Architect · Practice 01

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.

  1. 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 .msi installer from nodejs.org.

    brew install node@20

    Or download the .pkg installer from nodejs.org.

    curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
    sudo apt-get install -y nodejs
  2. Verify the install.
    node --version
    npm --version

    You should see v20.x.x or newer for Node and 10.x.x or newer for npm.

  3. 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.

Why Node 20 LTS: Playwright drops Node 18 support with v1.50+. Stick to an even-numbered LTS release so you match the Playwright team's CI matrix and avoid weird ESM bugs.

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:

architect-01-projects/ ├── node_modules/ ├── tests/ │ └── example.spec.ts ├── tests-examples/ ├── package.json ├── package-lock.json └── playwright.config.ts

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.

playwright.config.ts
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),
});
tests/dev.spec.ts
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();
  });
});
tests/staging.spec.ts
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\//);
  });
});
tests/prod.spec.ts
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.

One spec, many projects: if a test file is generic (say a "homepage loads" smoke) Playwright will run it against every project you select. If you want a spec to run only for one project, either put it in a dedicated folder and use 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.

Sanity check: open the HTML report with 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

  1. Add a fourth project for mobile emulation. Create a mobile project that uses ...devices['iPhone 14'], points at the same staging baseURL, and shares the staging specs. Confirm npx playwright test --project=mobile renders pages at mobile viewport width in the HTML report.
  2. Separate per-env credentials. Move the saucedemo username/password out of prod.spec.ts and into use.httpCredentials plus custom extraHTTPHeaders per project, loaded from .env.dev, .env.staging, and .env.prod files via dotenv. Test that swapping TARGET_ENV changes which file is loaded.