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_xxxTesting 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 usevi.fn()with explicit return types to match the expected Stripe API responses. - Stripe event objects in tests should match the
Stripe.Eventinterface. 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 Number | Behavior |
|---|---|
| 4242 4242 4242 4242 | Succeeds |
| 4000 0000 0000 3220 | 3D Secure 2 authentication required |
| 4000 0025 0000 3155 | 3D Secure required on setup |
| 4000 0000 0000 9995 | Declined: insufficient funds |
| 4000 0000 0000 0002 | Declined: generic decline |
| 4000 0000 0000 0069 | Declined: expired card |
| 4000 0000 0000 0127 | Declined: incorrect CVC |
| 4000 0000 0000 0119 | Declined: processing error |
| 4000 0000 0000 0101 | CVC check fails |
| 4000 0000 0000 3055 | 3D Secure required, completes successfully |
| 4000 0000 0000 3063 | 3D Secure required, authentication fails |
| 5555 5555 5555 4444 | Mastercard: succeeds |
| 3782 822463 10005 | Amex: 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
frameLocatorto interact with them. vi.mockin 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
| Approach | Pros | Cons |
|---|---|---|
| Vitest with mocked Stripe | Fast, isolated, no network needed | Does not test real Stripe behavior |
| Stripe test mode (real API) | Tests actual integration | Slower, needs network, rate limits |
| Stripe CLI (webhooks) | Tests real webhook flow locally | Requires CLI setup |
| Playwright E2E | Tests full user journey | Slow, flaky with Stripe iframes |
| MSW (Mock Service Worker) | Intercepts HTTP, realistic mocking | More 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_andpk_test_; live keys start withsk_live_andpk_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.tsfile that exports a mock object withvi.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.mockis 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
constructEventto 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 0002triggers a generic decline.4000 0000 0000 3220triggers 3D Secure 2 authentication.4242 4242 4242 4242always 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
frameLocatorto 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
mockResolvedValuewith objects matching the relevantStripe.*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 listenand starts withwhsec_. - The Dashboard secret is configured in Stripe's webhook settings for your deployed endpoint.
- Use the CLI secret in
.env.testfor 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.