Environment configuration: practical tips and trade-offs
When you run Playwright tests across local machines, staging servers, and preview environments, configuration becomes your first line of defence against chaos. We've seen too many teams accidentally run destructive tests against production, wipe real user data in staging, or spend hours debugging flaky tests that only fail in CI. The right environment setup catches these problems before they happen, and it doesn't require much—just clarity on what runs where, and why.
BASE_URL from environment variables
The foundation of any multi-environment test suite is separating your test logic from your deployment targets. Rather than hardcoding URLs into your test files, inject them through environment variables. This single practice prevents the most common deployment mistakes we see in production suites.
Start by defining a base URL in your Playwright config:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
Then in your tests, use relative URLs:
test('login flow', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', 'user@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button:has-text("Sign in")');
await page.waitForURL('/dashboard');
});
Now you can run the same test suite against different environments by changing a single variable:
BASE_URL=http://localhost:3000 npm run test
BASE_URL=https://staging.example.com npm run test
BASE_URL=https://preview.example.com npm run test
This approach gives you portability. Your test code doesn't know or care where it's running. It also makes CI/CD pipelines cleaner—you parameterize the environment, not the tests themselves.
dotenv for local settings
Local development moves faster when you don't type environment variables every time you run tests. We use dotenv to load a .env.local file that doesn't get committed to version control.
Install the package:
npm install dotenv
Create a .env.local file in your project root (add it to .gitignore):
BASE_URL=http://localhost:3000
TEST_USER_EMAIL=testuser@example.local
TEST_USER_PASSWORD=securepassword123
ADMIN_EMAIL=admin@example.local
ADMIN_PASSWORD=adminpass456
API_TOKEN=test_token_xyz
Load it in your Playwright config or test setup:
import dotenv from 'dotenv';
dotenv.config({ path: '.env.local' });
export default defineConfig({
use: {
baseURL: process.env.BASE_URL,
extraHTTPHeaders: {
'Authorization': `Bearer ${process.env.API_TOKEN}`,
},
},
});
Then in your tests, reference credentials without hardcoding:
test('admin logs in', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', process.env.ADMIN_EMAIL);
await page.fill('input[name="password"]', process.env.ADMIN_PASSWORD);
await page.click('button:has-text("Sign in")');
await expect(page.locator('text=Admin Dashboard')).toBeVisible();
});
The benefit here is workflow speed. Developers can experiment with different test accounts or toggle feature flags without touching code. Each person on the team can have their own .env.local without conflicts.
Different projects per environment
As your test suite grows, you'll want different configurations for different environments. Playwright's project system lets you run the same tests with different settings, browsers, or retry logic depending on where you're testing.
Define projects in your config:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'local',
use: {
baseURL: 'http://localhost:3000',
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
},
{
name: 'staging',
use: {
baseURL: 'https://staging.example.com',
},
retries: 2,
timeout: 30000,
},
{
name: 'preview',
use: {
baseURL: 'https://preview.example.com',
},
retries: 1,
timeout: 30000,
},
],
});
Run tests against a specific project:
npx playwright test --project=local
npx playwright test --project=staging
npx playwright test --project=preview
Different projects let you tune behavior for each environment. Staging tests might retry twice because network flakiness is common. Preview tests might use different test data seeds. Local tests start a dev server. This flexibility prevents one-size-fits-all configurations that fail in practice.
You can also tag tests to run only on certain projects:
test('checkout flow @staging @preview', async ({ page }) => {
// Run only on staging and preview
});
test('local-only debug test @local', async ({ page }) => {
// Run only locally
});
Then filter by tag:
npx playwright test --grep @staging
Never point destructive tests at production
This is the rule that matters most. If your tests can modify data, delete records, or change state, they should never run against production. Period. We've seen teams learn this lesson the hard way, and it's always painful.
Implement guard rails in your config:
export default defineConfig({
globalSetup: './tests/global-setup.ts',
});
In tests/global-setup.ts:
import { chromium } from '@playwright/test';
async function globalSetup(config) {
const baseURL = process.env.BASE_URL || '';
// Fail hard if someone tries to run tests against production
if (baseURL.includes('example.com') && !baseURL.includes('staging') && !baseURL.includes('preview')) {
throw new Error(
'ABORT: Refusing to run tests against production. ' +
'Use staging or preview environments only.'
);
}
// Verify test environment is safe
const browser = await chromium.launch();
const page = await browser.newPage();
try {
await page.goto(baseURL);
const title = await page.title();
// Check for environment indicator
if (!title.toLowerCase().includes('staging') &&
!title.toLowerCase().includes('preview') &&
!title.toLowerCase().includes('local')) {
throw new Error(
'WARNING: Environment identifier not found in page title. ' +
'Ensure your staging/preview environment displays its name clearly.'
);
}
} finally {
await browser.close();
}
}
export default globalSetup;
Additionally, mark your destructive tests clearly and restrict them:
test('delete order @destructive', async ({ page }) => {
// This test modifies data
// Should only run in isolated test environments
await page.goto('/admin/orders');
await page.click('text=Test Order 123');
await page.click('button:has-text("Delete")');
});
Then never include @destructive tests in your staging run:
npx playwright test --project=staging --grep-invert @destructive
Create a separate test environment—often called "test" or "e2e"—that's isolated from real user data. This environment is rebuilding its database from scratch on each run, so destructive tests can run freely.
Common mistakes
- Hardcoding URLs in tests. The moment you do this, you lose the ability to run the same test against different environments. It's a few extra minutes to extract—do it early.
- Checking .env files into version control. Even if they contain "fake" data, someone will later copy that .env to production. Use .env templates instead:
.env.examplecommitted,.env.localignored. - Over-complicating project config. You probably need local, staging, and maybe preview. Don't create ten variations. Keep it simple.
- Forgetting to test the configuration itself. Run your guard-rail checks manually once. Verify they actually catch bad URLs before you rely on them.
- Running the full suite against staging. Staging is fragile. Run a focused smoke test to verify your environment is up, then reserve full test runs for isolated test environments.
Key takeaways
- Extract your base URL to environment variables so the same test code runs against local, staging, and preview.
- Use dotenv to load
.env.localfor convenient local development without committing secrets or test credentials. - Define different Playwright projects for each environment, tuning timeouts and retries based on stability and purpose.
- Implement guard rails that refuse to run tests against production, and clearly mark destructive tests so they don't run in shared environments.
- Separate your test, staging, and production environments strictly—flawed configuration is the leading cause of accidental data loss in test automation.






Comments
Loading comments…