React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

stripetestingVitestPlaywrightmockingtest cardsStripe CLI

Testing Stripe Integration

Recipe

Test Stripe integrations using test mode API keys for real API calls, Stripe CLI for local webhook testing, mocked Stripe SDK for unit tests, and Playwright for end-to-end payment flows.

Test mode API keys:

# .env.test
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_test_...

Mock the Stripe SDK for unit tests:

// __mocks__/stripe.ts
import { vi } from "vitest";
 
const mockStripe = {
  checkout: {
    sessions: {
      create: vi.fn(),
      retrieve: vi.fn(),
    },
  },
  paymentIntents: {
    create: vi.fn(),
    retrieve: vi.fn(),
  },
  subscriptions: {
    create: vi.fn(),
    retrieve: vi.fn(),
    update: vi.fn(),
    cancel: vi.fn(),
  },
  customers: {
    create: vi.fn(),
    retrieve: vi.fn(),
  },
  billingPortal: {
    sessions: {
      create: vi.fn(),
    },
  },
  webhooks: {
    constructEvent: vi.fn(),
  },
};
 
export default vi.fn(() => mockStripe);
export { mockStripe };
// lib/__mocks__/stripe.ts
import { mockStripe } from "../../__mocks__/stripe";
export const stripe = mockStripe;

Working Example

Vitest tests for Stripe Server Actions:

// __tests__/actions/checkout.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { mockStripe } from "../../__mocks__/stripe";
 
// Mock the stripe module
vi.mock("@/lib/stripe", () => ({
  stripe: mockStripe,
}));
 
// Mock next/navigation
const mockRedirect = vi.fn();
vi.mock("next/navigation", () => ({
  redirect: mockRedirect,
}));
 
describe("createCheckoutSession", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
  });
 
  it("creates a checkout session and redirects", async () => {
    mockStripe.checkout.sessions.create.mockResolvedValue({
      id: "cs_test_123",
      url: "https://checkout.stripe.com/pay/cs_test_123",
    });
 
    // Import after mocks are set up
    const { createCheckoutSession } = await import(
      "@/app/actions/checkout"
    );
 
    await createCheckoutSession("price_test_123");
 
    expect(mockStripe.checkout.sessions.create).toHaveBeenCalledWith(
      expect.objectContaining({
        mode: "payment",
        line_items: [{ price: "price_test_123", quantity: 1 }],
        success_url: expect.stringContaining("/success"),
        cancel_url: expect.stringContaining("/pricing"),
      })
    );
 
    expect(mockRedirect).toHaveBeenCalledWith(
      "https://checkout.stripe.com/pay/cs_test_123"
    );
  });
 
  it("handles Stripe errors gracefully", async () => {
    mockStripe.checkout.sessions.create.mockRejectedValue(
      new Error("No such price: 'price_invalid'")
    );
 
    const { createCheckoutSession } = await import(
      "@/app/actions/checkout"
    );
 
    await expect(
      createCheckoutSession("price_invalid")
    ).rejects.toThrow();
  });
});

Testing PaymentIntent creation:

// __tests__/actions/payment-intent.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { mockStripe } from "../../__mocks__/stripe";
 
vi.mock("@/lib/stripe", () => ({
  stripe: mockStripe,
}));
 
describe("createPaymentIntent", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });
 
  it("creates a payment intent with correct amount", async () => {
    mockStripe.paymentIntents.create.mockResolvedValue({
      id: "pi_test_123",
      client_secret: "pi_test_123_secret_456",
      amount: 2999,
      currency: "usd",
      status: "requires_payment_method",
    });
 
    const { createPaymentIntent } = await import(
      "@/app/actions/payment"
    );
    const result = await createPaymentIntent(2999);
 
    expect(result.clientSecret).toBe("pi_test_123_secret_456");
    expect(mockStripe.paymentIntents.create).toHaveBeenCalledWith(
      expect.objectContaining({
        amount: 2999,
        currency: "usd",
        automatic_payment_methods: { enabled: true },
      })
    );
  });
});

Testing webhook handler:

// __tests__/api/webhook.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { mockStripe } from "../../__mocks__/stripe";
 
vi.mock("@/lib/stripe", () => ({
  stripe: mockStripe,
}));
 
const mockDb = {
  user: {
    update: vi.fn(),
    updateMany: vi.fn(),
  },
  purchase: {
    create: vi.fn(),
  },
};
 
vi.mock("@/lib/db", () => ({
  db: mockDb,
}));
 
describe("Stripe webhook handler", () => {
  beforeEach(() => {
    vi.clearAllMocks();
    process.env.STRIPE_WEBHOOK_SECRET = "whsec_test";
  });
 
  it("handles checkout.session.completed", async () => {
    const event = {
      id: "evt_test_123",
      type: "checkout.session.completed",
      data: {
        object: {
          id: "cs_test_123",
          mode: "subscription",
          customer: "cus_test_123",
          subscription: "sub_test_123",
          metadata: { userId: "user_123" },
          amount_total: 2900,
        },
      },
    };
 
    mockStripe.webhooks.constructEvent.mockReturnValue(event);
 
    const { POST } = await import(
      "@/app/api/webhooks/stripe/route"
    );
 
    const request = new Request("http://localhost:3000/api/webhooks/stripe", {
      method: "POST",
      body: JSON.stringify(event),
      headers: { "stripe-signature": "test_sig" },
    });
 
    const response = await POST(request);
    expect(response.status).toBe(200);
 
    const json = await response.json();
    expect(json.received).toBe(true);
  });
 
  it("rejects invalid signatures", async () => {
    mockStripe.webhooks.constructEvent.mockImplementation(() => {
      throw new Error("Invalid signature");
    });
 
    const { POST } = await import(
      "@/app/api/webhooks/stripe/route"
    );
 
    const request = new Request("http://localhost:3000/api/webhooks/stripe", {
      method: "POST",
      body: "{}",
      headers: { "stripe-signature": "invalid" },
    });
 
    const response = await POST(request);
    expect(response.status).toBe(400);
  });
});

Playwright E2E test for the checkout flow:

// e2e/checkout.spec.ts
import { test, expect } from "@playwright/test";
 
test.describe("Checkout flow", () => {
  test("completes a purchase with test card", async ({ page }) => {
    await page.goto("/shop");
    await page.click('button:has-text("Buy Now")');
 
    // Wait for Stripe Checkout redirect
    await page.waitForURL(/checkout\.stripe\.com/);
 
    // Fill in test card details on Stripe Checkout
    const emailInput = page.locator("#email");
    await emailInput.fill("test@example.com");
 
    const cardFrame = page.frameLocator("iframe[name*='card']").first();
 
    await cardFrame
      .locator('[placeholder="1234 1234 1234 1234"]')
      .fill("4242424242424242");
    await cardFrame
      .locator('[placeholder="MM / YY"]')
      .fill("12/34");
    await cardFrame
      .locator('[placeholder="CVC"]')
      .fill("123");
 
    // Fill billing name
    const nameInput = page.locator('#billingName');
    await nameInput.fill("Test User");
 
    // Submit payment
    await page.click('button:has-text("Pay")');
 
    // Wait for redirect back to success page
    await page.waitForURL(/\/success/);
    await expect(page.locator("h1")).toContainText("Payment Successful");
  });
 
  test("handles declined card", async ({ page }) => {
    await page.goto("/checkout");
 
    // Wait for the PaymentElement to load
    const stripeFrame = page.frameLocator(
      "iframe[name*='__privateStripeFrame']"
    ).first();
 
    await stripeFrame
      .locator('[placeholder="1234 1234 1234 1234"]')
      .fill("4000000000000002"); // Generic decline card
 
    await stripeFrame
      .locator('[placeholder="MM / YY"]')
      .fill("12/34");
    await stripeFrame
      .locator('[placeholder="CVC"]')
      .fill("123");
 
    await page.click('button:has-text("Pay")');
 
    // Expect error message
    await expect(page.locator('[role="alert"]')).toContainText("declined");
  });
});

Deep Dive

How It Works

  • Stripe's test mode is a complete sandbox environment. All API calls with sk_test_ keys create test data that does not result in real charges.
  • Test card numbers trigger specific behaviors (success, decline, 3D Secure) without involving real card networks.
  • The Stripe CLI (stripe listen --forward-to) creates a tunnel that forwards webhook events from Stripe's test environment to your local development server.
  • Mocking the Stripe SDK in Vitest isolates your business logic from the Stripe API. Tests run quickly and do not require network access.
  • Playwright tests against Stripe Checkout interact with real Stripe-hosted pages in test mode. These are slower but test the full integration.

Variations

Stripe CLI for triggering specific events:

# Trigger a specific event
stripe trigger checkout.session.completed
 
# Trigger with custom data
stripe trigger payment_intent.succeeded \
  --add payment_intent:metadata.userId=user_123
 
# Listen and forward to a specific endpoint
stripe listen --forward-to localhost:3000/api/webhooks/stripe \
  --events checkout.session.completed,invoice.paid
 
# Replay a specific event from your Dashboard
stripe events resend evt_xxx

Testing subscription lifecycle:

describe("Subscription lifecycle", () => {
  it("handles subscription cancellation", async () => {
    const mockUser = {
      id: "user_123",
      stripeSubscriptionId: "sub_test_123",
    };
 
    mockStripe.subscriptions.update.mockResolvedValue({
      id: "sub_test_123",
      cancel_at_period_end: true,
      status: "active",
    });
 
    vi.mock("@/lib/auth", () => ({
      auth: vi.fn().mockResolvedValue({
        user: { id: "user_123" },
      }),
    }));
 
    vi.mock("@/lib/db", () => ({
      db: {
        user: {
          findUnique: vi.fn().mockResolvedValue(mockUser),
        },
      },
    }));
 
    const { cancelSubscriptionAction } = await import(
      "@/app/actions/subscription"
    );
    const result = await cancelSubscriptionAction();
 
    expect(result).toEqual({ success: true });
    expect(mockStripe.subscriptions.update).toHaveBeenCalledWith(
      "sub_test_123",
      { cancel_at_period_end: true }
    );
  });
});

TypeScript Notes

  • When mocking Stripe, type your mocks as Partial<Stripe> or use vi.fn() with explicit return types to match the expected Stripe API responses.
  • Stripe event objects in tests should match the Stripe.Event interface. Use real event shapes from the Stripe CLI output as reference.
import type Stripe from "stripe";
 
function createMockEvent(
  type: string,
  data: Record<string, unknown>
): Stripe.Event {
  return {
    id: `evt_test_${Date.now()}`,
    type,
    data: { object: data },
    object: "event",
    api_version: "2024-12-18.acacia",
    created: Math.floor(Date.now() / 1000),
    livemode: false,
    pending_webhooks: 0,
    request: null,
  } as unknown as Stripe.Event;
}

Test Card Numbers

Card NumberBehavior
4242 4242 4242 4242Succeeds
4000 0000 0000 32203D Secure 2 authentication required
4000 0025 0000 31553D Secure required on setup
4000 0000 0000 9995Declined: insufficient funds
4000 0000 0000 0002Declined: generic decline
4000 0000 0000 0069Declined: expired card
4000 0000 0000 0127Declined: incorrect CVC
4000 0000 0000 0119Declined: processing error
4000 0000 0000 0101CVC check fails
4000 0000 0000 30553D Secure required, completes successfully
4000 0000 0000 30633D Secure required, authentication fails
5555 5555 5555 4444Mastercard: succeeds
3782 822463 10005Amex: succeeds

Use any future expiration (e.g., 12/34) and any 3-digit CVC (4 digits for Amex).

Gotchas

  • Test mode and live mode are completely separate environments. Products, customers, and subscriptions created in test mode do not exist in live mode.
  • Stripe CLI webhook signing secrets start with whsec_ and are different from your Dashboard webhook secret. Use the CLI-provided secret during local development.
  • Playwright tests against Stripe Checkout pages can be flaky because Stripe's UI may change. Prefer testing your own UI with mocked Stripe calls.
  • Stripe Elements render inside iframes. In Playwright, you must use frameLocator to interact with them.
  • vi.mock in Vitest is hoisted to the top of the file. Dynamic imports (await import(...)) inside tests ensure the module loads with mocks in place.
  • Do not commit test API keys to version control. Use .env.test (gitignored) or CI/CD environment variables.
  • Stripe rate-limits test mode API calls at 25 requests per second (lower than live mode). Parallelize test suites carefully.

Alternatives

ApproachProsCons
Vitest with mocked StripeFast, isolated, no network neededDoes not test real Stripe behavior
Stripe test mode (real API)Tests actual integrationSlower, needs network, rate limits
Stripe CLI (webhooks)Tests real webhook flow locallyRequires CLI setup
Playwright E2ETests full user journeySlow, flaky with Stripe iframes
MSW (Mock Service Worker)Intercepts HTTP, realistic mockingMore setup than vi.mock

FAQs

What is the difference between test mode API keys and live mode API keys?
  • Test mode keys start with sk_test_ and pk_test_; live keys start with sk_live_ and pk_live_.
  • Test mode is a complete sandbox -- no real charges occur.
  • Products, customers, and subscriptions in test mode do not exist in live mode.
How do you mock the Stripe SDK for Vitest unit tests?
  • Create a __mocks__/stripe.ts file that exports a mock object with vi.fn() for each method.
  • Use vi.mock("@/lib/stripe", () => ({ stripe: mockStripe })) in your test file.
  • Import modules with await import(...) after mocks are set up so mocks take effect.
Why use dynamic imports (await import) inside test cases?
  • vi.mock is hoisted to the top of the file, but the module under test may import Stripe at load time.
  • Dynamic imports ensure the module loads after mocks are in place.
  • This prevents the real Stripe client from being instantiated during tests.
How do you test webhook signature verification?
// Valid signature
mockStripe.webhooks.constructEvent.mockReturnValue(event);
 
// Invalid signature
mockStripe.webhooks.constructEvent.mockImplementation(() => {
  throw new Error("Invalid signature");
});
  • Mock constructEvent to return the event or throw.
  • Verify the route returns 200 for valid and 400 for invalid signatures.
How does the Stripe CLI help with local webhook testing?
stripe listen --forward-to localhost:3000/api/webhooks/stripe \
  --events checkout.session.completed,invoice.paid
  • It creates a tunnel forwarding Stripe test events to your local server.
  • You can trigger specific events with stripe trigger checkout.session.completed.
  • The CLI provides its own whsec_ signing secret for local development.
What test card number triggers a generic decline, and what triggers 3D Secure?
  • 4000 0000 0000 0002 triggers a generic decline.
  • 4000 0000 0000 3220 triggers 3D Secure 2 authentication.
  • 4242 4242 4242 4242 always succeeds.
Gotcha: Why are Playwright tests against Stripe Checkout pages often flaky?
  • Stripe's hosted UI may change without notice, breaking selectors.
  • Stripe Elements render inside iframes, requiring frameLocator to interact.
  • Prefer testing your own UI with mocked Stripe calls for reliability.
Gotcha: What is the test mode rate limit and how can it affect your test suite?
  • Stripe rate-limits test mode at 25 requests per second (lower than live mode's 100).
  • Highly parallelized test suites can hit this limit and receive rate_limit_error.
  • Use mocked Stripe for most tests and reserve real API calls for a small integration suite.
How should you type a mock Stripe event in TypeScript?
import type Stripe from "stripe";
 
function createMockEvent(
  type: string,
  data: Record<string, unknown>
): Stripe.Event {
  return {
    id: `evt_test_${Date.now()}`,
    type,
    data: { object: data },
    object: "event",
    api_version: "2024-12-18.acacia",
    created: Math.floor(Date.now() / 1000),
    livemode: false,
    pending_webhooks: 0,
    request: null,
  } as unknown as Stripe.Event;
}
How do you type Stripe mocks with vi.fn() to match expected return types?
  • Use vi.fn() without explicit generics -- Vitest infers the type from mock return values.
  • When asserting, use mockResolvedValue with objects matching the relevant Stripe.* interface.
  • Cast to Partial<Stripe> if you need a partial mock of the full client.
What is the difference between the Stripe CLI webhook secret and the Dashboard webhook secret?
  • The CLI secret is generated locally when you run stripe listen and starts with whsec_.
  • The Dashboard secret is configured in Stripe's webhook settings for your deployed endpoint.
  • Use the CLI secret in .env.test for local development only.
How do you interact with Stripe Elements inside iframes in Playwright?
const stripeFrame = page.frameLocator(
  "iframe[name*='__privateStripeFrame']"
).first();
 
await stripeFrame
  .locator('[placeholder="1234 1234 1234 1234"]')
  .fill("4242424242424242");
  • Use page.frameLocator() with a selector matching the Stripe iframe.
  • All interactions with card inputs must go through the frame locator.