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.
Senior engineer insight
The moment that changed how I think about Playwright Fundamentals: I watched a team spend two days debugging “intermittent” cross-browser failures, only to discover that actionTimeout was never set in the config — each developer had a different machine speed, so the same test passed or failed depending on whose laptop ran it. Playwright’s auto-waiting is powerful, but it still needs explicit timeout boundaries to be deterministic across environments. Setting actionTimeout and navigationTimeout in playwright.config.ts once, at the team level, turned three days of intermittent pain into a green suite that stayed green.
Most common mistake: teams treat playwright.config.ts as a one-person job done on day one and never revisited — then wonder why test behaviour differs between CI and local months later when someone silently changed their BASE_URL fallback.
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.
From the field
A Wellington-based government-services team assumed that adding WebKit to their projects array was enough to validate Safari compatibility for their citizen-facing portal. Six weeks in, their product owner flagged that the real Safari on an iPad Mini was behaving completely differently to WebKit in Playwright — scroll events weren’t triggering correctly, and a sticky navigation bar was covering form submission buttons. The team had been testing Playwright’s bundled WebKit engine, which is not the same as Apple’s shipping browser on iOS. They pivoted to using Playwright for functional regression and added BrowserStack device sessions for real-device Safari sign-off. The lesson that generalises: cross-browser coverage in Playwright is an excellent first line of defence, but for public-facing NZ services where a significant portion of mobile users are on Apple devices, Playwright WebKit is a proxy, not a replacement for real-device validation.
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.
Why teams fail here
- Writing tests before
playwright.config.ts— then discovering 80 tests have hardcodedlocalhost:3000URLs that all need changing before CI can run them. - Using
beforeEachfor authentication setup instead of fixtures — authentication runs on every test even when unnecessary, and there is no shared typing between the setup block and the tests that follow. - Importing directly from
@playwright/testin test files instead of a sharedfixtures.ts— making it impossible to inject team-wide custom fixtures without touching every test file individually. - Treating
retries: 2as a fix for flakiness rather than a detection mechanism — retries mask the root cause and let unreliable tests hide in the suite for months until a deadline forces someone to investigate.
8 Self-Check
Click each question to reveal the answer.
Interview Questions
What NZ hiring managers ask about Playwright fundamentals.
Q1. What is Playwright and how does it differ from Selenium?
Strong answer: Playwright is a Microsoft-maintained end-to-end testing framework that controls Chromium, Firefox, and WebKit via browser-native DevTools protocols. Unlike Selenium (which uses WebDriver), Playwright auto-waits for elements to be actionable, supports network interception natively, and has first-class TypeScript support with a trace viewer and code generator. The key difference for NZ teams: Playwright has lower maintenance overhead on modern web applications, particularly SPAs with complex asynchronous loading.
Q2. How does Playwright auto-waiting work, and when should you add explicit waits?
Strong answer: Playwright waits for elements to be visible, enabled, and stable before interacting — no explicit sleeps needed for most interactions. Add explicit waits when you need: waitForResponse (wait for a network response before asserting), waitForURL (wait for navigation to complete), or waitForSelector with a specific state. Never use page.waitForTimeout — it is a code smell that masks timing problems. If you find yourself adding timeouts, investigate the root cause instead.
Q3. What is the Page Object Model and why is it used in Playwright suites?
Strong answer: The Page Object Model wraps each page in a class exposing user-facing actions (loginPage.login(username, password)) rather than raw selectors. Benefits: selectors are centralised so a UI change requires one update; tests read as user journeys not implementation details; new automation engineers can contribute without learning selector strategy. For NZ teams with contractor turnover, a well-structured POM is the most effective way to maintain knowledge continuity.
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.
Key takeaway
The config is the contract — every flaky test, every CI-only failure, and every “works on my machine” conversation traces back to a playwright.config.ts that was written by one person, never reviewed as a team, and quietly diverged from what CI actually runs.
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.