Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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;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");
});
});sk_test_ keys create test data that does not result in real charges.stripe listen --forward-to) creates a tunnel that forwards webhook events from Stripe's test environment to your local development server.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 }
);
});
});Partial<Stripe> or use vi.fn() with explicit return types to match the expected Stripe API responses.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;
}| 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).
whsec_ and are different from your Dashboard webhook secret. Use the CLI-provided secret during local development.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..env.test (gitignored) or CI/CD environment variables.| 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 |
sk_test_ and pk_test_; live keys start with sk_live_ and pk_live_.__mocks__/stripe.ts file that exports a mock object with vi.fn() for each method.vi.mock("@/lib/stripe", () => ({ stripe: mockStripe })) in your test file.await import(...) after mocks are set up so mocks take effect.vi.mock is hoisted to the top of the file, but the module under test may import Stripe at load time.// Valid signature
mockStripe.webhooks.constructEvent.mockReturnValue(event);
// Invalid signature
mockStripe.webhooks.constructEvent.mockImplementation(() => {
throw new Error("Invalid signature");
});constructEvent to return the event or throw.stripe listen --forward-to localhost:3000/api/webhooks/stripe \
--events checkout.session.completed,invoice.paidstripe trigger checkout.session.completed.whsec_ signing secret for local development.4000 0000 0000 0002 triggers a generic decline.4000 0000 0000 3220 triggers 3D Secure 2 authentication.4242 4242 4242 4242 always succeeds.frameLocator to interact.rate_limit_error.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;
}vi.fn() without explicit generics -- Vitest infers the type from mock return values.mockResolvedValue with objects matching the relevant Stripe.* interface.Partial<Stripe> if you need a partial mock of the full client.stripe listen and starts with whsec_..env.test for local development only.const stripeFrame = page.frameLocator(
"iframe[name*='__privateStripeFrame']"
).first();
await stripeFrame
.locator('[placeholder="1234 1234 1234 1234"]')
.fill("4242424242424242");page.frameLocator() with a selector matching the Stripe iframe.Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥