Root cause #
A third-party script is code you did not write, delivered by infrastructure you do not control, executing in the same document as the feature under test. Its latency is the vendor’s; its availability is the vendor’s; its behaviour can change on any given day when the vendor deploys. That alone makes it a non-deterministic input into every test that loads it.
The DOM effects are what turn that into concrete failures. Consent management platforms render a full-viewport overlay that intercepts clicks — a test that clicks a button “sometimes” fails because the banner appeared a few hundred milliseconds later than usual. Session recorders wrap event handlers and can swallow or delay them. Chat widgets inject a floating element that covers the corner of the page where a submit button happens to sit. Each is the moving-target problem from a source the application code does not even reference.
There is a second-order cost that shows up in reporting rather than in failures. Third-party scripts produce console errors and failed requests of their own, so any check that fails a test on an uncaught error or a broken asset — a useful check — becomes noisy and gets disabled. Blocking the tags restores the value of that check, because everything it then reports belongs to your application.
Step-by-step fix #
1. Block the tags at the network layer #
cy.intercept can force a request to fail or return an empty body before the script ever executes. A short allow-nothing list applied globally is usually the single biggest stability win available to a Cypress suite.
// cypress/support/e2e.js
// Trade-off: blocking by pattern is coarse and needs updating as vendors are
// added; the alternative is every spec inheriting four sources of flakiness.
const THIRD_PARTY = [
'**/*.googletagmanager.com/**',
'**/*.google-analytics.com/**',
'**/*.hotjar.com/**',
'**/*.intercom.io/**',
'**/consent.cookiebot.com/**',
];
beforeEach(() => {
for (const pattern of THIRD_PARTY) {
cy.intercept(pattern, { statusCode: 204, body: '' });
}
});
Returning an empty 204 is usually gentler than aborting: some loaders retry on a network error and log noisily, whereas an empty successful response simply produces a script that does nothing.
2. Stub the global the tag would have created #
Blocking the script means the global it defines never exists, and application code that calls it will throw. Provide a no-op stand-in before the app boots, and you get stability without changing production code.
// Trade-off: a stub keeps the app's call sites working and means the real
// vendor integration is never exercised — cover that separately, if at all.
cy.visit('/checkout', {
onBeforeLoad(win) {
win.dataLayer = []; // tag manager queue
win.gtag = cy.stub().as('gtag'); // assertable analytics calls
win.analytics = { track: cy.stub().as('track'), page: cy.stub() };
},
});
Stubbing rather than merely blocking has a bonus: the analytics contract becomes testable. Asserting that checkout fires a purchase event with the right value is a real requirement in most products, and it is far easier against a stub than against a vendor’s network endpoint.
3. Pre-set consent so the banner never renders #
A consent overlay is the single most disruptive third-party element because it deliberately blocks interaction. Seed the consent state before the app boots rather than clicking through the banner in every test.
// Trade-off: seeding consent skips the banner entirely, so keep one spec that
// exercises the real consent flow — it is a legal requirement, not decoration.
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('cookie-consent', JSON.stringify({
analytics: true, marketing: false, version: 2,
}));
},
});
The timing rule is the same one that governs all pre-boot state: the value must be written before the application’s scripts read it, which is what onBeforeLoad guarantees and a post-visit write does not.
4. Assert the analytics contract deliberately #
Once the vendor globals are stubs, the events themselves are ordinary assertions — and they catch a category of regression that otherwise reaches production silently, because nobody notices a missing event until a report is empty at the end of the month.
// Trade-off: asserting event payloads couples tests to the tracking plan,
// which is appropriate — the tracking plan is a product requirement.
cy.findByRole('button', { name: 'Complete purchase' }).click();
cy.get('@gtag').should('have.been.calledWithMatch', 'event', 'purchase', {
value: 42.5,
currency: 'EUR',
});
5. Decide what happens to unknown third-party hosts #
A hard-coded blocklist ages: a new vendor is added and nobody updates the suite. Inverting it — allow your own origins, block everything else — keeps coverage of the policy itself, at the cost of occasional deliberate exceptions.
// Trade-off: an allowlist is stricter and will block a legitimate new
// integration until someone adds it, which is the feedback you want.
const OWN = /(^\/|localhost|\.example\.com)/;
beforeEach(() => {
cy.intercept({ url: /^https?:\/\// }, (req) => {
if (!OWN.test(new URL(req.url).host)) {
req.reply({ statusCode: 204, body: '' }); // any third party
}
});
});
6. Keep one spec that runs with tags enabled #
Blocking everywhere means a broken tag configuration never fails a test. One tagged spec that loads the real scripts against a staging property preserves that coverage, and its occasional instability is contained.
Pitfalls #
- Blocking the script but not stubbing its global. Application code calling
gtag(...)throws. Mitigation: stub the globals inonBeforeLoad. - Aborting instead of returning an empty 204. Some loaders retry and log noisily on network errors. Mitigation: reply with an empty success.
- Clicking through the consent banner in every test. Slow and timing-dependent. Mitigation: seed consent state before boot, and keep one spec for the real flow.
- Setting consent after
cy.visit(). The banner already rendered. Mitigation: write it inonBeforeLoad. - A blocklist nobody maintains. New vendors slip in and the flakiness returns. Mitigation: prefer an own-origin allowlist.
- Blocking everything and never testing the tags. A broken tracking plan ships unnoticed. Mitigation: one tagged spec with real scripts.
- Ignoring the widget that covers the button. Chat launchers sit exactly where submit buttons live. Mitigation: block them, and assert click targets are not obscured.
Reliability targets #
| Metric | Target | Notes |
|---|---|---|
| Third-party requests in a test run | 0 | Excluding the one tagged spec |
| Failures caused by consent overlays | 0 | Consent seeded before boot |
| Analytics events asserted | 1 per key conversion | Purchase, sign-up, activation |
| Suite time saved by blocking | 10–30% | Vendor scripts are often the slowest requests |
| Console errors from third parties | 0 | Restores the value of strict error checks |
Frequently Asked Questions #
Q: Is blocking analytics hiding a real integration problem? A: It is deferring it deliberately, which is different. A functional test of checkout should fail when checkout breaks, not when a vendor’s CDN is slow. Keep the integration covered by asserting the events against stubs, plus one spec that loads the real tags — that combination catches a broken tracking plan without letting it destabilise everything else.
Q: The consent banner still appears even though I blocked the script. Why? A: Either the banner is rendered by your own application rather than the vendor, or the vendor script is served from a first-party proxy domain that the blocklist does not match. Check the request in the command log; a first-party proxy is common precisely because it evades ad blockers, and it evades your patterns for the same reason.
Q: Should I block third parties in Playwright too? A: Yes, and the mechanism is the same idea with a different API — a route that fulfils vendor URLs with an empty body, registered in a shared fixture. The policy should be identical across runners, or the two suites will have different stability characteristics for reasons unrelated to the application.
Q: Blocking a vendor script broke the page entirely. What now?
A: The application depends on a global that script defines, and the dependency is unguarded. Stub the global in onBeforeLoad to restore the call sites, and treat the crash as a finding rather than an inconvenience: a page that white-screens when a third-party CDN is slow will do the same for real users on a bad connection. The stub keeps the suite green; the resilience fix belongs in the product.
Q: What about scripts that are genuinely part of the product, like a payment iframe? A: Those are dependencies, not decoration, and blocking them removes real coverage. Mock them at the boundary you control — a sandbox mode from the provider, or a stubbed iframe served from your own origin — rather than blocking them outright. The trade-off is the same one weighed throughout Network & API Mocking for Reliable Tests: fidelity where the behaviour matters, determinism everywhere else.