Playwright Fundamentals
From zero to a working test suite. This lesson covers project setup, playwright.config.ts, the test runner model, fixtures, and how Playwright’s auto-waiting model eliminates the most common source of flakiness.
1 The Hook
A mid-level SDET joins a Wellington startup that uses Playwright but has no central config. Each developer set it up differently. Some use TypeScript, some plain JavaScript. Some have baseURL set; others hardcode URLs throughout the test files. The CI pipeline runs a different browser to local dev.
When the SDET sets up a standard playwright.config.ts, three previously “intermittent” failures become permanent. They were only passing because of browser-specific timing quirks in one developer’s local setup. The tests were never actually reliable — they were just lucky.
The config is the foundation. Get it wrong and everything built on it is unstable. A flaky test is usually a symptom. An inconsistent config is usually the disease.
2 The Rule
A playwright.config.ts is not boilerplate. It defines your test contract: what browsers, what base URL, what timeouts, what retry policy, what reporter. Define it first, as a team, and commit it.
3 The Analogy
playwright.config.ts is the building code for your test suite.
Electricians, plumbers, and carpenters all work differently. The building code makes their work consistent and compatible — same socket heights, same pipe standards, same load-bearing specs. Without it, everyone does their own thing and the house doesn’t hold together. When something fails, you don’t know which tradie is at fault because there was never a shared standard.
Your config is that shared standard. One playwright.config.ts, committed to the repo, agreed by the team. Every test run — local, CI, colleague’s laptop — operates to the same contract.
4 Watch Me Do It
Here is a production-grade playwright.config.ts. Every setting is deliberate.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI, // Don't commit test.only
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: process.env.CI
? [['html'], ['github']]
: [['html', { open: 'on-failure' }]],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
actionTimeout: 10_000,
navigationTimeout: 30_000,
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});
forbidOnly prevents a developer committing test.only() to main. It is the cheapest CI guardrail you can add.Now the fixture pattern. Fixtures are composable, lazily initialised, and automatically torn down. They are strictly better than beforeEach for shared setup.
// fixtures.ts
import { test as base, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
type Fixtures = {
loginPage: LoginPage;
authenticatedPage: void;
};
export const test = base.extend<Fixtures>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await use(loginPage);
},
authenticatedPage: async ({ page }, use) => {
// Log in once, reuse auth state
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/dashboard');
await use();
},
});
export { expect };
Tests import from fixtures.ts instead of @playwright/test directly. The fixture is only initialised when a test declares it as a parameter — unused fixtures cost nothing.
Auto-waiting is baked into every Playwright action. When you call page.getByRole('button').click(), Playwright waits for the element to be visible, stable, enabled, and not obscured before clicking. You do not write waitForSelector or sleep. If a test is fragile because of timing, the root cause is almost always a missing assertion before an action, not a missing sleep.
5 When to Use It
- Every new Playwright project — config before the first test.
- Retrofitting to existing projects that have inconsistent setup.
- When onboarding a new team member: the config is the first document they read.
- Before starting any cross-browser work — the
projectsarray is how you add browsers, not command-line flags.
6 Common Mistakes
✗ I used to think: I’ll sort out the config later once tests are written.
Actually: retrofitting a consistent config onto 200 existing tests is painful. Hardcoded URLs, inconsistent timeout assumptions, and missing browser coverage all need to be unpicked test by test. Set the config before writing the first test. It takes 20 minutes and saves days.
✗ I used to think: retries fix flaky tests.
Actually: retries hide flaky tests. A test that passes on the second attempt is still broken — it means the first attempt produced the wrong result. Use retries: 2 in CI as a safety net only, and track which tests are retrying. If more than 5% of tests retry regularly, investigate the root cause.
✗ I used to think: process.env.CI reliably detects CI vs local.
Actually: GitHub Actions sets CI=true automatically, but other CI systems do not. Set a specific environment variable in your pipeline (e.g. PLAYWRIGHT_CI=true) and check that instead. Do not assume any CI system follows GitHub’s convention.
7 Now You Try
A new NZ SaaS project is starting with Playwright. Write the playwright.config.ts for a project that: runs in Chromium and WebKit, uses a base URL from an environment variable with fallback to localhost:4000, captures screenshots on failure, runs 2 retries in CI, uses 4 workers in CI, and generates both HTML and JUnit reporters in CI only.
8 Self-Check
Click each question to reveal the answer.
What does forbidOnly: !!process.env.CI protect against?
It prevents a developer from accidentally committing test.only() to the repository. In CI, process.env.CI is set, so forbidOnly becomes true. If any test file contains .only, Playwright exits immediately with an error rather than running only that test and silently skipping everything else.
Why are fixtures preferred over beforeEach for shared setup?
Fixtures are composable — you can combine multiple fixtures as parameters. They are lazily initialised, so a fixture is only set up when a test actually requests it. They have automatic teardown through the use() pattern. And they can be typed, making IDE autocompletion work correctly. beforeEach runs unconditionally and shares no type information with the tests that follow it.
What is the difference between actionTimeout and navigationTimeout?
actionTimeout applies to individual interactions: clicks, fills, selects, and assertions. navigationTimeout applies to page navigations and URL changes. Actions that wait for a page load need navigationTimeout; interactions with elements already on the page use actionTimeout. Setting them separately lets you be strict about UI responsiveness without being strict about initial page load time.
9 ISTQB Mapping
CTAL-TAE Section 4.1 — Test automation architecture: configuration management. The config file is the canonical example of a test automation architecture decision. It defines browser targets, execution environment, retry policy, and reporting strategy — all architectural concerns that must be made explicit and version-controlled rather than left to individual developer preference.