Testing Stripe Billing Integration: Subscriptions, Webhooks, and Metered Usage

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 login

Test 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.completed

Testing 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 stripe SDK, 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 webhooksstripe listen + stripe trigger for real end-to-end event flow

Read more