|

Handling Cookie Banners with Playwright addLocatorHandler

You write a clean, fast Playwright test, it passes ten times in a row, and then on the eleventh run a GDPR cookie banner slides in over your “Add to cart” button and the click fails with a timeout. Intermittent overlays like consent popups, newsletter modals, and “accept cookies” bars are one of the most common causes of flaky end-to-end tests. In this guide you will learn how the Playwright addLocatorHandler cookie banner pattern lets you register a one-time handler that auto-dismisses these overlays the moment they appear, anywhere in your suite, without scattering defensive clicks across every single test.

🎭 Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.

Contents

Why cookie banners make tests flaky

Cookie and consent banners are deliberately disruptive. They are often injected asynchronously after page load, sometimes seconds later, by a third-party consent management platform (CMP) such as OneTrust, Cookiebot, or Quantcast. Because the timing is non-deterministic, a banner might cover an element on one run and not the next. When Playwright tries to click a button that is now visually obscured by an overlay, the actionability checks fail and you get a confusing “element intercepts pointer events” or timeout error.

The naive fix is to add an “accept cookies” click at the start of every test. That works until the banner appears mid-test, or only on certain pages, or A/B-tests its way into a different DOM structure. You end up with brittle try/catch blocks copy-pasted everywhere. Playwright’s page.addLocatorHandler() solves this elegantly by letting you say: “whenever this element shows up and blocks an action, run this code to get rid of it, then continue.”

What is page.addLocatorHandler?

page.addLocatorHandler(locator, handler) registers a callback that Playwright runs automatically when a given locator becomes visible while it is performing an action (a click, fill, hover, or an auto-waiting assertion). It is specifically designed for overlays that appear at unpredictable times. The key insight is that the handler does not poll on a timer; instead Playwright checks for the locator right before and during actionability checks, so the handler only fires when the overlay would actually interfere.

The handler receives the matched locator as an argument, so you can interact with the specific overlay instance. After your handler runs, Playwright verifies the locator is no longer visible and then retries the original action. This makes it ideal for the Playwright addLocatorHandler cookie banner use case.

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

test('dismiss cookie banner automatically', async ({ page }) => {
  // Register the handler ONCE, before navigation.
  // Whenever the consent dialog becomes visible and blocks
  // an action, Playwright runs this callback to clear it.
  await page.addLocatorHandler(
    page.getByRole('dialog', { name: /cookie|consent/i }),
    async (dialog) => {
      await dialog.getByRole('button', { name: /accept all/i }).click();
    }
  );

  await page.goto('https://example.com');

  // This click "just works" even if the banner pops up first.
  await page.getByRole('link', { name: 'Get started' }).click();
});

Notice there is no try/catch, no explicit wait for the banner, and no defensive check. You register the handler once and forget about it. Playwright takes care of the rest during its normal auto-waiting.

A complete, runnable example

Real consent banners rarely expose a clean ARIA dialog role, so in practice you target the container by a stable attribute or test id. Below is a fuller example that handles a banner identified by a data-testid, clicks the reject button (a privacy-friendly default), and then proceeds with the actual test flow. The handler closure can run any async logic you need.

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

test('shopping flow with intermittent consent banner', async ({ page }) => {
  const banner = page.locator('[data-testid="cookie-consent"]');

  await page.addLocatorHandler(banner, async (overlay) => {
    // Prefer an explicit, scoped click on the overlay instance.
    await overlay.getByRole('button', { name: 'Reject all' }).click();
    // Optional: assert it is gone before Playwright retries the action.
    await expect(overlay).toBeHidden();
  });

  await page.goto('https://shop.example.com');

  // The banner may appear here, during search, or at checkout.
  await page.getByPlaceholder('Search products').fill('keyboard');
  await page.getByRole('button', { name: 'Search' }).click();
  await page.getByRole('link', { name: 'Mechanical Keyboard' }).click();
  await page.getByRole('button', { name: 'Add to cart' }).click();

  await expect(page.getByText('Added to cart')).toBeVisible();
});

Because the handler is attached to the page object, it stays active for the entire lifetime of that page. If you want it active across every test in a file or project, register it inside a fixture or a beforeEach hook, which we cover next.

Making the handler global with a fixture

Copy-pasting the handler into every test defeats the purpose. The clean approach is to extend Playwright’s base test with a custom fixture that wraps the default page and registers the cookie handler before the test body runs. Every test that imports your custom test automatically gets banner suppression for free.

// fixtures.ts
import { test as base, expect } from '@playwright/test';

export const test = base.extend({
  page: async ({ page }, use) => {
    // Register before the test runs so it covers the whole flow.
    await page.addLocatorHandler(
      page.locator('[data-testid="cookie-consent"]'),
      async (overlay) => {
        await overlay.getByRole('button', { name: /accept|reject/i }).first().click();
      },
      // noWaitAfter lets Playwright skip waiting for navigation
      // after the handler runs - useful for banners that reload.
      { noWaitAfter: true }
    );
    await use(page);
  },
});

export { expect };
// cart.spec.ts
import { test, expect } from './fixtures';

test('checkout never breaks on the consent banner', async ({ page }) => {
  await page.goto('https://shop.example.com');
  await page.getByRole('button', { name: 'Add to cart' }).click();
  await page.getByRole('link', { name: 'Checkout' }).click();
  await expect(page).toHaveURL(/checkout/);
});

This is the recommended production pattern. The fixture centralizes the logic in one place, so when the CMP changes its markup you update a single file instead of dozens of tests.

Understanding the options: times and noWaitAfter

The third argument to addLocatorHandler accepts an options object that controls how the handler behaves. Two options matter most for cookie banners.

  • times — limits how many times the handler runs. Most cookie banners only appear once per session, so { times: 1 } is a sensible guard that prevents an infinite or repeated handler if the overlay reappears unexpectedly.
  • noWaitAfter — by default, after the handler runs Playwright waits for the locator to be hidden before continuing. If your overlay triggers a reload or the element lingers in the DOM but becomes non-blocking, set noWaitAfter: true so Playwright does not stall waiting for it to disappear.
import { test } from '@playwright/test';

test('handler runs at most once', async ({ page }) => {
  await page.addLocatorHandler(
    page.getByText('We value your privacy'),
    async () => {
      await page.getByRole('button', { name: 'Accept' }).click();
    },
    { times: 1, noWaitAfter: false }
  );

  await page.goto('https://news.example.com');
  await page.getByRole('link', { name: 'Sports' }).click();
});

One important caveat: the handler only runs when Playwright is performing an action or an auto-waiting assertion. If your test simply navigates and never interacts with the page, the handler will not fire, because there is nothing for the banner to block. That is by design and is exactly why this API avoids the overhead of constant polling.

addLocatorHandler vs other dismissal strategies

There is more than one way to deal with consent overlays in Playwright. Each has trade-offs. The table below compares the main approaches so you can pick the right tool for your situation.

StrategyHow it worksBest forDrawback
addLocatorHandlerAuto-fires when the overlay blocks an actionBanners with unpredictable timingOnly runs during actions/assertions
storageState / cookiesPre-seed a consent cookie so the banner never showsKnown, stable consent cookieYou must reverse-engineer the cookie name/value
page.route blockingAbort the CMP script request so it never loadsThird-party CMPs loaded via a known URLCan break analytics or other page logic
beforeEach clickExplicitly click accept at test startBanner that always appears immediatelyFails if banner appears mid-test or is delayed

For most teams, addLocatorHandler is the most robust default because it does not depend on timing or internal knowledge of the CMP. However, the fastest tests skip the banner entirely. If you can identify the consent cookie, pre-seeding it via browser.newContext({ storageState }) means the banner never renders, saving you the dismissal step on every test.

🚀 Level Up Your Playwright

From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.

Alternative: pre-seed consent with storageState

When you know the exact cookie the CMP sets after consent (inspect it once in DevTools after clicking “accept”), you can inject it directly into the browser context. The banner logic reads the cookie, sees consent already exists, and skips rendering entirely. This is faster than dismissing the banner on each run.

import { test as base } from '@playwright/test';

export const test = base.extend({
  context: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: {
        cookies: [
          {
            name: 'cookie_consent',
            value: 'accepted',
            domain: '.example.com',
            path: '/',
            expires: -1,
            httpOnly: false,
            secure: true,
            sameSite: 'Lax',
          },
        ],
        origins: [],
      },
    });
    await use(context);
    await context.close();
  },
});

A pragmatic hybrid is to pre-seed the consent cookie for speed and also keep an addLocatorHandler registered as a safety net, in case the CMP changes its cookie format or an A/B test serves a different banner. Belt and suspenders keeps the suite green.

Common pitfalls and best practices

  • Register before navigation. Call addLocatorHandler before page.goto() so the handler is armed when the banner first appears.
  • Use a resilient locator. Target the banner by a stable data-testid or ARIA role rather than a brittle CSS class that the CMP may rename.
  • Scope clicks to the overlay. Use the locator passed into your handler (e.g. overlay.getByRole(...)) so you click the correct button inside that specific banner.
  • Avoid heavy assertions inside the handler. Keep it focused on dismissal; the handler runs during another action, so long waits can slow every interaction.
  • Remove the handler when needed. Call page.removeLocatorHandler(locator) if a later part of your test must actually interact with the banner itself.

Conclusion

Intermittent consent overlays are a leading source of flaky end-to-end tests, but they no longer have to be. The Playwright addLocatorHandler cookie banner pattern lets you register a single, declarative handler that auto-dismisses overlays exactly when they would block an action, with no timing guesswork and no defensive code scattered through your suite. Combine it with a custom fixture for project-wide coverage, and optionally pre-seed a consent cookie via storageState for maximum speed. Adopt this pattern and your tests will stay focused on the behavior you actually care about, while annoying cookie banners quietly disappear in the background.

FAQ

Does addLocatorHandler run on a timer or poll the page?

No. Playwright only checks for the registered locator while it is performing an action or an auto-waiting assertion such as a click, fill, hover, or expect(...).toBeVisible(). There is no background polling, so the handler fires only when the overlay would actually interfere with what your test is doing. If a test never interacts with the page, the handler will not run.

Can I limit how many times the cookie handler fires?

Yes. Pass the times option, for example { times: 1 }, so the handler runs at most once. This is a useful guard for banners that should only appear a single time per session and prevents the handler from re-firing if the overlay unexpectedly reappears. You can also call page.removeLocatorHandler(locator) to detach it manually.

Should I use addLocatorHandler or just pre-seed a consent cookie?

If you know the exact consent cookie the CMP sets, pre-seeding it with browser.newContext({ storageState }) is faster because the banner never renders. Use addLocatorHandler when the cookie is unknown, the banner timing is unpredictable, or as a safety net alongside the seeded cookie in case the CMP changes its markup or runs an A/B test.

🎓 Master Playwright End to End

Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.