Third-Party SDK Isolation
Modern applications are assembled from other companies’ services: a payment processor, an analytics platform, a feature-flag provider, an email sender, an error tracker. Each arrives as an SDK that is trivially easy to call and surprisingly hard to test around — it needs credentials, makes network calls on import, batches events in the background, and behaves differently in test mode than in production. Left alone, these SDKs make test runs slow, flaky, and occasionally expensive: real emails sent to real addresses, analytics dashboards polluted with test traffic, feature flags that change a test’s behaviour without any change to the code. This topic belongs to advanced mocking and service isolation patterns and covers isolating these dependencies at the right boundary, so tests stay fast and deterministic while the integration code — the part you actually wrote — stays covered.
Architectural Scope & Boundaries
The code in scope is the glue between your application and a vendor: creating a checkout session, tracking a conversion, evaluating a flag, sending a transactional email. It excludes the vendor’s own behaviour, which you neither own nor can change, and it excludes the vendor’s dashboard and configuration, which are operational concerns.
The central design decision is where the seam goes, and there are three reasonable answers. The narrowest is to intercept HTTP — the SDK runs unmodified and MSW answers its requests. The widest is an adapter interface in your own code, with the SDK hidden behind it. In between is replacing the SDK module with vi.mock. Each has a place, and choosing wrongly is the most common source of pain in this area.
Intercepting HTTP gives the highest fidelity for the adapter itself, because the SDK’s serialisation, retries and error mapping all run. It is also the most brittle, since it depends on the SDK’s wire format, which vendors change. An adapter interface gives the most stable tests for business logic, because they depend only on your own types, but it leaves the adapter’s mapping to the SDK to be tested separately. Module mocking sits awkwardly between them: coupled to the SDK’s API surface like HTTP interception, but without its fidelity.
The rule this topic follows is to combine the first two. Business logic depends on an adapter interface and is tested against an in-memory fake; each adapter is tested once, thoroughly, against intercepted HTTP or the vendor’s own test mode. Module mocking is reserved for SDKs that do work at import time and cannot be isolated any other way.
There is a boundary of consequence as well. Some SDKs are purely observational — analytics and error tracking — and tests only need them silent. Others are transactional — payments and email — where the call itself is the behaviour under test, and tests need to assert on exactly what was requested. The two categories deserve different fakes: a no-op for the first, a recording fake for the second.
A final framing point concerns ownership. The adapter is the one place where knowledge of a vendor’s API accumulates — its quirks, its retry semantics, the field it deprecated last year. Putting all of that in one module, with one set of tests, means that knowledge has an address. When a vendor announces a breaking change, the question “what do we need to change?” has a one-file answer, and the tests that prove the change is complete are already sitting next to it.
Prerequisites
Step-by-Step Implementation
Step 1 — Define the interface in your terms, not the vendor’s. The interface expresses what the application needs, with your own types, so swapping or upgrading the vendor changes one file.
// src/ports/payments.ts
export type CheckoutRequest = { orderId: string; amountPence: number; currency: 'GBP' | 'USD'; customerEmail: string };
export type CheckoutSession = { id: string; url: string };
export interface Payments {
createCheckout(req: CheckoutRequest): Promise<CheckoutSession>;
refund(paymentId: string, amountPence: number): Promise<{ refundId: string }>;
}
Notice what the interface leaves out: sessions, payment intents, price objects, API versions. Those are how one vendor models payments, and exposing them would make every caller depend on that model. The application needs to start a checkout and issue a refund; everything else is the adapter’s business. Keeping the interface this small is also what makes the fake trivial to write — two methods, not a simulation of a payments platform.
Step 2 — Implement the production adapter around the SDK. All vendor-specific mapping lives here, and nowhere else.
// src/adapters/stripe-payments.ts
import Stripe from 'stripe';
import type { Payments } from '../ports/payments';
export function stripePayments(secretKey: string): Payments {
const stripe = new Stripe(secretKey, { apiVersion: '2024-06-20' });
return {
async createCheckout(req) {
const s = await stripe.checkout.sessions.create({
mode: 'payment',
customer_email: req.customerEmail,
client_reference_id: req.orderId,
line_items: [{ quantity: 1, price_data: { currency: req.currency.toLowerCase(), unit_amount: req.amountPence, product_data: { name: `Order ${req.orderId}` } } }],
success_url: `${process.env.APP_URL}/orders/${req.orderId}?paid=1`,
cancel_url: `${process.env.APP_URL}/checkout`,
});
return { id: s.id, url: s.url! };
},
async refund(paymentId, amountPence) {
const r = await stripe.refunds.create({ payment_intent: paymentId, amount: amountPence });
return { refundId: r.id };
},
};
}
Everything vendor-specific — the API version, the shape of line items, the conversion of currency codes to lower case — is concentrated in this one function. That concentration is the payoff: an upgrade that changes any of it is a change to one file with one set of tests, and the rest of the application does not know it happened.
Step 3 — Write a recording fake for transactional vendors. It implements the interface, records every request, and lets tests script failures.
// test/fakes/payments.ts
import type { Payments, CheckoutRequest } from '../../src/ports/payments';
export function fakePayments() {
const checkouts: CheckoutRequest[] = [];
const refunds: Array<{ paymentId: string; amountPence: number }> = [];
let failNext: Error | undefined;
const payments: Payments = {
async createCheckout(req) {
if (failNext) { const e = failNext; failNext = undefined; throw e; }
checkouts.push(req);
return { id: `cs_test_${checkouts.length}`, url: `https://pay.test/cs_${checkouts.length}` };
},
async refund(paymentId, amountPence) {
refunds.push({ paymentId, amountPence });
return { refundId: `re_test_${refunds.length}` };
},
};
return { payments, checkouts, refunds, failNextWith: (e: Error) => { failNext = e; } };
}
Step 4 — Test business logic against the fake. These tests are fast, need no credentials, and assert on exactly what the application asked the vendor to do.
// src/checkout/start-checkout.test.ts
import { test, expect } from 'vitest';
import { startCheckout } from './start-checkout';
import { fakePayments } from '../../test/fakes/payments';
test('requests a checkout for the discounted total', async () => {
const { payments, checkouts } = fakePayments();
await startCheckout({ orderId: 'o1', basketTotalPence: 10_000, promo: 'SAVE10', email: 'a@example.test' }, { payments });
expect(checkouts).toEqual([{ orderId: 'o1', amountPence: 9_000, currency: 'GBP', customerEmail: 'a@example.test' }]);
});
The same fake serves every test that involves payments, which is the other benefit of a small interface. A test about order confirmation, a test about refunds and a test about abandoned baskets all use it without configuration, and the scripted failure mode lets any of them check what the application does when the payment provider is down — a scenario the real sandbox almost never produces on request.
Step 5 — Test each adapter once against intercepted HTTP. This is where the SDK’s mapping is verified: MSW answers the vendor’s endpoints with realistic responses and errors.
// src/adapters/stripe-payments.test.ts
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
http.post('https://api.stripe.com/v1/checkout/sessions', async ({ request }) => {
const form = new URLSearchParams(await request.text());
expect(form.get('line_items[0][price_data][unit_amount]')).toBe('9000');
return HttpResponse.json({ id: 'cs_test_123', url: 'https://checkout.stripe.com/c/cs_test_123' });
}),
);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterAll(() => server.close());
test('maps a checkout request onto the vendor API', async () => {
const payments = stripePayments('sk_test_fake');
await expect(payments.createCheckout({ orderId: 'o1', amountPence: 9000, currency: 'GBP', customerEmail: 'a@example.test' }))
.resolves.toEqual({ id: 'cs_test_123', url: expect.stringContaining('cs_test_123') });
});
Step 6 — Guard the boundary with a lint rule. Once business code imports the vendor SDK directly, the isolation erodes one import at a time.
// eslint.config.js
export default [{
files: ['src/**/*.ts'],
ignores: ['src/adapters/**'],
rules: {
'no-restricted-imports': ['error', { paths: ['stripe', '@segment/analytics-node', 'launchdarkly-node-server-sdk', '@sendgrid/mail'] }],
},
}];
The rule is a small investment with a large effect. Without it, the next developer who needs a vendor feature not yet exposed by the adapter will import the SDK directly “just this once”, and that import is invisible to the fakes, the adapter tests and the contract job. With it, the natural path is to extend the interface — which is exactly the change that keeps everything else working.
Configuration Reference Table
| Concern | Setting | Where | Effect |
|---|---|---|---|
| Vendor keys | absent in test env | CI secrets | A misconfigured test cannot reach the real service at all. |
| Test-mode keys | sk_test_… style |
adapter contract job | Used only by the scheduled adapter checks, never by the unit suite. |
onUnhandledRequest |
'error' |
MSW server | Any unexpected vendor call fails the test instead of leaking. |
| Adapter factory | injected | app composition | Tests pass fakes; production passes real adapters. |
| Observational SDKs | no-op in tests | composition root | Stops background batching and network traffic during runs. |
| SDK init at import | vi.mock |
test setup | Last resort for SDKs that connect on import. |
| Restricted imports | lint rule | eslint config | Keeps vendor packages inside the adapter directory. |
| Contract schedule | nightly | CI workflow | Detects vendor API changes without gating every merge. |
The first row is the one that prevents incidents rather than merely improving tests. If production keys are simply not present in the environment where tests run, no misconfiguration of a fake, no forgotten mock and no accidental import can charge a card or email a customer. Every other protection in this topic depends on people doing the right thing; this one depends only on the secrets being scoped correctly.
Verification & Assertions
The first verification is that no real vendor call happens during the unit suite. MSW with onUnhandledRequest: 'error' turns any leak into a failure, and a network-restricted CI job makes it structural rather than conventional.
npx vitest run --reporter=dot
# any unexpected request to api.stripe.com, api.segment.io, … fails with:
# [MSW] Error: intercepted a request without a matching request handler
The second is that the adapters themselves stay correct as vendors evolve. A scheduled job runs the adapter tests against the vendor’s genuine test mode — real credentials, real API, no customer impact — and a failure there means the vendor changed something your adapter depends on. That job should not gate merges, because its failures are usually about the vendor, but it should alert someone.
The third is behavioural: for transactional vendors, assert on the full request, not on whether a call happened. “A checkout was created” passes when the amount is wrong; “a checkout was created for 9,000 pence in GBP for this order” does not. The recording fake makes the precise assertion as easy to write as the vague one.
Edge Cases & Failure Modes
SDKs that do work at import time. Some SDKs open connections, start timers or read configuration as soon as they are imported, before any test code runs. Diagnose by noticing network activity or open handles with no test calling the SDK. Fix by importing the SDK only inside the adapter’s factory function, or — if it must be imported at module scope — by replacing the module with vi.mock in the test setup.
Background batching that outlives the test. Analytics SDKs typically queue events and flush them on a timer. In tests this produces flushes after the test has finished, open handles that keep the process alive, and occasional network calls during unrelated tests. The no-op fake avoids all of it; if the real SDK must run, call its flush and shutdown methods in teardown.
Test mode that behaves differently from live mode. Vendor test modes are excellent but not identical to production — some webhooks are not sent, some limits do not apply, some error codes never occur. Treat the contract job as evidence of API shape, not as proof of production behaviour, and keep your own tests for the error paths the test mode will not produce.
Retries inside the SDK that hide failures. Many SDKs retry transient errors internally before surfacing them, so an adapter test that returns a single 500 from MSW may see a success on the SDK’s second attempt. Decide whether that retry is behaviour you rely on; if so, test it by returning an error then a success, and if not, configure the SDK’s retry count explicitly in the adapter so it is not an invisible default.
Fakes that drift from the interface. A fake that implements an old version of the interface compiles only if it is typed against the interface; an untyped fake silently diverges. Type every fake as the interface it implements, so a change to the port fails compilation in every fake at once.
Performance & CI Impact
Isolating vendor SDKs is one of the largest speed improvements available to many suites, because real SDK calls are slow and unpredictable — hundreds of milliseconds per call, with occasional multi-second outliers when the vendor is under load. A suite that makes real calls in unit tests is paying that latency hundreds of times and inheriting the vendor’s availability as its own.
It also removes a category of flakiness that no amount of retrying fixes properly: rate limits. Test suites that call vendor sandboxes in parallel routinely hit per-account rate limits, producing failures that correlate with how many CI jobs happen to be running rather than with anything in the code.
The contract job is the one place real vendor calls remain, and it runs on a schedule rather than per commit. Its cost is a few minutes a night; its value is catching the day a vendor deprecates a field before that deprecation reaches production. Keep its failures visible but non-blocking, and treat each one as a small investigation rather than noise.
Finally, there is a cost that does not appear in timings: the risk of acting on the world from a test. A misconfigured suite that sends real emails or creates real charges is an incident, and removing production keys from the test environment entirely is the only defence that does not depend on everyone remembering.
One last piece of practical advice: resist building elaborate simulators. It is tempting, once a fake exists, to make it model the vendor’s state machine in full — payment intents moving through statuses, subscriptions renewing, emails bouncing. Those simulators become projects in their own right and drift from the real service. Keep fakes small and scripted, test the specific transitions your code handles, and leave the vendor’s full behaviour to the contract job and the vendor’s own test mode.
In-Depth Guides
- Testing Stripe payment flows without live keys — a payments port, a recording fake, MSW for the adapter and test mode for the contract.
- Isolating analytics SDKs from test runs — silence by default, and precise assertions where tracking is the feature.
- Mocking feature flag providers deterministically — explicit flag states per test and both branches covered.
- Stubbing email and notification providers — an outbox fake, template rendering and delivery failures.
Related
- Back to Advanced Mocking & Service Isolation Patterns
- External Service Simulation — the MSW techniques the adapter tests use.
- Contract Testing — the formal version of the nightly adapter check.
- Authentication & Session Mocking — identity providers are one more vendor behind the same boundary.
Testing Stripe Payment Flows Without Live Keys
Cover checkout, webhooks, refunds and declines without live keys: stripe-mock for the adapter, signed test webhooks, and a nightly test-mode run.
Isolating Analytics SDKs From Test Runs
Keep analytics and error-tracking SDKs silent in tests, assert precisely where tracking is the feature, and stop background flushes leaking.
Mocking Feature Flag Providers Deterministically
Make every flag explicit in tests: an in-memory flags provider, both branches covered, OpenFeature's test provider, and no silent defaults.
Stubbing Email and Notification Providers
Test transactional email and SMS without sending any: an outbox fake, template checks, provider errors and bounces, and a mail catcher for E2E.