Isolating Analytics SDKs From Test Runs
Analytics and error-tracking SDKs are built to be fire-and-forget: call track(), and the SDK quietly batches, retries and flushes in the background. That design is exactly wrong for tests. Events from test runs pollute real dashboards, background flush timers keep the test process alive after the last test, network calls slow the suite and fail when the network is restricted, and the rare test that genuinely cares about tracking has no reliable way to see what was sent. This guide covers making these SDKs silent by default, asserting precisely where tracking is the feature under test, and cleaning up the background work they leave behind. It applies to Segment, PostHog, Amplitude, Sentry and similar SDKs in both Node and browser code, and sits under third-party SDK isolation.
Root Cause Analysis
The problems all come from the SDK’s asynchronous design meeting a test runner’s synchronous expectations. A track() call returns immediately, having only queued the event; the actual network request happens later on a timer. In a test, “later” may be after the test has finished, after its mocks have been restored, or after the worker has started shutting down — producing stray requests, warnings about open handles, and occasionally a test run that refuses to exit.
Pollution is the second problem, and it is not merely cosmetic. Product and marketing teams make decisions from these dashboards. A CI pipeline that sends a few thousand synthetic sign-up events a day distorts conversion rates, triggers alerts, and in some pricing models costs real money per event. Error trackers fare worse: test runs that deliberately exercise error paths flood the issue list with noise that hides real production errors.
The third problem is the inverse. Some tracking is itself a requirement — a purchase event that feeds revenue reporting, a consent-gated event that must not fire before consent. Those deserve precise tests, and a globally stubbed SDK gives them nothing to assert against.
Reproducible Setup
Route all tracking through a small interface so the SDK is referenced in exactly one module.
// src/ports/analytics.ts
export type AnalyticsEvent =
| { name: 'signup_completed'; userId: string; plan: 'free' | 'pro' }
| { name: 'order_completed'; orderId: string; revenuePence: number; currency: string };
export interface Analytics {
track(event: AnalyticsEvent): void;
identify(userId: string, traits: Record<string, unknown>): void;
flush(): Promise<void>;
}
// src/adapters/segment-analytics.ts — the only file that imports the SDK
import { Analytics as Segment } from '@segment/analytics-node';
import type { Analytics } from '../ports/analytics';
export function segmentAnalytics(writeKey: string): Analytics {
const client = new Segment({ writeKey, flushInterval: 10_000 });
return {
track: ({ name, ...properties }) => client.track({ event: name, userId: 'userId' in properties ? String(properties.userId) : 'system', properties }),
identify: (userId, traits) => client.identify({ userId, traits }),
flush: () => client.closeAndFlush(),
};
}
A discriminated union for events is a small investment with a large return: a misspelled event name or a missing property becomes a compile error rather than a silently broken dashboard.
Implementation
Step 1 — Default to a silent implementation in tests. Wire the application with a no-op unless a test explicitly asks for something else.
// test/fakes/analytics.ts
import type { Analytics, AnalyticsEvent } from '../../src/ports/analytics';
export const silentAnalytics: Analytics = { track() {}, identify() {}, async flush() {} };
export function recordingAnalytics() {
const events: AnalyticsEvent[] = [];
const identities: Array<{ userId: string; traits: Record<string, unknown> }> = [];
const analytics: Analytics = {
track: (e) => { events.push(e); },
identify: (userId, traits) => { identities.push({ userId, traits }); },
async flush() {},
};
return { analytics, events, identities };
}
// test/app.ts — the default composition for tests
export const testApp = (overrides: Partial<Deps> = {}) =>
createApp({ analytics: silentAnalytics, ...defaultTestDeps(), ...overrides });
Step 2 — Assert precisely where tracking is the requirement. Revenue events feed financial reporting, so their exact shape is a business requirement worth a test.
// src/orders/complete-order.test.ts
import { test, expect } from 'vitest';
import { recordingAnalytics } from '../../test/fakes/analytics';
test('reports revenue in minor units with the order currency', async () => {
const { analytics, events } = recordingAnalytics();
await completeOrder({ orderId: 'o1', totalPence: 4999, currency: 'GBP' }, { ...defaultTestDeps(), analytics });
expect(events).toEqual([{ name: 'order_completed', orderId: 'o1', revenuePence: 4999, currency: 'GBP' }]);
});
Step 3 — Test consent gating as a behaviour. Whether an event may be sent at all is often a legal requirement, and the recording fake makes the absence of an event as easy to assert as its presence.
test('does not track before the user has consented', async () => {
const { analytics, events } = recordingAnalytics();
const tracker = consentAwareTracker(analytics, { consent: 'denied' });
tracker.track({ name: 'signup_completed', userId: 'u1', plan: 'free' });
expect(events).toEqual([]);
});
Step 4 — Silence error trackers the same way, but capture in the few tests that care. Sentry-style SDKs should never initialise in tests; where error reporting is itself the behaviour, a recording fake captures what would have been sent.
// src/ports/errors.ts
export interface ErrorReporter { capture(err: unknown, context?: Record<string, unknown>): void }
// test
test('reports a payment failure with the order id for triage', async () => {
const captured: Array<{ err: unknown; context?: Record<string, unknown> }> = [];
const reporter = { capture: (err: unknown, context?: Record<string, unknown>) => captured.push({ err, context }) };
await expect(chargeOrder('o7', { ...deps, reporter, payments: failingPayments() })).rejects.toThrow();
expect(captured[0]?.context).toMatchObject({ orderId: 'o7' });
});
Step 5 — For browser code, block the SDK’s network endpoints. Client-side snippets often load from a CDN and post to a collection endpoint; in Playwright, route both to a no-op so end-to-end runs never reach the vendor.
// e2e/fixtures/block-analytics.ts
import { test as base } from '@playwright/test';
export const test = base.extend({
page: async ({ page }, use) => {
await page.route(/(cdn\.segment\.com|api\.segment\.io|app\.posthog\.com|sentry\.io)/, (r) => r.fulfill({ status: 204 }));
await use(page);
},
});
Step 6 — Flush and close real SDKs in teardown if any test uses one. If an integration test must run the real adapter, close it explicitly so its timers do not outlive the test.
afterAll(async () => { await analytics.flush(); });
Verification
Confirm no analytics traffic leaves a test run. With MSW set to error on unhandled requests, any stray call to a vendor endpoint fails the test that caused it.
npx vitest run --reporter=dot
# no "[MSW] intercepted a request without a matching request handler" for any analytics host
Then confirm the run exits cleanly. Open handles from an SDK’s flush timer show up as a delayed exit or a warning; Vitest can report them explicitly.
npx vitest run --reporter=hanging-process
# no analytics timers listed among open handles
Troubleshooting
Symptom: the test run hangs for several seconds after the last test. Diagnosis: a real SDK was initialised somewhere and its flush interval is keeping the event loop alive. Fix: find the import with the hanging-process reporter, route it through the silent adapter, and ensure the SDK is never constructed at module scope.
Symptom: events from CI still appear in the dashboard. Diagnosis: an end-to-end or preview-deployment run is loading the real client-side snippet. Fix: block the vendor hosts with page.route as in Step 5, and configure the application to skip loading the snippet entirely when a test flag is set at build time.
Symptom: an event-shape test passes, but the dashboard shows the wrong property. Diagnosis: the adapter’s mapping from your event type to the vendor’s payload is untested. Fix: add one adapter test with MSW capturing the vendor’s batch endpoint, asserting on the property names the vendor actually receives.
Symptom: error tracking is noisy with test failures in production. Diagnosis: a deployment environment used for testing shares the production project key. Fix: give every non-production environment its own project or disable reporting there, and tag events with the environment so any leak is at least attributable.
FAQ
Is mocking the SDK module with vi.mock simpler?
It is quicker to write, and it works, but it couples every test to the SDK’s API surface and to the details of how it is imported. The port approach costs one small file and makes the silent default and the recording fake available everywhere with no per-test setup. For an SDK used in many places, the port pays for itself within days.
Should analytics calls be awaited?
In production, generally not — tracking should never slow a user action. In code, that means the port’s track returns nothing and the adapter handles batching. The one place to await is shutdown, where flush ensures queued events are delivered before the process exits.
How do I test that an event fires exactly once?
The recording fake makes this a length assertion. It is worth doing for events that feed counts — sign-ups, purchases — because double-firing is a common bug after a component re-renders or a handler is retried, and it inflates metrics silently.
What about tag managers that load arbitrary scripts?
Treat them like any third-party script in end-to-end tests: block the loader host by default. If a specific test needs the tag manager’s behaviour, that is testing a vendor’s product rather than your code, and it belongs in a separate, deliberately-scoped check.
Related
- Back to Third-Party SDK Isolation
- Mocking feature flag providers deterministically — another SDK that should be explicit in tests.
- Controlling Date.now and setTimeout in Jest — why SDK timers interfere with fake time.
- Mocking network in Playwright component tests — route-blocking techniques for the browser.