Testing Stripe Billing Integration: Subscriptions, Webhooks, and Metered Usage
Billing is where bugs have the most direct financial impact. Overcharging customers, webhooks that fail silently, or subscriptions that don't cancel properly — these bugs erode trust fast. This guide covers how to test your Stripe billing integration systematically using Stripe's test mode, the Stripe CLI for webhook testing, and Jest for unit and integration tests.
Setup
npm install stripe
npm install -D jest stripe # stripe types included
<span class="hljs-comment"># Install Stripe CLI for webhook testing
brew install stripe/stripe-cli/stripe
stripe loginTest Mode vs Production
All tests run against Stripe's test mode. Key test cards:
| Card number | Behavior |
|---|---|
4242 4242 4242 4242 |
Successful payment |
4000 0000 0000 0002 |
Card declined |
4000 0025 0000 3155 |
3D Secure required |
4000 0000 0000 9995 |
Insufficient funds |
Unit Testing: Billing Logic
Extract billing logic from Stripe SDK calls so you can unit test it:
// services/billing.ts
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2023-10-16' })
export async function createSubscription(
customerId: string,
priceId: string
): Promise<{ subscriptionId: string; status: string }> {
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
payment_behavior: 'default_incomplete',
payment_settings: { save_default_payment_method: 'on_subscription' },
expand: ['latest_invoice.payment_intent'],
})
return {
subscriptionId: subscription.id,
status: subscription.status,
}
}
export async function cancelSubscription(subscriptionId: string): Promise<void> {
await stripe.subscriptions.cancel(subscriptionId)
}
export async function reportUsage(
subscriptionItemId: string,
quantity: number,
timestamp: number
): Promise<void> {
await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
quantity,
timestamp,
action: 'increment',
})
}// services/billing.test.ts
import Stripe from 'stripe'
import { createSubscription, cancelSubscription, reportUsage } from './billing'
jest.mock('stripe')
const mockStripe = {
subscriptions: {
create: jest.fn(),
cancel: jest.fn(),
},
subscriptionItems: {
createUsageRecord: jest.fn(),
},
}
;(Stripe as jest.MockedClass<typeof Stripe>).mockImplementation(
() => mockStripe as any
)
describe('createSubscription', () => {
it('creates a subscription with correct parameters', async () => {
mockStripe.subscriptions.create.mockResolvedValue({
id: 'sub_test_123',
status: 'incomplete',
})
const result = await createSubscription('cus_test', 'price_monthly_pro')
expect(mockStripe.subscriptions.create).toHaveBeenCalledWith(
expect.objectContaining({
customer: 'cus_test',
items: [{ price: 'price_monthly_pro' }],
})
)
expect(result).toEqual({ subscriptionId: 'sub_test_123', status: 'incomplete' })
})
it('propagates Stripe errors', async () => {
mockStripe.subscriptions.create.mockRejectedValue(
new Stripe.errors.StripeCardError({
message: 'Your card was declined.',
type: 'card_error',
code: 'card_declined',
} as any)
)
await expect(createSubscription('cus_bad', 'price_pro')).rejects.toThrow(
'Your card was declined.'
)
})
})
describe('reportUsage', () => {
it('reports incremental usage', async () => {
mockStripe.subscriptionItems.createUsageRecord.mockResolvedValue({})
const timestamp = Math.floor(Date.now() / 1000)
await reportUsage('si_test', 100, timestamp)
expect(mockStripe.subscriptionItems.createUsageRecord).toHaveBeenCalledWith(
'si_test',
{ quantity: 100, timestamp, action: 'increment' }
)
})
})Webhook Testing
Webhooks are where most billing integrations break. Test the webhook handler logic directly, and use the Stripe CLI to test end-to-end:
// webhooks/stripeWebhookHandler.ts
import Stripe from 'stripe'
import { db } from '../db'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2023-10-16' })
export async function handleStripeWebhook(
payload: string,
signature: string
): Promise<void> {
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
payload,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
throw new Error(`Webhook signature verification failed: ${(err as Error).message}`)
}
switch (event.type) {
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription
await db.subscriptions.updateStatus(subscription.id, subscription.status)
break
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription
await db.subscriptions.cancel(subscription.id)
await db.tenants.downgradeToFree(subscription.metadata.tenantId)
break
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice
await db.tenants.setPaymentFailed(invoice.customer as string)
break
}
}
}// webhooks/stripeWebhookHandler.test.ts
import Stripe from 'stripe'
import { handleStripeWebhook } from './stripeWebhookHandler'
import { db } from '../db'
jest.mock('../db')
jest.mock('stripe')
const mockDb = db as jest.Mocked<typeof db>
// Helper to create a fake Stripe event payload
function makeStripeEvent(type: string, data: object): { payload: string; signature: string } {
const event = {
id: 'evt_test_123',
type,
data: { object: data },
created: Math.floor(Date.now() / 1000),
}
// Mock the signature verification
jest.spyOn(Stripe.prototype.webhooks, 'constructEvent').mockReturnValue(event as any)
return {
payload: JSON.stringify(event),
signature: 'test-signature',
}
}
describe('handleStripeWebhook', () => {
beforeEach(() => jest.clearAllMocks())
it('updates subscription status on subscription.updated', async () => {
mockDb.subscriptions.updateStatus.mockResolvedValue(undefined)
const { payload, signature } = makeStripeEvent('customer.subscription.updated', {
id: 'sub_123',
status: 'past_due',
})
await handleStripeWebhook(payload, signature)
expect(mockDb.subscriptions.updateStatus).toHaveBeenCalledWith('sub_123', 'past_due')
})
it('cancels subscription and downgrades tenant on subscription.deleted', async () => {
mockDb.subscriptions.cancel.mockResolvedValue(undefined)
mockDb.tenants.downgradeToFree.mockResolvedValue(undefined)
const { payload, signature } = makeStripeEvent('customer.subscription.deleted', {
id: 'sub_456',
status: 'canceled',
metadata: { tenantId: 'tenant-1' },
})
await handleStripeWebhook(payload, signature)
expect(mockDb.subscriptions.cancel).toHaveBeenCalledWith('sub_456')
expect(mockDb.tenants.downgradeToFree).toHaveBeenCalledWith('tenant-1')
})
it('marks payment failed on invoice.payment_failed', async () => {
mockDb.tenants.setPaymentFailed.mockResolvedValue(undefined)
const { payload, signature } = makeStripeEvent('invoice.payment_failed', {
id: 'in_789',
customer: 'cus_123',
})
await handleStripeWebhook(payload, signature)
expect(mockDb.tenants.setPaymentFailed).toHaveBeenCalledWith('cus_123')
})
it('throws on invalid webhook signature', async () => {
jest.spyOn(Stripe.prototype.webhooks, 'constructEvent').mockImplementation(() => {
throw new Error('No signatures found')
})
await expect(handleStripeWebhook('payload', 'bad-sig')).rejects.toThrow(
'Webhook signature verification failed'
)
})
})Integration Testing with Stripe CLI
For real end-to-end webhook testing, use the Stripe CLI to forward events to your local server:
# In one terminal: forward Stripe events to your local server
stripe listen --forward-to localhost:3000/webhooks/stripe
<span class="hljs-comment"># In another terminal: trigger a specific event
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failed
stripe trigger checkout.session.completedTesting Subscription State Machine
Test all subscription status transitions:
describe('Subscription status transitions', () => {
const validTransitions = [
{ from: 'incomplete', to: 'active', event: 'invoice.payment_succeeded' },
{ from: 'active', to: 'past_due', event: 'invoice.payment_failed' },
{ from: 'past_due', to: 'canceled', event: 'customer.subscription.deleted' },
{ from: 'active', to: 'canceled', event: 'customer.subscription.deleted' },
]
test.each(validTransitions)(
'transitions from $from to $to on $event',
async ({ from, to, event }) => {
const subscription = await db.subscriptions.create({ status: from })
const { payload, signature } = makeStripeEvent(event, {
id: subscription.id,
status: to,
metadata: { tenantId: 'tenant-test' },
})
await handleStripeWebhook(payload, signature)
const updated = await db.subscriptions.findById(subscription.id)
expect(updated.status).toBe(to)
}
)
})Testing Metered Usage Billing
describe('Metered usage reporting', () => {
it('accumulates usage correctly across the billing period', async () => {
// Report usage in three batches
const subscriptionItemId = 'si_test'
const baseTimestamp = Math.floor(Date.now() / 1000)
await reportUsage(subscriptionItemId, 100, baseTimestamp)
await reportUsage(subscriptionItemId, 250, baseTimestamp + 3600)
await reportUsage(subscriptionItemId, 50, baseTimestamp + 7200)
// Verify all three records were created
expect(mockStripe.subscriptionItems.createUsageRecord).toHaveBeenCalledTimes(3)
const calls = mockStripe.subscriptionItems.createUsageRecord.mock.calls
const totalQuantity = calls.reduce((sum: number, call: any[]) => sum + call[1].quantity, 0)
expect(totalQuantity).toBe(400)
})
})What Automated Tests Miss
Unit and webhook tests cover logic but won't catch:
- Webhook delivery failures from network timeouts between Stripe and your server
- Idempotency key collisions when retried events are processed twice
- Stripe API rate limiting under high invoice volume
- Clock drift causing usage records to land in the wrong billing period
HelpMeTest runs scheduled end-to-end tests of your complete billing flow — from upgrade button click to subscription confirmation screen — in a real browser with Stripe test mode cards. Pro plan at $100/month with unlimited tests.
Summary
Stripe billing testing at every layer:
- Unit tests — mock
stripeSDK, test handler logic for each event type - Signature verification — test that invalid signatures are rejected
- Status machine — parameterize tests across all valid subscription transitions
- Metered usage — verify incremental records are created with correct quantities
- CLI webhooks —
stripe listen+stripe triggerfor real end-to-end event flow