After 100 days of building, debugging and refining test automation with Playwright, the patterns that emerge aren't always obvious. We've learned that the most reliable and maintainable test suites share a handful of core principles—and ignoring them costs time, not saves it. Here's what matters most after 100 days of Playwright and browser automation: the recap.
Locate like a user — Roles, labels and text over CSS
One of the first temptations when writing Playwright tests is to lean on CSS selectors. They're fast to write, and they work. Until the designer changes a class name, or the layout shifts, and suddenly half your tests are broken. The real breakthrough comes when you stop thinking like a developer and start thinking like a user.
Playwright's accessibility-first locators—role, label and text—are not just best practice. They're the difference between tests that break on every design iteration and tests that survive years of refactoring. When you locate a button by its role and text, you're testing what matters: does the user see it, can they interact with it, does it do what they expect?
Consider a form with a submit button. The brittle approach:
await page.click('button.btn-primary.submit-action');The user-centric approach:
await page.getByRole('button', { name: /submit/i }).click();The difference compounds. In the first case, any CSS refactor breaks the test. In the second, the test only fails if the button disappears or changes its accessible name—both genuine product changes the test should catch. We've seen teams cut selector maintenance by 60% within a month of switching to role-based locators.
For input fields, use labels:
await page.getByLabel('Email address').fill('test@example.com');For text content, use exact or partial matches:
await page.getByText('Welcome back').isVisible();This approach also catches accessibility bugs. If your button doesn't have a proper role, your test won't find it—which means your users won't be able to find it either.
Never sleep — Web-first assertions and network waits
The second-most common anti-pattern we see is await page.waitForTimeout(2000). It feels safe. It feels like it accounts for everything. In reality, it's a time bomb.
Sleep statements are almost always a symptom of a test that doesn't understand what it's waiting for. Is the page waiting for a network response? For a DOM element to appear? For an animation to finish? Each requires a different solution, and waitForTimeout solves none of them.
Playwright's web-first assertions wait intelligently. They retry until the condition is met or a timeout occurs (default 30 seconds). This means your tests run as fast as your application allows, no faster, no slower.
Instead of:
await page.fill('#email', 'test@example.com');
await page.click('button');
await page.waitForTimeout(2000);
expect(await page.locator('.success-message').textContent()).toBe('Sign up complete');Write:
await page.fill('#email', 'test@example.com');
await page.click('button');
await expect(page.locator('.success-message')).toContainText('Sign up complete');If the success message takes 100ms, the test completes in 100ms. If it takes 5 seconds due to a slow API, the test waits. If it never appears, the test fails with a clear error after the timeout. No guesswork.
For network-dependent tests, use waitForResponse or waitForURL:
const responsePromise = page.waitForResponse(response =>
response.url().includes('/api/users') && response.status() === 200
);
await page.click('button');
const response = await responsePromise;
const data = await response.json();This decouples your test from timing. The test waits for the specific event it cares about, not an arbitrary duration.
Isolate everything — Fresh contexts and data per test
Test isolation is the backbone of reliable automation. When tests share state—cookies, localStorage, database records, feature flags—failures cascade and reproduction becomes nightmare-inducing.
The most robust pattern is a fresh browser context for each test. Playwright makes this trivial with fixtures:
test('user can update profile', async ({ page, context }) => {
// This context is fresh, with no cookies or storage from other tests
await page.goto('https://app.example.com/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: /sign in/i }).click();
await page.goto('https://app.example.com/profile');
await page.getByLabel('Name').fill('Updated Name');
await page.getByRole('button', { name: /save/i }).click();
await expect(page.getByText('Updated Name')).toBeVisible();
});The fixture runner automatically creates a fresh context before each test and closes it after. No leaked state.
At the data layer, use database transactions or test data fixtures that roll back after each test:
test.beforeEach(async () => {
// Create test user in database
await db.users.create({ email: 'test@example.com', role: 'admin' });
});
test.afterEach(async () => {
// Clean up
await db.users.delete({ email: 'test@example.com' });
});Or, if your backend supports it, use test data APIs that return isolated environments:
const testEnv = await fetch('/api/test/setup', {
method: 'POST',
body: JSON.stringify({ scenario: 'full-user' })
}).then(r => r.json());
test('admin flow', async ({ page }) => {
await page.goto(`https://app.example.com?env=${testEnv.id}`);
// ... test runs in isolated environment
});Isolation means your tests can run in any order, in parallel, without interference. It also means when a test fails, you know exactly what conditions triggered it.
Make CI fast and debuggable — Parallel runs, shards and traces
A test suite that runs serially for 45 minutes loses value by the time results arrive. Developers context-switch. The CI slot fills with jobs. Feedback loops stretch to hours.
Playwright's sharding distributes tests across machines. With three workers, a 45-minute suite becomes 15 minutes. With ten workers, 4.5 minutes. The CI configuration is straightforward:
// playwright.config.ts
export default defineConfig({
workers: process.env.CI ? 1 : undefined, // Serial on local dev, parallel in CI
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
use: {
trace: 'on-first-retry', // Capture trace only on retry to save storage
},
});And in your CI pipeline (GitHub Actions example):
- name: Run tests
run: npx playwright test --shard=${{ matrix.shard }}/${{ strategy.job-total }}With a matrix of 4 jobs, Playwright divides tests evenly and runs them in parallel.
Traces are the game-changer for debugging failures. When a test fails in CI, you get a timeline of every action, every network request, every DOM state change. Traces can be viewed in Playwright Inspector:
npx playwright show-trace path/to/trace.zipConfigure traces to capture only on retry to balance debugging value against storage cost:
export default defineConfig({
use: {
trace: 'on-first-retry',
},
});This captures a trace the first time a test retries, giving you full debugging data without bloating your artifact storage. For critical paths, capture full traces for every run.
Common mistakes
- Mixing sync and async patterns. Every interaction in Playwright is async. Forgetting
awaitcauses tests to proceed before actions complete. Use a linter rule to catch missing awaits. - Hardcoding waits between assertions. Instead of
await page.waitForTimeout(500); expect(...), let the assertion retry. It's faster and more robust. - Sharing login context across tests. This couples test failures. Each test should be independent. Set up fresh auth data per test, even if it slows things by seconds.
- Relying on flaky selectors in helper functions. Centralizing selectors is good practice, but only if they're stable. A brittle selector in a shared helper breaks multiple tests simultaneously.
- Ignoring test timing in CI. Tests that pass locally on a beefy machine fail sporadically in CI. Use generous timeouts, not tight ones. 30 seconds is fine for a slow API.
Key takeaways
- Locate elements by role, label and text. CSS selectors are for styling, not testing.
- Use web-first assertions and network waits instead of arbitrary timeouts. Let Playwright wait intelligently.
- Isolate tests completely: fresh contexts, fresh data, no shared state between runs.
- Parallelize tests and capture traces on failure. Fast feedback loops compound over months.
- Treat your test suite like production code. Refactor, extract helpers, delete duplication.
These four principles won't cover every edge case, but they solve the vast majority of problems we see across teams. Apply them consistently, and your Playwright suite becomes a reliable partner instead of a source of frustration.






Comments
Loading comments…