|

Analytics Event Verification in Playwright

Analytics tracking is the one part of your app that breaks silently. A renamed event, a missing property, or a beacon that never fires costs you data you can never recover, and no user ever files a bug for it. In this guide you will learn how to do Playwright analytics event verification end to end: intercepting outbound tracking requests, asserting on event names and payloads, stubbing third-party SDKs so they never pollute real dashboards, and wiring everything into reusable fixtures.

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

Contents

Why analytics events deserve their own tests

Most teams test the visible UI and forget that a huge slice of product value depends on data that is invisible in the browser. When a developer refactors a button handler and accidentally drops the purchase_completed event, every functional test still passes. The checkout works, the confirmation page renders, the order is created. Only weeks later does someone notice revenue attribution has cratered. Analytics verification closes that gap by treating the network beacon as a first-class assertion target.

The good news is that almost every analytics platform, Google Analytics 4, Segment, Amplitude, Mixpanel, PostHog, or a homegrown collector, ultimately sends an HTTP request. That request is exactly what Playwright is built to observe and control through its network APIs. There are three things you typically want to verify:

  • The event fired at all when the user performed an action.
  • The event carried the correct name and properties (user id, value, currency, page path).
  • The event did not fire when it should not have (no duplicate or phantom events).

Capturing analytics requests with page.on(‘request’)

The simplest starting point is a passive listener. Playwright emits a request event for every outgoing request, so you can collect everything that matches your analytics endpoint and inspect it after an interaction. This approach is non-intrusive: it observes traffic without blocking or modifying it.

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

test('checkout fires a GA4 purchase event', async ({ page }) => {
  const analyticsRequests: Request[] = [];

  // GA4 sends beacons to /g/collect on the region-1 endpoint.
  page.on('request', (request) => {
    if (request.url().includes('google-analytics.com/g/collect')) {
      analyticsRequests.push(request);
    }
  });

  await page.goto('https://shop.example.com/cart');
  await page.getByRole('button', { name: 'Place order' }).click();
  await expect(page.getByText('Order confirmed')).toBeVisible();

  // GA4 encodes the event name in the `en` query parameter.
  const purchase = analyticsRequests.find((req) => {
    const params = new URL(req.url()).searchParams;
    return params.get('en') === 'purchase';
  });

  expect(purchase, 'a GA4 purchase beacon should have been sent').toBeDefined();
  const params = new URL(purchase!.url()).searchParams;
  expect(params.get('cu')).toBe('USD');        // currency
  expect(Number(params.get('epn.value'))).toBeGreaterThan(0); // value
});

GA4 packs the event name into the en parameter and custom numeric properties into epn.* keys, while string properties use ep.*. The pattern is identical for any vendor: collect matching requests, then parse the URL or POST body to assert on the payload. The passive listener is great for read-only checks but it has a subtle race: you must perform the interaction after attaching the handler, which the example above does.

Waiting deterministically with page.waitForRequest

Passive collection works, but it forces you to guess how long to wait before asserting. A cleaner approach for a single expected beacon is page.waitForRequest, which returns a promise that resolves the moment a matching request is sent. Combine it with the action using Promise.all so the listener is armed before the click happens, avoiding any flakiness.

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

test('sign-up button sends a Segment track call', async ({ page }) => {
  await page.goto('https://app.example.com/');

  const [request] = await Promise.all([
    // Segment posts to /v1/t (track) on the CDN/api host.
    page.waitForRequest((req) =>
      req.url().includes('api.segment.io/v1/t') && req.method() === 'POST'
    ),
    page.getByRole('button', { name: 'Create account' }).click(),
  ]);

  const body = request.postDataJSON();
  expect(body.event).toBe('Account Created');
  expect(body.properties.plan).toBe('free');
  expect(body.userId).toMatch(/^usr_/);
});

request.postDataJSON() parses a JSON body for you, which is ideal for Segment, Amplitude, and most modern collectors that POST JSON. For form-encoded or beacon (navigator.sendBeacon) payloads you can fall back to request.postData() and parse manually. Note that waitForRequest has a default timeout of 30 seconds and will throw a clear error if no matching beacon ever fires, which is exactly the failure you want surfaced.

Stubbing analytics so real dashboards stay clean

There is a real danger in running analytics tests against live endpoints: every test run pollutes your production dashboards with fake events and may even consume vendor quota. The professional solution is page.route, which lets you intercept the request, assert on it, and then fulfill it with a fake 200 response so the beacon never leaves the test machine. This is the single most important pattern in Playwright analytics event verification.

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

test('add-to-cart event is captured and the beacon is blocked', async ({ page }) => {
  const captured: Request[] = [];

  // Intercept every analytics call; never let it reach the network.
  await page.route('**/g/collect*', async (route) => {
    captured.push(route.request());
    // Respond with a benign 204 just like the real GA4 endpoint does.
    await route.fulfill({ status: 204, body: '' });
  });

  await page.goto('https://shop.example.com/product/42');
  await page.getByRole('button', { name: 'Add to cart' }).click();

  // Give the SDK a tick to flush, then assert.
  await expect.poll(() => captured.length).toBeGreaterThan(0);

  const addToCart = captured.find(
    (req) => new URL(req.url()).searchParams.get('en') === 'add_to_cart'
  );
  expect(addToCart, 'add_to_cart beacon expected').toBeDefined();
});

Two things make this robust. First, route.fulfill returns a fake success response, so the analytics SDK believes the event was delivered and does not retry or log errors. Second, expect.poll retries the length check until the asynchronous SDK has flushed its queue, which removes the need for arbitrary waitForTimeout sleeps. If you want to block analytics entirely without inspecting it, you can call route.abort() instead of fulfilling.

Verifying the dataLayer for GTM-driven sites

If your stack uses Google Tag Manager, events are often pushed onto window.dataLayer before any network call happens. You can assert against that array directly with page.evaluate, which is faster and less brittle than parsing GA4 query strings, and it verifies the contract between your app and the tag manager.

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

test('newsletter signup pushes a dataLayer event', async ({ page }) => {
  await page.goto('https://www.example.com/');
  await page.getByLabel('Email').fill('reader@example.com');
  await page.getByRole('button', { name: 'Subscribe' }).click();

  // Read the GTM dataLayer straight from the page context.
  const events = await page.evaluate(() => {
    type DataLayerEvent = { event?: string; [key: string]: unknown };
    const dl = (window as unknown as { dataLayer?: DataLayerEvent[] }).dataLayer ?? [];
    return dl.filter((e) => e.event === 'generate_lead');
  });

  expect(events.length).toBe(1);
  expect(events[0].form_name).toBe('newsletter');
});

Choosing the right verification strategy

Each technique trades off isolation, speed, and fidelity. Use this table to pick the approach that matches what you are trying to prove.

TechniqueAPIBlocks beacon?Best for
Passive listenerpage.on(‘request’)NoCollecting many events, debugging
Deterministic waitpage.waitForRequestNoAsserting one expected beacon
Intercept + fulfillpage.route + route.fulfillYesCI safety, payload assertions
dataLayer readpage.evaluateN/AGTM contract, pre-network checks
Negative checkroute + waitForTimeoutYesProving an event did NOT fire

🚀 Level Up Your Playwright

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

Building a reusable analytics fixture

Repeating route setup in every spec is tedious and error-prone. Playwright’s fixtures let you wrap interception in a single helper that collects every analytics event and exposes a clean assertion surface to your tests. Below is a small AnalyticsTracker fixture that intercepts a configurable host, decodes GA4 beacons, and lets each test query captured events by name.

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

class AnalyticsTracker {
  readonly events: { name: string; params: URLSearchParams }[] = [];

  constructor(private page: Page) {}

  async install(): Promise<void> {
    await this.page.route('**/g/collect*', async (route) => {
      const params = new URL(route.request().url()).searchParams;
      this.events.push({ name: params.get('en') ?? '', params });
      await route.fulfill({ status: 204, body: '' });
    });
  }

  byName(name: string) {
    return this.events.filter((e) => e.name === name);
  }
}

export const test = base.extend<{ analytics: AnalyticsTracker }>({
  analytics: async ({ page }, use) => {
    const tracker = new AnalyticsTracker(page);
    await tracker.install();
    await use(tracker);
  },
});

// Usage in a spec:
test('product view fires exactly one view_item event', async ({ page, analytics }) => {
  await page.goto('https://shop.example.com/product/42');
  await expect.poll(() => analytics.byName('view_item').length).toBe(1);
});

export { expect };

Because the fixture installs the route before the test body runs, you never miss a beacon fired during initial page load. The byName helper combined with expect.poll gives you readable, retry-safe assertions. You can extend the same class to decode Segment JSON bodies, Amplitude batches, or your internal collector simply by adding more route patterns.

Asserting that an event did NOT fire

Negative assertions are awkward because you cannot wait for something that never happens. The reliable pattern is to perform the action, give the SDK a bounded window to flush, then assert the count is still zero. Keep the wait short and document why it exists.

import { test, expect } from './analytics-fixture';

test('closing the modal does NOT fire a conversion event', async ({ page, analytics }) => {
  await page.goto('https://app.example.com/pricing');
  await page.getByRole('button', { name: 'Compare plans' }).click();
  await page.getByRole('button', { name: 'Close' }).click();

  // Bounded flush window: SDKs batch on a short timer.
  await page.waitForTimeout(1000);
  expect(analytics.byName('purchase').length).toBe(0);
});

Practical tips for reliable analytics tests

  • Prefer expect.poll over fixed sleeps; analytics SDKs flush asynchronously and batch events on timers.
  • Always route.fulfill or route.abort in CI so test runs never write to real dashboards or burn vendor quota.
  • Match endpoints with glob patterns (**/g/collect*) rather than hard-coded full URLs, since vendors rotate regional hosts.
  • For sendBeacon payloads, read request.postData() as a string; the body is often URL-encoded, not JSON.
  • Use expect.soft when checking many properties on one event so a single mismatch does not hide the others.
  • Decode GA4 batch requests carefully: multiple events can ride in one POST body separated by newlines.

With these patterns, Playwright analytics event verification becomes a routine part of your suite rather than an afterthought. You catch broken tracking the moment a regression lands, keep production dashboards pristine by stubbing every beacon, and give your data and growth teams confidence that the numbers they report are real. Start with a single high-value funnel, such as signup or checkout, wrap it in the fixture above, and expand coverage from there.

FAQ

How do I verify a GA4 event in Playwright?

Intercept requests to the GA4 collect endpoint with page.route('**/g/collect*', ...), then parse the URL query string. GA4 stores the event name in the en parameter, string properties in ep.*, and numeric properties in epn.*. Fulfill the route with a 204 so the beacon never reaches Google, then assert on the parsed parameters.

Should I block analytics requests during tests?

Yes, in CI you should always intercept and either route.fulfill with a fake success or route.abort the request. This keeps fake test events out of your production analytics dashboards, avoids consuming vendor quota, and makes tests faster and deterministic since they no longer depend on a live third-party endpoint.

How do I assert that a tracking event did not fire?

Install a route that captures the event into an array, perform the action, then wait a bounded window with page.waitForTimeout to let the SDK flush its queue, and finally assert the captured count is zero. Negative assertions need a short, documented wait because you cannot await an event that is supposed to never happen.

🎓 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.