FlawPilot
From the blog

Playwright Automation Testing: How to Build More Reliable Browser Tests

A test fails in CI. Someone reruns the pipeline. It passes. Nobody investigates, because everyone already knows what this is: a flaky test. Over a few months, the team stops trusting red builds,…

The FlawPilot TeamSecurity research3 Sept 202611 min read

A test fails in CI. Someone reruns the pipeline. It passes. Nobody investigates, because everyone already knows what this is: a flaky test. Over a few months, the team stops trusting red builds, starts rerunning failures out of habit, and the test suite that was supposed to catch regressions quietly turns into background noise everyone's learned to ignore.

Most of that flakiness isn't random. It's a race condition between the test and the page: the script clicks a button before the page finished rendering it, or checks for text that hasn't loaded yet. Selenium provides explicit waiting mechanisms, but teams often need to configure and maintain those waits themselves. Poorly designed fixed waits or synchronization logic can contribute to flaky tests, and that maintenance burden is a big part of what Playwright was built to remove.

Playwright is an open-source browser automation framework, originally built by Microsoft, that drives Chromium, Firefox, and WebKit through a single API. The core idea that sets it apart is auto-waiting: before performing an action, Playwright performs the actionability checks required for that action, such as whether the element is visible, stable, enabled, and able to receive the interaction, rather than assuming the page is ready and hoping for the best. This approach can reduce a common source of timing-related flakiness by synchronizing interactions with the actual state of the page.

What Makes Playwright Different

Playwright applies the actionability checks required for each action. Depending on the action, those checks can include whether the element is attached, visible, stable, enabled, and able to receive the interaction. If the required conditions aren't met yet, Playwright waits up to the configured timeout instead of failing immediately.

This behavior can significantly reduce the need for manual sleep() calls and custom wait-helper code that often appears in browser test suites. Because Playwright reacts to the current state of the page instead of relying on hardcoded delays, tests are less dependent on small changes in application loading timing.

Beyond auto-waiting, Playwright ships with a few things that used to require separate tools: parallel test execution out of the box, a trace viewer for debugging failures after the fact, built-in API testing, and native support for testing across all three major browser engines from one codebase.

Getting Set Up

Playwright installs through its own scaffolding command, which sets up the config, an example test, and browser binaries in one step.

npm init playwright@latest

That walks through a few prompts (TypeScript or JavaScript, test folder name, whether to add a GitHub Actions workflow) and installs the Playwright browser binaries locally so tests can run against Chromium, Firefox, and WebKit.

The result is a playwright.config.ts file and a tests/ folder with a starter test. The config is where test-wide behavior lives: which browsers to run against, the base URL, retry counts, and reporter settings.

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  use: {
    baseURL: 'https://example.com',
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
});

Writing a First Test

A Playwright test is a function that receives a page object and describes a user journey against it. Locators find elements, actions interact with them, and assertions check the result.

import { test, expect } from '@playwright/test';

test('user can log in with valid credentials', async ({ page }) => {
  await page.goto('/login');

  await page.getByLabel('Email').fill('[email protected]');
  await page.getByLabel('Password').fill('correct-password');
  await page.getByRole('button', { name: 'Log in' }).click();

  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});

Notice there's no wait, no sleep, no polling loop anywhere in that test. fill, click, and expect(...).toBeVisible() all wait on their own until the target element is actually ready, and expect specifically retries the assertion until it either passes or times out, which is what makes it safe to check for content that hasn't rendered yet.

Locators: Finding Elements the Way Users Do

How a test finds an element matters more than it seems. A locator built on a CSS class that changes with every redesign breaks constantly; a locator built on what a user actually sees or a screen reader announces tends to survive redesigns.

Playwright's built-in locators lean toward the second approach:

page.getByRole('button', { name: 'Submit' });
page.getByLabel('Email address');
page.getByPlaceholder('Search products');
page.getByText('Order confirmed');
page.getByTestId('checkout-total');

getByRole is often a strong choice because it uses an element's accessible role and name, encouraging locators that reflect how users and assistive technologies perceive the interface, which also nudges the underlying app toward better accessibility as a side effect. getByTestId is the fallback for anything that has no meaningful role or text, a data-testid attribute added specifically for testing.

Locators in Playwright are lazy: creating one doesn't search the page yet, only calling an action or assertion on it does. That's what allows the auto-retry behavior to work, the locator can be resolved fresh at the moment it's actually needed rather than once at creation time.

Running and Debugging Tests

Once tests exist, the CLI handles running them, in a few different modes depending on what's needed.

npx playwright test                  # run everything, headless
npx playwright test --headed         # watch the browser while it runs
npx playwright test --debug          # step through with the inspector
npx playwright test login.spec.ts    # run one file
npx playwright test --ui             # open UI mode

UI mode is usually the fastest way to actually understand a failure: it shows every test in a sidebar, lets you time-travel through each action, and renders a DOM snapshot at each step so you can see exactly what the page looked like when something failed.

For failures that already happened, most commonly in CI, the trace viewer reconstructs the entire test run after the fact:

npx playwright show-trace trace.zip

A trace includes DOM snapshots, network requests, console logs, and a screenshot at every step, which is usually enough to diagnose a CI-only failure without needing to reproduce it locally.

Structuring Tests With the Page Object Model

As a suite grows past a handful of tests, repeating the same locators across every file becomes a maintenance problem: change one selector in the UI, and now ten test files need the same fix. The Page Object Model solves that by wrapping each page's locators and actions inside a class.

// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.getByLabel('Email');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Log in' });
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.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 page.goto('/login');
  await loginPage.login('[email protected]', 'correct-password');

  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});

Now a UI change only means updating one class, not every test file that touches the login form.

Beyond the Browser: API and Visual Testing

Playwright isn't limited to clicking through a UI. Its request context sends real HTTP requests directly, which is useful for setting up test data quickly or checking an API independently of the frontend.

test('API returns the created order', async ({ request }) => {
  const response = await request.post('/api/orders', {
    data: { productId: 42, quantity: 1 },
  });

  expect(response.ok()).toBeTruthy();
  const order = await response.json();
  expect(order.status).toBe('pending');
});

It also supports visual regression testing, comparing a screenshot against a stored baseline and failing if the rendered page has changed unexpectedly:

await expect(page).toHaveScreenshot('checkout-page.png');

The first run saves the baseline image; every run after that compares against it, which catches unintended layout shifts that functional assertions alone would miss entirely.

Wiring Playwright Into CI

Tests that only run on a developer's laptop protect nothing once code merges. A basic GitHub Actions workflow runs the suite on every push and pull request:

# .github/workflows/playwright.yml
name: Playwright Tests
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/

The if: always() on the upload step matters: it makes sure the HTML report and traces get saved even when the tests fail, which is exactly when they're needed most.

Playwright vs. Selenium vs. Cypress

None of these tools are strictly better in every situation, but they optimize for different things.

PlaywrightSeleniumCypress
Auto-waitingBuilt inExplicit/implicit waits availableBuilt in
Browser supportChromium, Firefox, WebKitNearly everything, including legacy browsersChrome-family browsers, Firefox, and experimental WebKit
Multiple tabs/originsNative supportSupported, more manualMulti-origin support available through cy.origin(), native multi-tab support remains limited
API testingBuilt inRequires separate toolingBuilt in
DebuggingTrace viewer, UI modeVaries by driverTime-travel debugger
Language supportJS/TS, Python, Java, .NETNearly every languageJavaScript/TypeScript only

Selenium still matters when a project needs to test on a browser or OS combination outside Playwright's supported set, or when an existing Selenium Grid infrastructure isn't going anywhere soon. Cypress and Playwright solve a lot of the same flakiness problem in similar ways. The practical difference usually comes down to multi-tab and multi-origin testing, where Playwright's architecture supports it natively, while Cypress handles multi-origin scenarios through cy.origin() and still has more limited native support for multiple tabs.

Where Teams Get Playwright Wrong

Fighting the auto-wait instead of using it. Adding manual page.waitForTimeout() calls on top of Playwright's built-in waiting defeats the entire point and just makes the suite slower for no reliability gain.

Chasing brittle CSS selectors when a role-based locator would do. A locator tied to a specific class name breaks the moment a designer touches the CSS. getByRole and getByLabel survive far more UI churn.

Skipping the trace viewer and guessing at CI failures instead. Reproducing a CI-only failure locally can burn hours; the trace usually shows exactly what happened in under a minute.

Writing UI tests for things an API test would cover faster. Clicking through five screens to verify a backend calculation is slow and fragile. If the assertion doesn't actually depend on the UI, test it through request instead.

Letting the Page Object Model sprawl into a second application. Page objects should wrap locators and actions, not business logic. Once they start containing conditionals and complex state, they've become something else to maintain.

Building a Real Testing Strategy Around It

Start with the user journeys that would actually hurt the business if they broke, typically login, checkout, and whatever the product's core action is. Cover those end to end with UI tests, since that's where auto-waiting and real browser rendering matter most. Push everything else, data setup, backend validation, edge-case permutations, down into API tests, which run faster and break less often. Run the full suite in CI on every pull request, keep traces on for retries so failures are diagnosable without a repro step, and treat a flaky test as a bug in the test, not something to quietly rerun and forget.

Frequently asked questions

Yes. Playwright is fully open source under the Apache 2.0 license, with no paid tier or usage limits on the framework itself.

Final Thoughts

The flaky test problem was never really about test writers being careless. It was about tools that couldn't tell the difference between a page that looked ready and a page that actually was.

Playwright's auto-waiting closes that gap directly, and the trace viewer closes the other half of the problem: when something does fail, there's finally a way to see exactly what happened instead of guessing. None of this replaces good test design. It just removes the excuse for a red build nobody trusts.

How FlawPilot helps

FlawPilot is useful because it connects detection to remediation. A scan can tell you a Row-Level Security policy is missing. The next step, actually closing it, is what determines whether the risk goes away.

Every finding lands in a ranked “What to do next” list, written in plain English instead of a severity label. The fix for the top issue in every pillar, security, performance, infrastructure, SEO, is included in the free report, spelled out clearly enough to act on without a security background. For a full crawl of the site, and for findings that go deeper than a config change, Logicwind's engineering team builds a prioritized remediation roadmap and puts people on it directly: RLS policies, header configuration, DNS records, all of it.

The boundaries matter as much as the capability. FlawPilot only checks publicly accessible signals to run the scan, it never touches your server, your codebase, or your credentials, and it doesn't auto-apply any fix without a human in the loop. Finding the gap and fixing the gap happen through the same team, but that means engineers doing the work, not a bot merging code on your behalf.

Playwright TestingBrowser AutomationEnd To End TestingQA TestingTest AutomationSoftware Testing

Verify your AI-generated app is production-ready.

80+ security checks in 60 seconds - free, no account needed.

No account needed · Public signals only · Results in minutes