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 have by the end of 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
βββ global-setup.ts
βββ playwright.config.ts
βββ .env
βββ package.json
Clean. Scalable. Ready to grow. Let's build every file. π
β 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 + globalSetup:
Login once before the entire suite runs. Save the session to a file. Every test picks it up and starts already authenticated.
// global-setup.ts
import { chromium } from '@playwright/test';
import * as dotenv from 'dotenv';
dotenv.config();
async function globalSetup() {
const browser = await chromium.launch();
// Save admin session
const adminContext = await browser.newContext();
const adminPage = await adminContext.newPage();
await adminPage.goto(`${process.env.BASE_URL}/login`);
await adminPage.getByLabel('Email').fill(process.env.ADMIN_EMAIL!);
await adminPage.getByLabel('Password').fill(process.env.ADMIN_PASSWORD!);
await adminPage.getByRole('button', { name: 'Sign in' }).click();
await adminPage.waitForURL('/dashboard');
await adminContext.storageState({ path: '.auth/admin.json' });
await adminContext.close();
// Save regular user session
const userContext = await browser.newContext();
const userPage = await userContext.newPage();
await userPage.goto(`${process.env.BASE_URL}/login`);
await userPage.getByLabel('Email').fill(process.env.USER_EMAIL!);
await userPage.getByLabel('Password').fill(process.env.USER_PASSWORD!);
await userPage.getByRole('button', { name: 'Sign in' }).click();
await userPage.waitForURL('/dashboard');
await userContext.storageState({ path: '.auth/user.json' });
await userContext.close();
await browser.close();
}
export default globalSetup;
β 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();
}
async deleteTask(title: string) {
const taskItem = this.getTaskLocator(title);
// Hover to reveal the delete button
await taskItem.hover();
await taskItem.getByRole('button', { name: 'Delete' }).click();
// Confirm deletion in the dialog
await this.page.getByRole('button', { name: 'Confirm' }).click();
}
getTaskLocator(title: string): Locator {
return this.page.getByRole('listitem').filter({ hasText: title });
}
}
Note the deleteTask() method β we'll need this in Part 2 when we assert on DELETE network calls. Build it once, use it everywhere. πͺ
β 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 AND the action that triggers it β together
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 (git-ignored)
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, devices } from '@playwright/test';
import * as 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',
},
},
],
});
Now your tests use relative paths β works in any environment:
// β
baseURL resolved from config β one change switches environments
await page.goto('/login');
await page.goto('/dashboard');
β Mistake #6 β 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!
// π΄ No assertion after an action β just hoping it worked
await page.click('button[type="submit"]');
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');
// β
Negative assertion
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 owns its full lifecycle
test('user can delete a task', async ({ page }) => {
const taskPage = new TaskPage(page);
// Arrange β create the data 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
await taskPage.deleteTask('Temporary task for deletion test');
// Assert
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. β
π Building the Remaining Files
We have the POM and config. Now let's complete the rest of the project structure.
The Auth Fixture
Playwright fixtures are how you share setup logic cleanly across tests without beforeEach soup. Here we create a fixture that gives tests a pre-initialised TaskPage tied to a specific role.
// fixtures/auth.fixture.ts
import { test as base } from '@playwright/test';
import { TaskPage } from '../pages/TaskPage';
import { LoginPage } from '../pages/LoginPage';
type AuthFixtures = {
taskPage: TaskPage;
loginPage: LoginPage;
};
export const test = base.extend<AuthFixtures>({
taskPage: async ({ page }, use) => {
const taskPage = new TaskPage(page);
await taskPage.goto();
await use(taskPage);
},
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await use(loginPage);
},
});
export { expect } from '@playwright/test';
Now tests import from the fixture instead of @playwright/test directly:
// Instead of:
import { test, expect } from '@playwright/test';
// Use:
import { test, expect } from '../../fixtures/auth.fixture';
The Login Spec
// tests/auth/login.spec.ts
import { test, expect } from '../../fixtures/auth.fixture';
import { LoginPage } from '../../pages/LoginPage';
// This test deliberately does NOT use storageState
// It tests the login flow itself
test.use({ storageState: { cookies: [], origins: [] } });
test('successful login redirects to dashboard', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.loginAndWait(
process.env.USER_EMAIL!,
process.env.USER_PASSWORD!
);
await expect(page).toHaveURL('/dashboard');
await expect(page.getByRole('heading', { name: 'My Tasks' })).toBeVisible();
});
test('invalid credentials show error message', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('wrong@test.com', 'wrongpassword');
await expect(loginPage.errorMessage).toBeVisible();
await expect(loginPage.errorMessage).toHaveText('Invalid email or password');
// Should stay on login page β no redirect
await expect(page).toHaveURL('/login');
});
test('empty fields show validation errors', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
// Click sign in without filling anything
await loginPage.signInButton.click();
await expect(page.getByText('Email is required')).toBeVisible();
await expect(page.getByText('Password is required')).toBeVisible();
});
Notice test.use({ storageState: { cookies: [], origins: [] } }) at the top β this clears the stored auth session for this file only, so we can actually test the login flow without being auto-authenticated. π
The Task Management Spec
// tests/tasks/task-management.spec.ts
import { test, expect } from '../../fixtures/auth.fixture';
test('user can create a new task', async ({ taskPage }) => {
await taskPage.createTask('Write integration tests');
await expect(taskPage.getTaskLocator('Write integration tests')).toBeVisible();
});
test('user can delete a task', async ({ taskPage }) => {
// Arrange
await taskPage.createTask('Task to delete');
await expect(taskPage.getTaskLocator('Task to delete')).toBeVisible();
// Act
await taskPage.deleteTask('Task to delete');
// Assert
await expect(taskPage.getTaskLocator('Task to delete')).not.toBeVisible();
});
test('task list shows correct count after creation', async ({ taskPage, page }) => {
const initialCount = await page.getByRole('listitem').count();
await taskPage.createTask('Count test task');
await expect(page.getByRole('listitem')).toHaveCount(initialCount + 1);
});
Clean. No boilerplate. The fixture handles the navigation and page object setup. Tests just test. β
π Final Project Structure After Part 1
Every file listed here has been fully built in this article:
playwright-playbook/
βββ tests/
β βββ auth/
β β βββ login.spec.ts β
built above
β βββ tasks/
β βββ task-management.spec.ts β
built above
βββ pages/
β βββ LoginPage.ts β
built above
β βββ TaskPage.ts β
built above (includes deleteTask)
βββ fixtures/
β βββ auth.fixture.ts β
built above
βββ .auth/ β git-ignored, auto-generated
β βββ admin.json
β βββ user.json
βββ global-setup.ts β
built above
βββ playwright.config.ts β
built above
βββ .env β git-ignored
βββ .env.example β commit this to repo
βββ package.json
πΊοΈ What's Coming in This Series
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 500 errors, asserting on real API payloads, and recording HAR files. The TaskPage we built here (including deleteTask) will be used to assert on exactly what network calls Playwright fires when you interact with the app.
π― 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












