As a Playwright suite grows, three things decide whether it stays manageable: where test data comes from, how tests switch between environments, and how you run the right subset. This guide covers test data management, environment configuration and tags and annotations.
Test Data Management
Level: L3.
What is it?
Strategies for supplying data to tests: inline arrays, JSON/CSV files, typed factories, random data (Faker), API-generated, and DB-generated data.
Why do we need it?
Hard-coded, duplicated data makes tests brittle and coupled. Good data management enables data-driven tests, parallel safety (unique data), and easy environment switching.
How does it work?
Externalize data (files/factories) and parameterize tests. Generate unique data per run to avoid collisions in parallel/shared environments.
Syntax
// JSONimport users from '../data/users.json';// typed factoryimport { faker } from '@faker-js/faker';export const newUser = () => ({ email: faker.internet.email(), password: 'P@ssw0rd!', name: faker.person.fullName(),});
Basic Example (data-driven)
const cases = [ { user: 'standard_user', ok: true }, { user: 'locked_out_user', ok: false },];for (const c of cases) { test(`login ${c.user}`, async ({ page }) => { /* ... assert by c.ok */ });}
Practical Example — Faker for unique registration
import { newUser } from '../utils/factories';test('register a new user', async ({ page }) => { const u = newUser(); // unique every run await page.goto('/register'); await page.getByLabel('Name').fill(u.name); await page.getByLabel('Email').fill(u.email); await page.getByLabel('Password').fill(u.password); await page.getByRole('button', { name: 'Create account' }).click(); await expect(page.getByText(`Welcome, ${u.name}`)).toBeVisible();});
Line-by-Line Explanation
- newUser() produces unique, typed data each run → no “email already exists” collisions in parallel/shared envs.
- The test uses u throughout and asserts on the generated name — self-consistent and isolated.
Common Mistakes
- Reusing the same email/username across parallel tests → collisions.
- Committing environment-specific secrets in data files.
- Giant JSON blobs no one maintains; prefer small typed factories.
Best Practices
- Generate unique data (Faker/timestamps) for create flows.
- Type your data with interfaces; keep factories in utils/.
- Separate static reference data (JSON) from dynamic per-run data (factories).
- Never store real secrets in data files; use env/secrets.
Interview Questions
- Q: How do you avoid data collisions in parallel runs? A: Generate unique data per test (Faker, timestamps, UUIDs).
- Q: JSON vs factory data? A: JSON for stable reference data; factories for dynamic, unique, typed data.
- Q: How to run one test over many inputs? A: Loop over a typed array creating one test per case (data-driven).
Practice Exercise
Build a factories.ts with newUser() and newProduct() using Faker; write a data-driven login test from a JSON file and a registration test using the factory.
Real-World Scenario
A create-account suite ran fine solo but failed in parallel with duplicate-email errors. Switching to Faker-generated emails made every worker’s data unique, and the suite parallelized cleanly.
Environment Management
Level: L4.
What is it?
Running the same suite against multiple environments (DEV/QA/UAT/PROD) with environment-specific URLs, credentials, and data — driven by config and .env files, with secrets kept out of the repo.
Why do we need it?
Tests must target the right environment safely. Hard-coded URLs/credentials are brittle and insecure. You need one framework, many environments, zero code changes to switch.
How does it work?
Load environment variables (from the shell/CI or a .env file via dotenv) and read them in playwright.config.ts. Select the env with a single variable; keep secrets in CI secret stores, not files.
Structure
.env # local defaults (gitignored)
.env.qa # per-env files (gitignored)
.env.uat
config/env.ts # loads + validates env vars
playwright.config.ts # reads env for baseURL, etc.
Loading env (dotenv)
// playwright.config.ts (top)import { defineConfig } from '@playwright/test';import dotenv from 'dotenv';const ENV = process.env.TEST_ENV ?? 'qa';dotenv.config({ path: `.env.${ENV}` }); // load the chosen env fileexport default defineConfig({ use: { baseURL: process.env.BASE_URL },});
# .env.qa (gitignored)BASE_URL=https://qa.myapp.comAPI_URL=https://qa.myapp.com/api
Practical Example — validate required vars (fail fast)
// config/env.tsconst required = ['BASE_URL', 'API_URL', 'TEST_USER', 'TEST_PASSWORD'] as const;for (const key of required) { if (!process.env[key]) throw new Error(`Missing required env var: ${key}`);}export const env = { baseURL: process.env.BASE_URL!, apiURL: process.env.API_URL!, user: process.env.TEST_USER!, password: process.env.TEST_PASSWORD!,};
Run against different environments:
TEST_ENV=qa npx playwright testTEST_ENV=uat npx playwright test
Line-by-Line Explanation
- TEST_ENV picks which .env.<env> file to load; default qa.
- dotenv.config({ path }) populates process.env locally; in CI, the same vars come from secret stores (no file).
- config/env.ts validates required vars before tests run, so a missing secret fails immediately with a clear message instead of a confusing mid-test error.
Secrets handling
- Never commit real secrets. .gitignore all .env*.
- In CI, inject via the platform’s secret store (GitHub Actions secrets, Jenkins credentials).
- Provide a committed .env.example (keys only, no values) to document required vars.
Common Mistakes
- Committing .env with real credentials.
- Hard-coding PROD URLs (risk of running destructive tests on prod!).
- No validation → cryptic failures when a var is missing.
- Different code paths per env instead of one config reading vars.
Best Practices
- One config, env-selected via a single variable.
- Validate required vars at startup (fail fast).
- Secrets from CI stores; .env* gitignored; commit .env.example.
- Guard against accidental destructive runs on PROD (explicit opt-in flag).
Interview Questions
- Q: How do you run one suite against multiple environments? A: Env-driven config (TEST_ENV → .env.<env>), reading BASE_URL/creds from env vars.
- Q: Where do secrets live? A: CI secret stores / local gitignored .env; never in the repo.
- Q: How to prevent missing-config failures mid-run? A: Validate required vars at startup and fail fast.
- Q: How do you document required vars? A: A committed .env.example with keys only.
Practice Exercise
Add dotenv loading keyed by TEST_ENV, create .env.qa and .env.uat (gitignored), a .env.example, and a required-vars validator. Run the suite against both envs by changing only TEST_ENV.
Real-World Scenario
A misconfigured job nearly ran a data-mutating suite against PROD. Adding env validation + an explicit ALLOW_PROD=true guard made destructive runs impossible without deliberate opt-in — a safeguard every serious framework needs.
Test Tags & Annotations
Level: L3.
What is it?
Tags label tests (@smoke, @regression) for selective runs. Annotations attach metadata/behavior (skip, fixme, slow, fail, custom notes).
Why do we need it?
To run the right subset at the right time (fast smoke on PRs, full regression nightly) and to document/adjust test behavior transparently.
How does it work?
Tags live in the title or the tag option; filter with --grep. Annotations use test.skip()/fixme()/slow()/fail() or test.info().annotations.
Syntax
test('checkout works @smoke @regression', async ({ page }) => { /* ... */ });// modern tag optiontest('login', { tag: ['@smoke'] }, async ({ page }) => { /* ... */ });// annotationstest.skip(({ browserName }) => browserName === 'webkit', 'not supported yet');test('slow report', async ({ page }) => { test.slow(); /* ... */ });
npx playwright test --grep @smokenpx playwright test --grep-invert @slow
Basic Example
test('health check @smoke', async ({ page }) => { await page.goto('/'); await expect(page).toHaveTitle(/App/);});
Practical Example — conditional skip + metadata
test('geolocation feature', async ({ page, browserName }) => { test.skip(browserName === 'firefox', 'geo mock differs on FF'); test.info().annotations.push({ type: 'jira', description: 'QA-1234' }); // ...});
Line-by-Line Explanation
- test.skip(condition, reason) skips only when the condition holds, with a visible reason.
- test.info().annotations.push(...) attaches a Jira reference that appears in the report — traceability.
Common Mistakes
- Unconditional skip/fixme with no reason or ticket.
- Tag typos (@smoke vs @Smoke) causing missed selections.
- Using tags as the only structure instead of describe grouping.
Best Practices
- Standardize a tag vocabulary (@smoke, @regression, @slow, @wip).
- Always give skip/fixme a reason and ticket.
- Combine tags with describe for structure + selectability.
Interview Questions
- Q: How to run only smoke tests? A: --grep @smoke (tag in title or tag option).
- Q: skip vs fixme vs fail? A: skip = don’t run (conditionally); fixme = known broken, don’t run; fail = expected to fail (passes if it fails).
- Q: How to link a test to a ticket? A: Push a custom annotation via test.info().annotations.
Practice Exercise
Tag your suite with @smoke/@regression, add a browser-conditional skip with a reason, and run each subset separately.
Real-World Scenario
PR pipeline runs @smoke (~2 min); a nightly job runs full @regression. A WebKit-specific bug is skipped with a linked ticket so the suite stays green while the fix is tracked.
FAQs
How do you run tests against different environments in Playwright?
Read an environment variable (for example TEST_ENV) in playwright.config.ts to choose baseURL and other settings, keep secrets in CI variables, and never hard-code URLs in tests.
How do you tag Playwright tests?
Add tags in the test title or the tag option (for example @smoke) and run them with --grep @smoke; use --grep-invert to exclude.