The Playwright Playbook — Part 1: Stop Writing Playwright Tests Like a Beginner
"Most people aren't writing bad tests on purpose. They just never learned the right way."
I've reviewed a lot of Playwright test suites over 7.6 years in QA automation.
And I keep seeing the same mistakes. Over and over.
Not from junior engineers. From seniors. From teams that have been using Playwright for years.
The tests run. The CI is green. But the suite is secretly fragile — one UI change away from mass failure, one new feature away from a 40-minute login loop, one scale-up away from complete chaos.
This series is called The Playwright Playbook for a reason.
Playbooks aren't for beginners learning how to play. They're for players who already know the basics — and want to win. 🏆
By the end of this 8-part series, you'll have a production-grade Playwright framework in TypeScript — with network interception, multi-user testing, API integration, visual regression, a CI/CD pipeline, and AI-powered test agents on top.
But first — we need to tear down what's broken.
Let's go. 🎯
🏗️ What We're Building in This Series
Before we write a single line of code — let me show you the app we're going to test throughout all 8 parts.
We'll use a simple Task Manager app as our test target. It has:
- Login / logout
- Create, edit, delete tasks
- A REST API underneath
- Role-based access (admin vs regular user)
- Real-time task updates
Every part of this series will add a new testing layer on top of this same project. By Part 8 — we'll have a complete, professional test framework built around it.
Here's the project structure we'll end up with by Part 1:
playwright-playbook/
├── tests/
│ ├── auth/
│ │ └── login.spec.ts
│ └── tasks/
│ └── task-management.spec.ts
├── pages/
│ ├── LoginPage.ts
│ └── TaskPage.ts
├── fixtures/
│ └── auth.fixture.ts
├── .auth/
│ ├── admin.json
│ └── user.json
├── playwright.config.ts
└── .env
Clean. Scalable. Ready to grow. Let's build it. 👇
❌ Mistake #1 — Fragile Selectors
This is the most common mistake in every codebase I've ever reviewed.
The bad way:
// 🔴 Brittle — breaks on any CSS refactor
await page.click('.btn-primary.submit-button-v2');
// 🔴 Brittle — breaks when DOM structure changes
await page.click('div > form > div:nth-child(3) > button');
// 🔴 Brittle — relies on auto-generated class names
await page.fill('#input_38', 'john@test.com');
These selectors are tied to implementation details. The moment a developer renames a class, restructures the DOM, or upgrades a UI library — your tests break. Not because the feature is broken. Because your test is brittle.
The right way — Playwright's built-in locators:
// ✅ Finds by accessible role — survives CSS changes
await page.getByRole('button', { name: 'Sign in' }).click();
// ✅ Finds by label — semantic and resilient
await page.getByLabel('Email address').fill('john@test.com');
// ✅ Finds by placeholder text
await page.getByPlaceholder('Enter your password').fill('secret123');
// ✅ Finds by visible text
await page.getByText('Welcome back, John').waitFor();
// ✅ The best option when you control the code — test IDs never change
await page.getByTestId('submit-btn').click();
getByRole, getByLabel, getByTestId — these are semantic locators. They describe what the element IS, not what it looks like. They survive redesigns. They survive framework upgrades. They survive the inevitable "quick CSS cleanup" from the frontend team.
Rule of thumb: If a non-technical person couldn't describe the element using your selector — it's probably fragile. 🎯
❌ Mistake #2 — Logging In Through the UI on Every Test
This one kills performance and introduces flakiness at the same time.
The bad way:
// 🔴 Every single test file has this at the top
test.beforeEach(async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@test.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
});
Multiply this by 50 tests. That's 50 UI login flows. Each one making real network requests, waiting for page loads, and praying the login page doesn't have a hiccup today.
The right way — storageState:
First, create a global setup file that logs in once and saves the authenticated session:
// global-setup.ts
import { chromium, FullConfig } from '@playwright/test';
async function globalSetup(config: FullConfig) {
const browser = await chromium.launch();
// Save admin session
const adminContext = await browser.newContext();
const adminPage = await adminContext.newPage();
await adminPage.goto('http://localhost:3000/login');
await adminPage.getByLabel('Email').fill('admin@test.com');
await adminPage.getByLabel('Password').fill('admin123');
await adminPage.getByRole('button', { name: 'Sign in' }).click();
await adminPage.waitForURL('/dashboard');
await adminContext.storageState({ path: '.auth/admin.json' });
// Save regular user session
const userContext = await browser.newContext();
const userPage = await userContext.newPage();
await userPage.goto('http://localhost:3000/login');
await userPage.getByLabel('Email').fill('user@test.com');
await userPage.getByLabel('Password').fill('user123');
await userPage.getByRole('button', { name: 'Sign in' }).click();
await userPage.waitForURL('/dashboard');
await userContext.storageState({ path: '.auth/user.json' });
await browser.close();
}
export default globalSetup;
Now wire it into playwright.config.ts:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
globalSetup: './global-setup.ts',
projects: [
// Admin tests — pre-authenticated
{
name: 'admin',
use: {
storageState: '.auth/admin.json',
},
testMatch: '**/admin/**/*.spec.ts',
},
// User tests — pre-authenticated
{
name: 'user',
use: {
storageState: '.auth/user.json',
},
testMatch: '**/user/**/*.spec.ts',
},
],
});
Now your tests start already logged in. Zero UI login overhead. Zero auth flakiness. 🚀
❌ Mistake #3 — No Project Structure (Everything in One File)
I've seen test files with 800 lines. Every test copy-pasting the same page.goto, page.fill, page.click sequences.
When the URL changes — you update it in 47 places. When the selector changes — same story.
The right way — Page Object Model (POM) in TypeScript:
// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
export class LoginPage {
readonly page: Page;
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly signInButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
this.page = page;
this.emailInput = page.getByLabel('Email address');
this.passwordInput = page.getByLabel('Password');
this.signInButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByTestId('login-error');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.signInButton.click();
}
async loginAndWait(email: string, password: string) {
await this.login(email, password);
await this.page.waitForURL('/dashboard');
}
}
// pages/TaskPage.ts
import { Page, Locator } from '@playwright/test';
export class TaskPage {
readonly page: Page;
readonly newTaskButton: Locator;
readonly taskTitleInput: Locator;
readonly saveTaskButton: Locator;
constructor(page: Page) {
this.page = page;
this.newTaskButton = page.getByRole('button', { name: 'New Task' });
this.taskTitleInput = page.getByLabel('Task title');
this.saveTaskButton = page.getByRole('button', { name: 'Save task' });
}
async goto() {
await this.page.goto('/tasks');
}
async createTask(title: string) {
await this.newTaskButton.click();
await this.taskTitleInput.fill(title);
await this.saveTaskButton.click();
}
getTaskLocator(title: string): Locator {
return this.page.getByRole('listitem').filter({ hasText: title });
}
}
Now your tests are clean and readable:
// tests/tasks/task-management.spec.ts
import { test, expect } from '@playwright/test';
import { TaskPage } from '../../pages/TaskPage';
test('user can create a new task', async ({ page }) => {
const taskPage = new TaskPage(page);
await taskPage.goto();
await taskPage.createTask('Write unit tests');
await expect(taskPage.getTaskLocator('Write unit tests')).toBeVisible();
});
One change to TaskPage.ts — every test that uses it gets updated. That's the power of POM. 💪
❌ Mistake #4 — Hard-Coded Waits
Nothing says "I don't trust my tests" like waitForTimeout.
The bad way:
// 🔴 Hoping 3 seconds is enough. It isn't. On a slow CI machine, it never is.
await page.waitForTimeout(3000);
await page.click('#submit');
// 🔴 Even worse — guessing at load time
await page.goto('/dashboard');
await page.waitForTimeout(5000);
await expect(page.locator('.tasks-list')).toBeVisible();
Hard-coded waits are the definition of flaky. Fast machine? Test passes. Slow CI? Fails. High network load? Fails. The test isn't measuring anything — it's just hoping.
The right way — Playwright's auto-wait and explicit conditions:
// ✅ Wait for a specific element to appear
await page.getByTestId('tasks-list').waitFor({ state: 'visible' });
// ✅ Wait for a network response to complete
await Promise.all([
page.waitForResponse(resp => resp.url().includes('/api/tasks') && resp.status() === 200),
page.getByRole('button', { name: 'Load tasks' }).click(),
]);
// ✅ Wait for URL to change — confirms navigation completed
await page.waitForURL('/dashboard');
// ✅ Wait for load state — page fully loaded
await page.waitForLoadState('networkidle');
// ✅ Playwright auto-waits before most actions — this just works
await expect(page.getByRole('heading', { name: 'My Tasks' })).toBeVisible();
Playwright's built-in locator methods already auto-wait. click(), fill(), check() — they all wait for the element to be actionable before acting. You rarely need to manually wait for anything.
If you find yourself writing waitForTimeout — stop. Find the condition you're actually waiting for. Wait for that instead. 🎯
❌ Mistake #5 — Hardcoded Config Everywhere
// 🔴 Scattered across 30 test files
await page.goto('http://localhost:3000/login');
await request.post('http://localhost:3000/api/tasks');
The moment you need to run tests against staging — you're doing a find-and-replace across your entire codebase.
The right way — centralized playwright.config.ts + .env:
# .env
BASE_URL=http://localhost:3000
API_URL=http://localhost:3000/api
ADMIN_EMAIL=admin@test.com
ADMIN_PASSWORD=admin123
USER_EMAIL=user@test.com
USER_PASSWORD=user123
// playwright.config.ts
import { defineConfig } from '@playwright/test';
import dotenv from 'dotenv';
dotenv.config();
export default defineConfig({
use: {
baseURL: process.env.BASE_URL,
// Screenshot on failure — always
screenshot: 'only-on-failure',
// Video on failure — invaluable for CI debugging
video: 'retain-on-failure',
// Full trace on first retry
trace: 'on-first-retry',
},
// Retry flaky tests once in CI
retries: process.env.CI ? 1 : 0,
// Run tests in parallel
workers: process.env.CI ? 4 : undefined,
reporter: [
['html', { open: 'never' }],
['list'],
],
});
Now your tests use relative paths:
// ✅ baseURL is resolved from config — works in any environment
await page.goto('/login');
await page.goto('/dashboard');
Switch environments by changing one line in .env. Or one environment variable in CI. That's it. ✅
❌ Mistake #6 — Writing Assertions That Don't Actually Assert Anything
// 🔴 This passes even if the element is invisible, disabled, or wrong
const element = await page.locator('.task-item');
expect(element).toBeTruthy(); // The locator object always exists!
// 🔴 Asserting the wrong thing
await page.click('button[type="submit"]');
// No assertion after this — just hoping the action worked
The right way — Playwright's web-first assertions:
// ✅ Checks actual visibility in the DOM
await expect(page.getByTestId('task-item')).toBeVisible();
// ✅ Checks the actual text content
await expect(page.getByRole('heading')).toHaveText('My Tasks');
// ✅ Checks element count
await expect(page.getByRole('listitem')).toHaveCount(3);
// ✅ Checks URL after navigation
await expect(page).toHaveURL('/dashboard');
// ✅ Checks input value
await expect(page.getByLabel('Task title')).toHaveValue('Write unit tests');
// ✅ Checks element is NOT visible (for negative assertions)
await expect(page.getByTestId('error-message')).not.toBeVisible();
// ✅ Checks element is disabled
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled();
Playwright's expect() assertions are web-first — they automatically retry until the condition is met or the timeout is reached. No manual waiting. No false passes.
❌ Mistake #7 — Tests That Depend on Each Other
// 🔴 Test 2 depends on Test 1 having run first
test('create a task', async ({ page }) => {
await taskPage.createTask('Buy groceries');
});
test('delete the task', async ({ page }) => {
// What if Test 1 failed? Or ran in a different order?
await taskPage.deleteTask('Buy groceries');
});
Dependent tests are a maintenance nightmare. Run them in parallel — they break. Run them in a different order — they break. One failure cascades into many.
The right way — fully independent, self-contained tests:
// ✅ Each test creates its own data, asserts, and cleans up
test('user can delete a task', async ({ page }) => {
const taskPage = new TaskPage(page);
// Arrange — create the task this test needs
await taskPage.goto();
await taskPage.createTask('Temporary task for deletion test');
await expect(taskPage.getTaskLocator('Temporary task for deletion test')).toBeVisible();
// Act — delete it
await taskPage.deleteTask('Temporary task for deletion test');
// Assert — confirm it's gone
await expect(taskPage.getTaskLocator('Temporary task for deletion test')).not.toBeVisible();
});
Every test owns its lifecycle. Setup → action → assertion → done. No shared state. No assumptions about what ran before. ✅
🏁 The Full Project Setup
Here's everything pulled together. This is what your project should look like by the end of Part 1:
// playwright.config.ts — the complete config
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
dotenv.config();
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 4 : undefined,
globalSetup: './global-setup.ts',
reporter: [
['html', { open: 'never' }],
['list'],
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'on-first-retry',
},
projects: [
{
name: 'admin',
use: {
...devices['Desktop Chrome'],
storageState: '.auth/admin.json',
},
},
{
name: 'user',
use: {
...devices['Desktop Chrome'],
storageState: '.auth/user.json',
},
},
],
});
# Final project structure after Part 1
playwright-playbook/
├── tests/
│ ├── auth/
│ │ └── login.spec.ts
│ └── tasks/
│ └── task-management.spec.ts
├── pages/
│ ├── LoginPage.ts
│ └── TaskPage.ts
├── .auth/ ← git-ignored, generated by globalSetup
│ ├── admin.json
│ └── user.json
├── global-setup.ts
├── playwright.config.ts
├── .env ← git-ignored
├── .env.example ← committed to repo
└── package.json
🗺️ What's Coming in This Series
We've set the foundation. Now we build. 🏗️
Part 1 — Stop Writing Tests Like a Beginner ← You are here
Part 2 — Network Interception: The Complete Guide
Part 3 — Multi-User, Multi-Tab & Context Testing
Part 4 — API Testing (The Underrated Superpower)
Part 5 — Visual Regression Testing
Part 6 — Debugging Like a Pro: Trace Viewer & Inspector
Part 7 — The CI/CD Setup Nobody Shows You
Part 8 — Playwright Meets AI: Agents, MCP & Self-Healing Tests
In Part 2, we go deep on network interception — mocking APIs, simulating failures, asserting on real network calls, and recording HAR files. It's where the real power of Playwright starts to show.
🎯 Who Is This Series For?
- QA engineers who are already using Playwright but know their suite could be better
- Automation engineers who want to level up from "it works" to "it scales"
- Developers who write Playwright tests and want to do it properly
- Anyone who has inherited a Playwright codebase and wondered why it keeps breaking
No beginner content here. We skip the "what is Playwright" intro.
If you can write a test — this series will make you dangerous. 🔥
🔖 Before You Go
Every pattern in this article is something I've fixed in a real codebase.
The hard selectors. The login loops. The waitForTimeout scattered everywhere. The 800-line test files. I've seen all of it — and I've cleaned all of it.
The good news: fixing these isn't complicated. It's just about knowing the right patterns.
Now you know them. 💪
Follow me so you don't miss Part 2 — where we get hands-on with Playwright's most underused feature: network interception. We'll mock APIs, simulate 500 errors, and test frontend behaviour without touching a single line of backend code.
Drop a comment below 👇
- Which of these mistakes is your team making right now?
- What's the most painful part of maintaining your Playwright suite?
- Or are you starting fresh — building a new framework from scratch?
All levels welcome here. Let's build something solid together. 🙌
Faizal Shaikh | Senior Automation Engineer | Playwright & AI Testing
Connect with me on LinkedIn
Top comments (2)
7.6 years in QA and I'm still catching teams chaining CSS class selectors like it's 2019 😂 The getByRole shift was genuinely the best thing Playwright did. Curious how you approach dynamic loading states in the later parts — auto-waiting or explicit patterns?
Haha right!! .btn-primary-v3 energy never dies 😂
And great question — auto-waiting handles 90% of cases cleanly, but there are specific scenarios where I go explicit: waiting on a waitForResponse tied to an API call complting, or waitForLoadState('networkidle') for heavier data loads.
Covering that properly in Part 2 (Network Interception) — because the cleanest pattern I've found is pairing page.waitForResponse() with the action that triggers it, so you're waiting on the actual condition not a DOM guess.