Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 100 / 100

100 days of Playwright and browser automation: the recap

The lessons that matter most.

Expona Admin7 min read

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:

typescript
await page.click('button.btn-primary.submit-action');

The user-centric approach:

typescript
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:

typescript
await page.getByLabel('Email address').fill('test@example.com');

For text content, use exact or partial matches:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
// 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):

typescript
- 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:

typescript
npx playwright show-trace path/to/trace.zip

Configure traces to capture only on retry to balance debugging value against storage cost:

typescript
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 await causes 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.

ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles