Analytics Testing
Testing GA4 in 20 Minutes: A Working Playbook
Five copy-paste steps that take a GA4 setup from "I think it works" to "the CI job fails the moment an event drops." Written for teams running gtag.js or GTM on a real product, with Playwright as the only test dependency.
Before you start
The tests below assume a standard GA4 web install. If one of the items on this checklist is missing, fix that first; otherwise the Playwright assertions will pass for the wrong reason. A test that asserts on zero events and gets zero events is not a passing test, it is a broken tag.
Prerequisites
- GA4 property created with a Web data stream and a G-XXXXXXXXXX Measurement ID
- gtag.js snippet or a GTM container installed in <head>, firing on every page
- Playwright installed in the repo: npm i -D @playwright/test && npx playwright install chromium
- A staging or local URL you can hit that renders the production tag
- Optional: a server-side GTM container for Measurement Protocol v2 coverage
- Optional: GA4 linked to BigQuery for the Step 5 integration checks
Step 1: Unit test the wrapper that pushes to dataLayer
Most teams wrap gtag(...) or dataLayer.push(...) in a small helper like track(). The fastest signal of a regression is a unit test on that helper, running in Vitest or Jest, with zero browser required. Mock window.dataLayer, call the wrapper, and assert on the exact object shape that will show up in production.
The second test matters more than it looks. GA4 silently accepts any event name. If your wrapper lets signUp through once, it becomes a parallel event in reports that nobody notices for weeks. Lock the naming rules in the unit test so the browser tests can assume the wrapper is correct.
A few more assertions to include in this file: numeric params stay numeric (GA4 will coerce a string '29' to a dimension instead of a metric, and your reports will look empty), parameter names stay under 40 characters, and the total param count per event stays under 25. These limits are enforced by Google silently; nothing in the browser will complain, which is exactly why the unit test is the right place to catch them.
Step 2: Intercept /g/collect with Playwright
GA4 events leave the browser as requests to https://www.google-analytics.com/g/collect. The event name lives in the en query parameter, string params use the ep. prefix, and numeric params use epn.. Use page.route to capture the requests, then parse the URL and assert on the fields you actually care about.
Two things to notice. First, page.waitForRequest is the deterministic wait; waitForTimeout will give you flakes. Second, the helper lets the request through with route.continue() so DebugView stays useful during local runs. Flip it to route.abort() in CI if you do not want test traffic reaching GA4 at all.
If the tag is deployed through GTM rather than gtag.js directly, you will also see requests to www.googletagmanager.com/gtag/js and to your server-side GTM container, usually ss.yourdomain.com/g/collect. Add a second route pattern for the server-side hostname if that is where events are actually leaving the client; otherwise your assertions will look empty because traffic is being proxied. A five-second scan of page.on('request', ...) during an exploratory run is the fastest way to confirm the real endpoint.
Step 3: Prove consent mode v2 actually blocks
Consent mode v2 is the area where silent bugs cost the most. If gtag('consent', 'default', {...}) fires after the first event, or if the banner forgets to call gtag('consent', 'update', {...}) when the user clicks accept, you will lose data forever and only notice when a monthly report flatlines. The test is simple: count /g/collect requests in three phases.
The gcs parameter encodes the current consent state. Values starting with G1 mean analytics storage is granted; G100 means denied. Assert on the exact prefix your product expects. If your CMP sets 'default' after the first gtag call, events queue forever and you will see zero hits in both phases; that is the failure mode this test catches.
Add a symmetric test for denied consent to round out coverage: mock the user clicking "Reject all", then assert that gcs starts with G10 and that no _ga or _ga_* cookies are set. With consent mode v2 in denied state, GA4 still pings /g/collect but with cookieless signals and no client id, which is the correct behavior; a missing ping would mean your tag is skipping the redacted call entirely and Google cannot model conversions.
Step 4: Verify enhanced measurement still works
GA4 enhanced measurement fires scroll at 90% depth, click on outbound links, and file_download on known extensions. These break when a developer replaces an anchor tag with a button + router.push (outbound detection stops working) or when a layout change makes pages too short to hit 90%. Drive the events with real Playwright actions and assert on the network layer, not on the DOM.
Enhanced measurement has quiet dependencies. Scroll events only fire if the document body is tall enough that 90% lives below the fold, so a homepage that fits above the fold will never produce a scroll event no matter how far you scroll. File downloads key off known extensions (pdf, doc, xls, zip, and friends); a download link to /report?id=42 that streams a PDF will not trigger the event because the URL does not end in an extension. If your product has routes like that, either append the extension or fire the event manually with gtag('event', 'file_download', {...}).
Step 5: Server-side sanity check via BigQuery
Network interception proves the browser sent the event. BigQuery proves GA4 accepted, parsed, and stored it the way you expected. Link your GA4 property to BigQuery (Admin > BigQuery links), pick streaming export, and you will see raw events in analytics_PROPERTYID.events_intraday_* within a few minutes. The integration test runs a user flow, tags the event with a unique id, then polls BigQuery for that id.
Why bother, when Step 2 already proved the request fired? Because the thing you care about is not "a request left the browser", it is "GA4 stored the event with the fields your analyst queries for." Between the browser and BigQuery, Google can drop the event for quota reasons, strip a parameter for exceeding the 25-param limit, bucket a value because the dimension is registered as a metric, or route the event to a different property if your tid is wrong. Only the BigQuery row proves the pipeline is healthy end to end.
For server-to-server events (Stripe webhooks, shipping confirmations, anything that fires outside the browser), swap the Playwright flow for a direct call to https://www.google-analytics.com/mp/collect using Measurement Protocol v2 with your API secret. The BigQuery assertion stays the same.
Gotchas worth pinning to the team wiki
Every team hits these at least once. Pinning them now saves a week of "why is GA4 broken" Slack threads later.
- GA4 batches events in transport mode
beacon. Events triggered just before navigation can defer topagehidevianavigator.sendBeacon. Always assert withwaitForRequeston a real action, not with fixed timeouts. - Chrome DevTools redacts long query strings in the Network panel summary view, but the params are still in the request. Use the Playwright interceptor or click into the full request URL.
- DebugView has a 1 to 2 minute delay and only shows events tagged with
debug_mode: trueor originating from a session with the GA Debugger extension installed. Do not treat an empty DebugView as "events are not firing"; check the network layer first. gtag('consent', 'default', {...})must fire before any other gtag call. If the CMP loader loses that race, events buffer forever and never send, even after consent is granted. Step 3's test catches this.- iOS Safari ITP caps first-party cookie lifetime at 7 days and drops cross-site state entirely. Session continuity based on
_gaalone will break on iOS; enable Google Signals or use server-side tagging for stable session stitching. - The Measurement Protocol v2 debug endpoint (
/debug/mp/collect) returns validation errors for server-sent events. Run it in CI before pushing real events to production; it is the only place GA4 will tell you the payload is wrong.
Run these five steps on every pull request and the next time a developer ships dataLayer.push({ event: 'purchase' }) without a currency, CI will stop the merge before the monthly report goes sideways. If writing the Playwright harness sounds like more work than it is worth, Assrt takes a plain-English scenario and generates the interception logic for you.