Test suites that work fine at 50 tests often collapse at 200: tests that depend on each other can't run in parallel, hardcoded waits make the suite slow and still flaky, and repeated locator chains scattered across files mean a single UI change breaks dozens of tests. The practices that prevent this are consistent: descriptive test names, strict state isolation, waitForTimeout replaced with element-based waiting, and Page Object Model introduced at the right moment. This article covers each practice with concrete Playwright examples and the reasoning behind each decision.
Name tests like sentences, not like code
The test name is the first thing you read when something fails at 2am in CI. It should tell you exactly what broke without opening the file.
Bad:
test('login test', async ({ page }) => { ... });
test('test1', async ({ page }) => { ... });
test('checkTable', async ({ page }) => { ... });Good:
test('user can log in with valid credentials', async ({ page }) => { ... });
test('login fails with incorrect password', async ({ page }) => { ... });
test('travel items table shows 5 rows after login', async ({ page }) => { ... });The pattern is: [who] can/cannot [do what] [under what condition]. Write it so a non-technical person reading the CI output understands what failed.
describe blocks work the same way:
test.describe('Login', () => {
test('succeeds with valid credentials', async ({ page }) => { ... });
test('fails with wrong password', async ({ page }) => { ... });
test('fails with empty email', async ({ page }) => { ... });
});One assertion per test: the ideal, not the rule
You'll see advice saying "one assertion per test." The real rule is: one logical concept per test.
A test that logs in and checks the page title is fine (those are part of the same flow). A test that logs in, checks the title, edits a record, checks the record updated, then logs out is doing too much. When it fails you won't know which part broke.
Good. One concept, multiple related assertions:
test('login redirects to dashboard with correct header', async ({ page }) => {
await page.goto('https://lab.becomeqa.com');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill('admin@becomeqa.com');
await page.getByLabel('Password').fill('testpass123');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page).toHaveURL(/dashboard/);
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
await expect(page.getByText('My Travel Items')).toBeVisible();
});Keep tests independent from each other
Tests that depend on each other are a trap. If test 3 only works when test 2 has run first, you can't run tests in parallel, you can't run a single test in isolation, and when test 2 breaks you get a cascade of failures that's hard to diagnose.
Every test must set up its own state and clean up after itself.
Each test that needs a logged-in user should do the login itself, or use storageState to save the auth cookie and reuse it without repeating the UI flow.
// Bad: depends on previous test having logged in
test('can see travel items', async ({ page }) => {
// assumes we're already logged in — breaks if run alone
await expect(page.getByText('My Travel Items')).toBeVisible();
});
// Good: sets up its own state
test('can see travel items', async ({ page }) => {
await page.goto('https://lab.becomeqa.com');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill('admin@becomeqa.com');
await page.getByLabel('Password').fill('testpass123');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('My Travel Items')).toBeVisible();
});test.only in committed code. It silently disables all other tests in the file. If a test.only gets merged, your CI passes with 1 test instead of 50 and nobody notices until something breaks in production.Use Page Object Model once files get large
When your test file hits 200+ lines and every test repeats the same getByLabel('Username').fill(...) calls, it's time for Page Object Model (POM).
POM moves page interactions into a separate class. Tests call methods on that class instead of raw Playwright commands. When the login form changes, you update one class instead of every test that touches login.
// pages/LoginPage.ts
import { Page } from '@playwright/test';
export class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('https://lab.becomeqa.com');
await this.page.getByRole('button', { name: 'Login' }).click();
}
async login(username: string, password: string) {
await this.page.getByLabel('Username').fill(username);
await this.page.getByLabel('Password').fill(password);
await this.page.getByRole('button', { name: 'Submit' }).click();
}
}// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
test('user can log in', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('admin@becomeqa.com', 'testpass123');
await expect(page.getByText('My Travel Items')).toBeVisible();
});When the login UI changes, you fix LoginPage.ts and all tests stay green.
Don't rush to POM. Write tests without it first. When you catch yourself copy-pasting the same 5 lines for the third time, that's the signal.
Avoid hardcoded waits
page.waitForTimeout(3000) is a code smell. You're telling Playwright to wait 3 seconds regardless of what's on the screen. The test becomes slow on fast machines and still flaky on slow CI runners.
Playwright auto-waits for elements before interacting with them. When you actually need to wait for something specific, wait for that specific thing:
// Bad
await page.waitForTimeout(3000);
await page.getByRole('button', { name: 'Save' }).click();
// Good — Playwright waits automatically before clicking
await page.getByRole('button', { name: 'Save' }).click();
// Good — wait for a specific element to appear
await page.waitForSelector('[data-testid="success-toast"]');
// Good — wait for a network request to complete
await page.waitForResponse(resp => resp.url().includes('/api/items'));The only time waitForTimeout is acceptable is in local debugging to slow things down and see what's happening. It should never exist in committed test code.
Use environment variables for credentials and URLs
Hardcoding credentials and base URLs in tests creates two problems: they leak into git history, and changing them means searching through every test file.
Store them in a .env file and load them via Playwright's config:
// .env (never commit this file)
BASE_URL=https://lab.becomeqa.com
TEST_USER=admin@becomeqa.com
TEST_PASS=testpass123// playwright.config.ts
export default defineConfig({
use: {
baseURL: process.env.BASE_URL || 'https://lab.becomeqa.com',
},
});// tests/login.spec.ts
test('user can log in', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Login' }).click();
await page.getByLabel('Username').fill(process.env.TEST_USER!);
await page.getByLabel('Password').fill(process.env.TEST_PASS!);
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByText('My Travel Items')).toBeVisible();
});Add .env to .gitignore. In CI, set the environment variables in the pipeline config instead.
Structure your test folder before it grows
A folder structure that works for 10 tests falls apart at 100. Set it up early:
tests/
auth/
login.spec.ts
logout.spec.ts
items/
items-list.spec.ts
items-crud.spec.ts
api/
items-api.spec.ts
pages/
LoginPage.ts
ItemsPage.ts
fixtures/
auth.fixture.tsGroup by feature, not by type. tests/auth/ is better than tests/ui/ because when something in auth breaks, you know exactly where to look.
npx playwright test tests/auth/ to run only the tests in a folder. Useful when you're working on a specific feature and don't want to wait for the full suite.Write tests that document intent
A test is documentation: variable names should describe what they hold, test data should look realistic, and comments should only appear for non-obvious setup.
// Hard to understand
const u = 'admin@becomeqa.com';
const p = 'testpass123';
await page.getByLabel('Username').fill(u);
// Clear
const adminEmail = 'admin@becomeqa.com';
const adminPassword = 'testpass123';
await page.getByLabel('Username').fill(adminEmail);Small difference in code, large difference in readability six months later.
Run the full suite before merging
Tests that only run locally are suggestions, not tests. Connect your Playwright tests to CI so they run on every pull request automatically.
At minimum, your CI should install dependencies, install browsers, run the suite, and upload the HTML report if tests fail:
# .github/workflows/tests.yml (simplified)
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright testIf tests pass in CI, you merge. If they fail, you fix before merging. That's the contract.
FAQ
How many tests is too many for one file?Around 300–400 lines, when scrolling to find a test becomes annoying. Split by feature at that point.
Should I test every edge case?No. Test the happy path, the most common error path, and any edge cases that have caused real bugs. The goal is confidence, not 100% coverage for its own sake.
My tests pass locally but fail in CI. What's usually wrong?Three most common causes: a hardcoded localhost URL, a missing await, or a race condition hidden by your machine being faster than the CI runner. Check the trace viewer output from CI: it shows exactly where it broke.
beforeEach vs a fixture?
beforeEach for simple setup specific to one test file. Fixtures for setup reused across multiple files (like a logged-in page or pre-seeded test data).
→ See also: Page Object Model in Playwright: From Messy to Maintainable | Playwright Fixtures Explained: From Built-in to Custom | Debugging Flaky Tests: A Practical Guide | Test Isolation: Why Each Playwright Test Should Be Stateless