How to Test API Integrations Without Hitting Production
How to Test API Integrations Without Hitting Production
Testing API integrations against live APIs is slow, expensive, flaky, and can produce real side effects. Send a test payment through Stripe's live API and you charge a real card. Hit OpenAI's API 10,000 times in CI and you spend real money. The solution: test without touching production.
The Testing Pyramid for API Integrations
╱╲
╱ ╲ End-to-End (Sandbox)
╱ ╲ Real API, test environment
╱──────╲
╱ ╲ Contract Tests
╱ ╲ Verify API contract hasn't changed
╱────────────╲
╱ ╲ Integration Tests (Mocked)
╱ ╲ Mock server, recorded responses
╱──────────────────╲
╱ ╲ Unit Tests
╱ ╲ Pure functions, no API calls
Strategy 1: Sandbox Environments
Most major APIs provide test/sandbox environments:
| Provider | Sandbox | How to Use |
|---|---|---|
| Stripe | Test mode | Use sk_test_ keys instead of sk_live_ |
| PayPal | Sandbox | sandbox.paypal.com, separate test accounts |
| Twilio | Magic numbers | Use specific test phone numbers |
| Auth0 | Dev tenant | Separate tenant for testing |
| Plaid | Sandbox | sandbox.plaid.com, test credentials |
| DocuSign | Demo | demo.docusign.net |
| Square | Sandbox | Use sandbox application ID |
// Environment-based API configuration
const config = {
stripe: {
apiKey: process.env.NODE_ENV === 'production'
? process.env.STRIPE_LIVE_KEY
: process.env.STRIPE_TEST_KEY,
},
plaid: {
baseUrl: process.env.NODE_ENV === 'production'
? 'https://production.plaid.com'
: 'https://sandbox.plaid.com',
},
};
// Stripe test mode — use test card numbers
// 4242424242424242 → Always succeeds
// 4000000000000002 → Always declines
// 4000000000009995 → Insufficient funds
When to use sandbox: End-to-end tests, integration tests that need realistic API behavior, manual QA.
Strategy 2: API Mocking with MSW
Mock Service Worker (MSW) intercepts network requests and returns mock responses:
// mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
// Mock Stripe customer creation
http.post('https://api.stripe.com/v1/customers', async ({ request }) => {
const body = await request.text();
const params = new URLSearchParams(body);
return HttpResponse.json({
id: 'cus_test_123',
email: params.get('email'),
name: params.get('name'),
created: Math.floor(Date.now() / 1000),
});
}),
// Mock Stripe payment intent
http.post('https://api.stripe.com/v1/payment_intents', async () => {
return HttpResponse.json({
id: 'pi_test_456',
status: 'succeeded',
amount: 2000,
currency: 'usd',
});
}),
// Mock error response
http.post('https://api.stripe.com/v1/charges', async () => {
return HttpResponse.json(
{ error: { type: 'card_error', code: 'card_declined', message: 'Your card was declined.' } },
{ status: 402 }
);
}),
// Mock Resend email
http.post('https://api.resend.com/emails', async () => {
return HttpResponse.json({
id: 'email_test_789',
});
}),
];
// mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// tests/setup.ts
import { server } from '../mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// tests/payment.test.ts
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';
import { processPayment } from '../src/payment';
test('successful payment', async () => {
const result = await processPayment({
amount: 2000,
currency: 'usd',
customerId: 'cus_123',
});
expect(result.status).toBe('succeeded');
expect(result.id).toBe('pi_test_456');
});
test('handles card decline', async () => {
// Override handler for this specific test
server.use(
http.post('https://api.stripe.com/v1/payment_intents', () => {
return HttpResponse.json(
{ error: { code: 'card_declined' } },
{ status: 402 }
);
})
);
await expect(processPayment({ amount: 2000, currency: 'usd' }))
.rejects.toThrow('Card declined');
});
test('retries on server error', async () => {
let attempts = 0;
server.use(
http.post('https://api.stripe.com/v1/payment_intents', () => {
attempts++;
if (attempts < 3) {
return HttpResponse.json({}, { status: 500 });
}
return HttpResponse.json({ id: 'pi_retry', status: 'succeeded' });
})
);
const result = await processPayment({ amount: 2000, currency: 'usd' });
expect(result.status).toBe('succeeded');
expect(attempts).toBe(3);
});
Strategy 3: Record and Replay
Record real API responses once, replay them in tests:
// Using Polly.js for record/replay
import { Polly } from '@pollyjs/core';
import NodeHTTPAdapter from '@pollyjs/adapter-node-http';
import FSPersister from '@pollyjs/persister-fs';
Polly.register(NodeHTTPAdapter);
Polly.register(FSPersister);
describe('API integration', () => {
let polly: Polly;
beforeEach(() => {
polly = new Polly('stripe-integration', {
adapters: ['node-http'],
persister: 'fs',
persisterOptions: {
fs: { recordingsDir: '__recordings__' },
},
recordIfMissing: process.env.RECORD === 'true',
matchRequestsBy: {
headers: false, // Don't match on auth headers
body: true,
url: { pathname: true, query: true },
},
});
});
afterEach(async () => {
await polly.stop();
});
test('creates a customer', async () => {
// First run with RECORD=true: hits real API, saves response
// Subsequent runs: replays saved response
const customer = await stripe.customers.create({
email: 'test@example.com',
});
expect(customer.email).toBe('test@example.com');
});
});
How it works:
- Run tests with
RECORD=true→ hits real API, saves responses to disk - Run tests normally → replays saved responses (no network calls)
- Re-record periodically to catch API changes
Strategy 4: Contract Testing
Verify that the API contract hasn't changed:
// Contract tests verify API shape, not business logic
import { z } from 'zod';
// Define expected API contracts
const StripeCustomerSchema = z.object({
id: z.string().startsWith('cus_'),
object: z.literal('customer'),
email: z.string().email().nullable(),
name: z.string().nullable(),
created: z.number(),
metadata: z.record(z.string()).optional(),
});
const StripeErrorSchema = z.object({
error: z.object({
type: z.string(),
code: z.string().optional(),
message: z.string(),
param: z.string().optional(),
}),
});
// Contract test — runs against sandbox
describe('Stripe API Contract', () => {
test('customer creation returns expected shape', async () => {
const customer = await stripe.customers.create({
email: 'contract-test@example.com',
});
const result = StripeCustomerSchema.safeParse(customer);
expect(result.success).toBe(true);
// Cleanup
await stripe.customers.del(customer.id);
});
test('invalid request returns expected error shape', async () => {
try {
await stripe.customers.create({ email: 'not-an-email' });
} catch (error: any) {
const result = StripeErrorSchema.safeParse({ error: error.raw });
expect(result.success).toBe(true);
}
});
});
When to run contract tests: In CI, weekly or on-demand. Not on every commit (they hit real APIs and are slow).
Strategy 5: API Client Abstraction for Testing
Design your code so the API client is swappable:
// Interface — your code depends on this
interface PaymentService {
createCustomer(email: string): Promise<{ id: string; email: string }>;
chargeCustomer(customerId: string, amount: number): Promise<{ id: string; status: string }>;
}
// Real implementation
class StripePaymentService implements PaymentService {
constructor(private stripe: Stripe) {}
async createCustomer(email: string) {
const customer = await this.stripe.customers.create({ email });
return { id: customer.id, email: customer.email! };
}
async chargeCustomer(customerId: string, amount: number) {
const intent = await this.stripe.paymentIntents.create({
customer: customerId,
amount,
currency: 'usd',
});
return { id: intent.id, status: intent.status };
}
}
// Test implementation — no API calls
class MockPaymentService implements PaymentService {
customers: Map<string, { id: string; email: string }> = new Map();
charges: Array<{ id: string; customerId: string; amount: number }> = [];
async createCustomer(email: string) {
const id = `cus_mock_${Date.now()}`;
const customer = { id, email };
this.customers.set(id, customer);
return customer;
}
async chargeCustomer(customerId: string, amount: number) {
const id = `pi_mock_${Date.now()}`;
this.charges.push({ id, customerId, amount });
return { id, status: 'succeeded' };
}
}
// Tests use mock — fast, deterministic, no API calls
test('checkout flow', async () => {
const payments = new MockPaymentService();
const checkout = new CheckoutService(payments);
const result = await checkout.processOrder({
email: 'test@example.com',
items: [{ id: 'prod_1', quantity: 1, price: 2000 }],
});
expect(result.status).toBe('succeeded');
expect(payments.charges).toHaveLength(1);
expect(payments.charges[0].amount).toBe(2000);
});
Testing Strategy by Layer
| Layer | What to Test | How | Speed |
|---|---|---|---|
| Unit tests | Business logic, data transformation | No mocking needed (pure functions) | Instant |
| Integration (mocked) | Request building, error handling, retries | MSW or dependency injection | Fast |
| Integration (recorded) | Real API behavior, response parsing | Record/replay (Polly.js) | Fast (replay) |
| Contract | API hasn't changed | Run against sandbox | Slow (real API) |
| E2E (sandbox) | Full flow works | Real sandbox API | Slow |
Testing Webhooks
// Test webhook handler without waiting for real webhooks
test('handles payment_intent.succeeded webhook', async () => {
const event = {
id: 'evt_test_123',
type: 'payment_intent.succeeded',
data: {
object: {
id: 'pi_test_456',
amount: 2000,
status: 'succeeded',
customer: 'cus_test_789',
},
},
};
// Generate valid test signature
const payload = JSON.stringify(event);
const signature = stripe.webhooks.generateTestHeaderString({
payload,
secret: WEBHOOK_SECRET,
});
const response = await app.inject({
method: 'POST',
url: '/webhooks/stripe',
headers: {
'stripe-signature': signature,
'content-type': 'application/json',
},
body: payload,
});
expect(response.statusCode).toBe(200);
// Verify side effects
const order = await db.orders.findByPaymentIntent('pi_test_456');
expect(order.status).toBe('paid');
});
CI/CD Pipeline Configuration
# .github/workflows/test.yml
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test -- --filter=unit
integration-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test -- --filter=integration
# Uses MSW mocks and recorded responses — no API keys needed
contract-tests:
runs-on: ubuntu-latest
# Only run weekly or on explicit trigger
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v4
- run: npm test -- --filter=contract
env:
STRIPE_TEST_KEY: ${{ secrets.STRIPE_TEST_KEY }}
Common Mistakes
| Mistake | Impact | Fix |
|---|---|---|
| Testing against live API in CI | Real side effects, costs money | Use sandbox or mocks |
| Mocking at wrong level | Tests pass but integration breaks | Mock at HTTP level, not function level |
| Not testing error cases | App crashes on first API error | Mock 4xx, 5xx, timeouts |
| Hard-coding mock responses | Tests pass when API changes | Use recorded responses, refresh periodically |
| No webhook testing | Webhook bugs found in production | Generate test events with signatures |
| Testing only happy path | Miss edge cases | Test rate limits, timeouts, malformed responses |
Find APIs with the best sandbox and testing environments on APIScout — sandbox availability, test credentials, and developer experience scores.