Skip to main content

How to Test API Integrations Without Hitting Production

·APIScout Team
testingapi integrationmockingsandboxbest practices

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:

ProviderSandboxHow to Use
StripeTest modeUse sk_test_ keys instead of sk_live_
PayPalSandboxsandbox.paypal.com, separate test accounts
TwilioMagic numbersUse specific test phone numbers
Auth0Dev tenantSeparate tenant for testing
PlaidSandboxsandbox.plaid.com, test credentials
DocuSignDemodemo.docusign.net
SquareSandboxUse 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:

  1. Run tests with RECORD=true → hits real API, saves responses to disk
  2. Run tests normally → replays saved responses (no network calls)
  3. 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

LayerWhat to TestHowSpeed
Unit testsBusiness logic, data transformationNo mocking needed (pure functions)Instant
Integration (mocked)Request building, error handling, retriesMSW or dependency injectionFast
Integration (recorded)Real API behavior, response parsingRecord/replay (Polly.js)Fast (replay)
ContractAPI hasn't changedRun against sandboxSlow (real API)
E2E (sandbox)Full flow worksReal sandbox APISlow

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

MistakeImpactFix
Testing against live API in CIReal side effects, costs moneyUse sandbox or mocks
Mocking at wrong levelTests pass but integration breaksMock at HTTP level, not function level
Not testing error casesApp crashes on first API errorMock 4xx, 5xx, timeouts
Hard-coding mock responsesTests pass when API changesUse recorded responses, refresh periodically
No webhook testingWebhook bugs found in productionGenerate test events with signatures
Testing only happy pathMiss edge casesTest rate limits, timeouts, malformed responses

Find APIs with the best sandbox and testing environments on APIScout — sandbox availability, test credentials, and developer experience scores.

Comments