Testing Stripe Payment Flows Without Live Keys
A card payment is not one call but a conversation: the application creates a checkout session, the customer pays on a hosted page, Stripe sends a webhook when the payment settles, and only then does the order get fulfilled. Most test suites cover the first step and assume the rest, which is backwards — fulfilment on the webhook is where double-shipping, missed orders and unrefunded cancellations come from. This guide covers the whole conversation with no live keys anywhere: stripe-mock for the adapter’s API calls, genuinely signed test webhooks for the fulfilment path, Stripe’s test card numbers for declines, and a nightly job against test mode to catch API drift. It sits under third-party SDK isolation.
Root Cause Analysis
The step teams test is the one that least needs it. Creating a checkout session is a single API call with a predictable response, and its failure modes are few. The step teams skip — reacting to checkout.session.completed or payment_intent.succeeded — carries all the business consequences: it decides whether goods ship, whether a subscription activates, whether a confirmation email goes out.
That fulfilment step is hard to test naively because it is triggered from outside. Stripe calls your endpoint, signs the payload with a secret, may deliver the same event more than once, and may deliver related events out of order. A test that calls the fulfilment function with a hand-built object skips the signature check and never sees duplicates, so it passes while the production handler double-ships on Stripe’s first retry.
Finally, declines and asynchronous payment methods are where the edge cases live. A card that requires authentication, a payment that succeeds later via bank debit, a dispute opened weeks afterwards — each produces a different sequence of events, and each needs the handler to do something specific. Stripe’s test mode can produce most of these deliberately, but only if the tests ask for them.
Reproducible Setup
stripe-mock is Stripe’s own OpenAPI-driven mock server: it accepts every endpoint with correctly-shaped responses, which makes it ideal for verifying that the adapter’s requests are well formed.
# docker-compose.test.yml
services:
stripe-mock:
image: stripe/stripe-mock:v0.186.0
ports: ["12111:12111"]
// src/adapters/stripe-client.ts — the endpoint is configurable, the key never live in tests
import Stripe from 'stripe';
export function createStripe() {
const key = process.env.STRIPE_SECRET_KEY!;
if (process.env.NODE_ENV !== 'production' && key.startsWith('sk_live_')) {
throw new Error('Refusing to use a live Stripe key outside production');
}
return new Stripe(key, {
apiVersion: '2024-06-20',
...(process.env.STRIPE_API_HOST ? { host: process.env.STRIPE_API_HOST, port: 12111, protocol: 'http' as const } : {}),
});
}
The live-key guard is a few lines of production code and the most valuable line in this whole guide: it makes the worst possible test misconfiguration impossible rather than merely unlikely.
Implementation
Step 1 — Verify the adapter’s requests against stripe-mock. The mock validates parameters against Stripe’s schema, so a malformed request fails the same way it would against the real API.
// src/adapters/stripe-payments.integration.test.ts
import { test, expect, beforeAll } from 'vitest';
import { stripePayments } from './stripe-payments';
beforeAll(() => {
process.env.STRIPE_SECRET_KEY = 'sk_test_123';
process.env.STRIPE_API_HOST = 'localhost';
});
test('creates a checkout session stripe accepts', async () => {
const session = await stripePayments().createCheckout({
orderId: 'o1', amountPence: 4999, currency: 'GBP', customerEmail: 'a@example.test',
});
expect(session.id).toMatch(/^cs_/);
});
Step 2 — Sign test webhooks with the SDK’s own helper. Stripe’s library generates test signature headers, so the fulfilment endpoint’s verification runs exactly as in production.
// test/stripe/webhook.ts
import Stripe from 'stripe';
const stripe = new Stripe('sk_test_123', { apiVersion: '2024-06-20' });
export const WEBHOOK_SECRET = 'whsec_test_secret';
export function stripeWebhook(event: { id: string; type: string; data: { object: Record<string, unknown> } }) {
const payload = JSON.stringify({ object: 'event', api_version: '2024-06-20', created: Math.floor(Date.now() / 1000), ...event });
const header = stripe.webhooks.generateTestHeaderString({ payload, secret: WEBHOOK_SECRET });
return { payload, header };
}
Step 3 — Test fulfilment through the real endpoint. A completed session must mark the order paid and trigger fulfilment exactly once.
// src/routes/stripe-webhook.test.ts
import request from 'supertest';
import { test, expect, vi, beforeEach } from 'vitest';
import { app } from '../app';
import { stripeWebhook, WEBHOOK_SECRET } from '../../test/stripe/webhook';
beforeEach(() => vi.stubEnv('STRIPE_WEBHOOK_SECRET', WEBHOOK_SECRET));
const completed = (orderId: string, id = 'evt_1') => stripeWebhook({
id, type: 'checkout.session.completed',
data: { object: { id: 'cs_1', client_reference_id: orderId, payment_status: 'paid', amount_total: 4999 } },
});
test('marks the order paid and fulfils it', async () => {
const { payload, header } = completed('o1');
await request(app).post('/webhooks/stripe').set('stripe-signature', header)
.set('content-type', 'application/json').send(payload).expect(200);
expect(await orders.get('o1')).toMatchObject({ status: 'paid' });
expect(fulfilment.dispatched()).toEqual(['o1']);
});
Step 4 — Test the duplicate delivery. Stripe retries if your endpoint is slow or errors, so the same event arriving twice must not ship twice.
test('a redelivered completion does not fulfil twice', async () => {
for (let i = 0; i < 2; i++) {
const { payload, header } = completed('o2', 'evt_same');
await request(app).post('/webhooks/stripe').set('stripe-signature', header)
.set('content-type', 'application/json').send(payload).expect(200);
}
expect(fulfilment.dispatched()).toEqual(['o2']);
});
Step 5 — Cover asynchronous and failed payments. A session can complete with payment_status: 'unpaid' for delayed methods; the order must wait for the later success event rather than ship immediately.
test('a completed-but-unpaid session waits for the async success event', async () => {
const pending = stripeWebhook({
id: 'evt_a', type: 'checkout.session.completed',
data: { object: { id: 'cs_3', client_reference_id: 'o3', payment_status: 'unpaid' } },
});
await request(app).post('/webhooks/stripe').set('stripe-signature', pending.header)
.set('content-type', 'application/json').send(pending.payload).expect(200);
expect(await orders.get('o3')).toMatchObject({ status: 'awaiting_payment' });
expect(fulfilment.dispatched()).toEqual([]);
});
A pattern worth adopting while writing these tests is to key fulfilment on the order, not the event. Stripe can legitimately send two different events that both mean “this order is paid” — a completed session and a succeeded payment intent — and deduplicating on event identifiers alone lets both through. Recording that an order has been fulfilled, and checking that record before dispatching, makes the handler correct regardless of which combination of events arrives.
Step 6 — Run a nightly contract against test mode. A scheduled job with a genuine test-mode key creates a session, confirms it with a test card, and checks the webhook arrives — proving the adapter still matches Stripe’s current API.
# .github/workflows/stripe-contract.yml
on: { schedule: [{ cron: '0 4 * * *' }], workflow_dispatch: {} }
jobs:
contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx vitest run test/contract/stripe
env: { STRIPE_SECRET_KEY: '${{ secrets.STRIPE_TEST_KEY }}' }
Verification
Confirm no live key can be used by any test path. Run the suite with a live-looking key and expect the guard to stop it immediately.
STRIPE_SECRET_KEY=sk_live_fake npx vitest run src/adapters
# Error: Refusing to use a live Stripe key outside production
Then confirm the webhook tests exercise signature verification by corrupting the secret: set a different STRIPE_WEBHOOK_SECRET and every fulfilment test should fail with a 400. If any still passes, it is bypassing the endpoint’s verification.
Troubleshooting
Symptom: every webhook test fails signature verification. Diagnosis: a JSON body parser consumed the raw body before the Stripe route. Fix: mount express.raw on the webhook route before any global JSON middleware — Stripe’s verification needs the exact bytes it signed.
Symptom: stripe-mock returns success for requests that fail against real Stripe. Diagnosis: stripe-mock validates shape, not business rules — it will not reject an amount below the minimum charge or a currency your account does not support. Fix: that is the nightly test-mode job’s purpose; keep business-rule checks there rather than expecting the mock to enforce them.
Symptom: fulfilment runs before payment settles for some customers. Diagnosis: the handler checks for checkout.session.completed but not payment_status. Fix: fulfil only on paid, and handle checkout.session.async_payment_succeeded for delayed methods, as in Step 5.
Symptom: the nightly contract job is flaky. Diagnosis: it waits for webhooks with a fixed sleep, and delivery latency in test mode varies. Fix: poll the test endpoint for the expected event with a generous timeout, and treat a failure as a prompt to investigate rather than as a blocking red build.
FAQ
Is stripe-mock better than MSW for the adapter?
For Stripe specifically, yes, because it validates requests against Stripe’s real OpenAPI specification and returns fully-shaped objects. MSW is the better general tool when a vendor offers no such mock, and it remains useful here for scripting specific error responses that stripe-mock does not produce.
Should unit tests use the Stripe SDK at all?
No — business logic should depend on your own payments interface and a recording fake, as described in third-party SDK isolation. The SDK appears only in the adapter and its tests, and in the webhook signature helper.
How do I test disputes and refunds?
Refunds are an API call — test the adapter against stripe-mock and the business logic against the fake. Disputes arrive as webhooks days later; sign a charge.dispute.created event the same way as a completion and assert on what the handler does, such as flagging the order and notifying support.
What about Stripe’s test card numbers?
Use them in the nightly contract job and in end-to-end tests that drive the hosted checkout page. They trigger specific outcomes — declines, authentication challenges, insufficient funds — deterministically. They are irrelevant to unit and adapter tests, which never reach Stripe’s real card processing.
Related
- Back to Third-Party SDK Isolation
- Testing webhook handlers with signed payloads — the general webhook pattern this builds on.
- Stubbing email and notification providers — the confirmation email fulfilment sends.
- Estimating the cost of an escaped defect — why payment paths deserve this effort.