Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 055 / 100

Testing payment integrations: what developers should know

Confidence without real money.

Expona Admin8 min read

Testing payment integrations is where theory meets reality. Most teams will tell you the moment they went live to production, they discovered edge cases that no amount of manual clicking could have caught. Testing payment integrations: what developers should know will help you build confidence without risking real money.

Understanding the test environment landscape

Before diving into specific test techniques, it's worth understanding what Stripe (and most payment processors) offer. Stripe's test mode gives you a complete replica of the production environment: same API endpoints, same behavior, same rate limits. The only difference is no real transactions occur and no money changes hands. This is your sandbox, and it should be treated as seriously as production.

Your CI/CD pipeline should run tests in this environment automatically. Your local development should never touch production credentials. And every team member needs access to the same test account so you can compare results and debug together.

Test cards: beyond "4242424242424242"

Everyone knows the basic test card number. But Stripe's test card suite covers dozens of scenarios, and your test suite should exercise all the critical ones. Test cards aren't just about success and failure—they simulate real-world payment behavior your customers will experience.

Start with the fundamentals. Card 4242424242424242 always succeeds with any future expiry and any CVC. This is your baseline happy path. But that's only the beginning.

  • Decline scenarios: Card 4000000000000002 always declines with error code card_declined. Use this to test your decline handler, error messaging, and retry logic.
  • Insufficient funds: Card 4000000000009995 declines with insufficient_funds. Your billing retry strategy should handle this differently than a lost card.
  • 3D Secure: Card 4000002500003155 requires authentication. During testing, you'll be prompted to complete a challenge. This tests the full redirect flow critical for European payments.
  • Expired card: Use expiry date 01/25 (or any past date) to test recovery flows when saved cards expire.
  • CVC mismatch: Any card with CVC 999 will decline with incorrect_cvc.

Your integration tests should cycle through these scenarios programmatically. Here's a practical example:

typescript
describe('Payment processing', () => {
  it('should handle successful charge', async () => {
    const response = await stripe.paymentIntents.create({
      amount: 2000,
      currency: 'usd',
      payment_method: 'pm_card_visa',
      confirm: true,
    });
    expect(response.status).toBe('succeeded');
  });

  it('should handle card decline', async () => {
    const response = await stripe.paymentIntents.create({
      amount: 2000,
      currency: 'usd',
      payment_method: 'pm_card_visa_chargeDeclined',
      confirm: true,
    });
    expect(response.status).toBe('requires_payment_method');
    expect(response.last_payment_error?.code).toBe('card_declined');
  });

  it('should handle insufficient funds', async () => {
    const response = await stripe.paymentIntents.create({
      amount: 2000,
      currency: 'usd',
      payment_method: 'pm_card_visa_insufficientFunds',
      confirm: true,
    });
    expect(response.last_payment_error?.code).toBe('insufficient_funds');
  });

  it('should require 3D Secure authentication', async () => {
    const response = await stripe.paymentIntents.create({
      amount: 2000,
      currency: 'usd',
      payment_method: 'pm_card_threeDSecure_required',
      confirm: true,
    });
    expect(response.status).toBe('requires_action');
  });
});

Each test card maps to a permanent payment method ID in test mode, making them predictable and repeatable. This repeatability is essential—your tests should produce the same results every single time.

Test clocks: simulating time-dependent scenarios

Subscription billing doesn't work without time. Trial periods expire, renewals trigger, and dunning cycles progress. You can't wait 30 days to verify renewal logic works correctly. This is where Stripe's test clocks come in.

Test clocks let you advance the billing clock forward in test mode. Create a clock, associate resources with it, and simulate weeks or months of billing cycles in seconds. This is critical for testing subscription renewal, trial expiry, and retry schedules.

Here's how to set one up:

typescript
// Create a test clock for this test scenario
const testClock = await stripe.testHelpers.testClocks.create({
  frozen_time: Math.floor(Date.now() / 1000),
});

// Create a subscription using this clock
const subscription = await stripe.subscriptions.create(
  {
    customer: customerId,
    items: [{ price: 'price_monthly' }],
    trial_period_days: 14,
  },
  {
    stripeContext: { testClock: testClock.id },
  }
);

// Verify subscription is in trial
expect(subscription.status).toBe('trialing');

// Advance time to trial end + 1 day
await stripe.testHelpers.testClocks.advance(testClock.id, {
  frozen_time: testClock.frozen_time + 15 * 24 * 60 * 60,
});

// Query subscription—it should now have renewed or be pending payment
const updatedSubscription = await stripe.subscriptions.retrieve(subscription.id, {
  stripeContext: { testClock: testClock.id },
});
expect(updatedSubscription.status).toBe('active');

This approach lets you build confidence in your renewal logic without slow, flaky time-dependent tests. Every test can run in parallel; each gets its own clock.

Webhook tests: replaying real event payloads

Webhooks are asynchronous. Your API receives the charge request, returns immediately, then Stripe delivers an event to your webhook endpoint moments later. Testing this requires careful orchestration, and many teams get it wrong.

The naive approach: mock the webhook in your test. The better approach: use Stripe's webhook testing API to replay actual event payloads. This tests your entire webhook handler, not just the business logic.

Stripe's webhook test events API lets you send real events to your endpoint:

typescript
it('should handle charge.succeeded webhook', async () => {
  // First, create a real charge in test mode
  const charge = await stripe.charges.create({
    amount: 2000,
    currency: 'usd',
    source: 'tok_visa',
  });

  // Now send the actual webhook event
  const event = await stripe.events.retrieve('evt_test_...'); // Or construct one

  // Post to your webhook endpoint
  const response = await fetch('http://localhost:3000/webhooks/stripe', {
    method: 'POST',
    headers: {
      'stripe-signature': generateStripeSignature(event),
    },
    body: JSON.stringify(event),
  });

  expect(response.status).toBe(200);

  // Verify your app's state changed correctly
  const order = await db.orders.findOne({ chargeId: charge.id });
  expect(order.status).toBe('paid');
});

Critical detail: you must validate the webhook signature. Stripe's SDKs provide helper functions. Never trust an unsigned webhook in testing or production.

typescript
import Stripe from 'stripe';

function handleWebhook(req: Request) {
  const sig = req.headers['stripe-signature'] as string;
  const event = Stripe.webhooks.constructEvent(
    req.body,
    sig,
    process.env.STRIPE_WEBHOOK_SECRET
  );
  // Now you can trust event.type, event.data, etc.
}

Your test should verify signature validation works and rejects tampered payloads. Build a small library of realistic event payloads and replay them in isolation.

End-to-end testing with Playwright

Unit and integration tests catch most issues. But full end-to-end tests through your Checkout flow catch UX bugs, form validation problems, and redirect mishaps that isolated tests miss.

Playwright (or Cypress) lets you automate browser interaction with your payment page. In test mode, you can complete real payment flows without risking money:

typescript
import { test, expect } from '@playwright/test';

test('should complete checkout successfully', async ({ page }) => {
  // Navigate to checkout
  await page.goto('http://localhost:3000/checkout');

  // Fill in email
  await page.fill('input[name="email"]', 'customer@example.com');

  // Use Stripe Elements or Checkout
  // If using Checkout hosted by Stripe, you'll be redirected
  // If using Elements, interact with the iframe
  const frameHandle = await page.$('iframe[title="Stripe card element"]');
  const frame = await frameHandle.contentFrame();
  
  await frame.fill('input[name="cardnumber"]', '4242424242424242');
  await frame.fill('input[name="exp-date"]', '12/25');
  await frame.fill('input[name="cvc"]', '123');

  // Submit payment
  await page.click('button:has-text("Pay now")');

  // Verify success page
  await page.waitForNavigation();
  expect(page.url()).toContain('success');
  expect(await page.textContent('h1')).toContain('Payment successful');
});

test('should handle card decline gracefully', async ({ page }) => {
  await page.goto('http://localhost:3000/checkout');

  // Use declined card
  const frame = await page.frameLocator('iframe[title="Stripe card element"]');
  await frame.locator('input[name="cardnumber"]').fill('4000000000000002');
  await frame.locator('input[name="exp-date"]').fill('12/25');
  await frame.locator('input[name="cvc"]').fill('123');

  await page.click('button:has-text("Pay now")');

  // Error should appear, not redirect
  expect(await page.textContent('.error-message')).toContain('card was declined');
});

Run these tests against your staging environment pointing to Stripe test mode. They're slower than unit tests but catch integration problems that faster tests miss.

Common mistakes

  • Testing only the happy path: Your code works when payments succeed. Build confidence by testing every decline scenario systematically.
  • Hardcoding customer IDs: Each test should create its own isolated resources. Shared state makes tests flaky and hard to debug.
  • Ignoring webhook signature validation: In test mode, it's tempting to skip verification. Don't. Test it every time.
  • Not cleaning up test resources: Orphaned test customers and subscriptions clutter your Stripe dashboard. Clean them up in your test teardown.
  • Mocking Stripe entirely: A mock tells you your code works with the mock. Real Stripe tells you your code works with reality. Use test mode, not mocks.
  • Assuming test mode mirrors production: It doesn't perfectly. Features, rate limits, and behaviors differ. Always verify critical flows in a production-like environment before launch.

Key takeaways

  • Test cards cover success, decline, 3D Secure, and edge cases—use them all, not just the basic success path.
  • Test clocks let you simulate subscription renewals and trial expiry without waiting days—invaluable for subscription logic.
  • Replay real webhook payloads with signature validation in unit tests to catch deserialization and business logic bugs.
  • End-to-end tests through Playwright catch UX and redirect issues that isolated tests miss—run them regularly.
  • Keep test mode separate from production with environment variables and secrets management—no exceptions.
  • Test the entire flow, not just the happy path—decline handling and error messaging matter as much as success.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles