Probar la integración con Stripe
Receta
Prueba integraciones con Stripe usando claves API de modo test para llamadas reales a la API, Stripe CLI para probar webhooks localmente, el SDK de Stripe simulado para tests unitarios y Playwright para flujos de pago de extremo a extremo.
Claves API de modo test:
# .env.test
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_test_...Simula el SDK de Stripe para tests unitarios:
// __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;Ejemplo funcional
Tests de Vitest para Server Actions de Stripe:
// __tests__/actions/checkout.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { mockStripe } from "../../__mocks__/stripe";
// Simula el módulo stripe
vi.mock("@/lib/stripe", () => ({
stripe: mockStripe,
}));
// Simula 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",
});
// Importa después de configurar los mocks
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();
});
});Probar la creación de PaymentIntent:
// __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 },
})
);
});
});Probar el manejador de webhooks:
// __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);
});
});Test E2E de Playwright para el flujo de checkout:
// 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")');
// Espera la redirección a Stripe Checkout
await page.waitForURL(/checkout\.stripe\.com/);
// Rellena los datos de la tarjeta de prueba en 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");
// Rellena el nombre de facturación
const nameInput = page.locator('#billingName');
await nameInput.fill("Test User");
// Envía el pago
await page.click('button:has-text("Pay")');
// Espera la redirección de vuelta a la página de éxito
await page.waitForURL(/\/success/);
await expect(page.locator("h1")).toContainText("Payment Successful");
});
test("handles declined card", async ({ page }) => {
await page.goto("/checkout");
// Espera a que cargue el PaymentElement
const stripeFrame = page.frameLocator(
"iframe[name*='__privateStripeFrame']"
).first();
await stripeFrame
.locator('[placeholder="1234 1234 1234 1234"]')
.fill("4000000000000002"); // Tarjeta de rechazo genérico
await stripeFrame
.locator('[placeholder="MM / YY"]')
.fill("12/34");
await stripeFrame
.locator('[placeholder="CVC"]')
.fill("123");
await page.click('button:has-text("Pay")');
// Espera el mensaje de error
await expect(page.locator('[role="alert"]')).toContainText("declined");
});
});Profundización
Cómo funciona
- El modo test de Stripe es un entorno sandbox completo. Todas las llamadas a la API con claves
sk_test_crean datos de prueba que no generan cargos reales. - Los números de tarjeta de prueba activan comportamientos específicos (éxito, rechazo, 3D Secure) sin involucrar redes de tarjetas reales.
- Stripe CLI (
stripe listen --forward-to) crea un túnel que reenvía eventos de webhook desde el entorno de prueba de Stripe a tu servidor de desarrollo local. - Simular el SDK de Stripe en Vitest aísla tu lógica de negocio de la API de Stripe. Los tests se ejecutan rápido y no requieren acceso a la red.
- Los tests de Playwright contra Stripe Checkout interactúan con páginas reales alojadas por Stripe en modo test. Son más lentos, pero prueban la integración completa.
Variaciones
Stripe CLI para activar eventos específicos:
# Activa un evento específico
stripe trigger checkout.session.completed
# Activa con datos personalizados
stripe trigger payment_intent.succeeded \
--add payment_intent:metadata.userId=user_123
# Escucha y reenvía a un endpoint específico
stripe listen --forward-to localhost:3000/api/webhooks/stripe \
--events checkout.session.completed,invoice.paid
# Reenvía un evento específico desde tu Dashboard
stripe events resend evt_xxxProbar el ciclo de vida de una suscripción:
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 }
);
});
});Notas de TypeScript
- Al simular Stripe, tipa tus mocks como
Partial<Stripe>o usavi.fn()con tipos de retorno explícitos para que coincidan con las respuestas esperadas de la API de Stripe. - Los objetos de evento de Stripe en los tests deben coincidir con la interfaz
Stripe.Event. Usa formas de evento reales de la salida de Stripe CLI como referencia.
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;
}Números de tarjeta de prueba
| Número de tarjeta | Comportamiento |
|---|---|
| 4242 4242 4242 4242 | Éxito |
| 4000 0000 0000 3220 | Requiere autenticación 3D Secure 2 |
| 4000 0025 0000 3155 | Requiere 3D Secure en setup |
| 4000 0000 0000 9995 | Rechazada: fondos insuficientes |
| 4000 0000 0000 0002 | Rechazada: rechazo genérico |
| 4000 0000 0000 0069 | Rechazada: tarjeta caducada |
| 4000 0000 0000 0127 | Rechazada: CVC incorrecto |
| 4000 0000 0000 0119 | Rechazada: error de procesamiento |
| 4000 0000 0000 0101 | Falla la verificación de CVC |
| 4000 0000 0000 3055 | Requiere 3D Secure, se completa con éxito |
| 4000 0000 0000 3063 | Requiere 3D Secure, falla la autenticación |
| 5555 5555 5555 4444 | Mastercard: éxito |
| 3782 822463 10005 | Amex: éxito |
Usa cualquier fecha de caducidad futura (p. ej., 12/34) y cualquier CVC de 3 dígitos (4 dígitos para Amex).
Errores comunes
- El modo test y el modo live son entornos completamente separados. Los productos, clientes y suscripciones creados en modo test no existen en modo live.
- Los secretos de firma de webhooks de Stripe CLI empiezan por
whsec_y son distintos del secreto de webhook del Dashboard. Usa el secreto proporcionado por la CLI durante el desarrollo local. - Los tests de Playwright contra páginas de Stripe Checkout pueden ser inestables porque la UI de Stripe puede cambiar. Prefiere probar tu propia UI con llamadas a Stripe simuladas.
- Stripe Elements se renderiza dentro de iframes. En Playwright, debes usar
frameLocatorpara interactuar con ellos. vi.mocken Vitest se eleva (hoisting) al inicio del archivo. Los imports dinámicos (await import(...)) dentro de los tests garantizan que el módulo se cargue con los mocks en su sitio.- No subas claves API de prueba al control de versiones. Usa
.env.test(en .gitignore) o variables de entorno de CI/CD. - Stripe limita las llamadas a la API en modo test a 25 solicitudes por segundo (menos que en modo live). Paraleliza las suites de tests con cuidado.
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| Vitest con Stripe simulado | Rápido, aislado, sin red | No prueba el comportamiento real de Stripe |
| Modo test de Stripe (API real) | Prueba la integración real | Más lento, requiere red, límites de tasa |
| Stripe CLI (webhooks) | Prueba el flujo real de webhooks localmente | Requiere configurar la CLI |
| Playwright E2E | Prueba el recorrido completo del usuario | Lento, inestable con iframes de Stripe |
| MSW (Mock Service Worker) | Intercepta HTTP, simulación realista | Más configuración que vi.mock |
FAQs
¿Cuál es la diferencia entre las claves API de modo test y las de modo live?
- Las claves de modo test empiezan por
sk_test_ypk_test_; las de modo live empiezan porsk_live_ypk_live_. - El modo test es un sandbox completo: no se producen cargos reales.
- Los productos, clientes y suscripciones en modo test no existen en modo live.
¿Cómo simulas el SDK de Stripe para tests unitarios con Vitest?
- Crea un archivo
__mocks__/stripe.tsque exporte un objeto mock convi.fn()para cada método. - Usa
vi.mock("@/lib/stripe", () => ({ stripe: mockStripe }))en tu archivo de test. - Importa los módulos con
await import(...)después de configurar los mocks para que surtan efecto.
¿Por qué usar imports dinámicos (await import) dentro de los casos de test?
vi.mockse eleva al inicio del archivo, pero el módulo bajo prueba puede importar Stripe en el momento de la carga.- Los imports dinámicos garantizan que el módulo se cargue después de que los mocks estén en su sitio.
- Esto evita que se instancie el cliente real de Stripe durante los tests.
¿Cómo pruebas la verificación de firma de webhooks?
// Firma válida
mockStripe.webhooks.constructEvent.mockReturnValue(event);
// Firma inválida
mockStripe.webhooks.constructEvent.mockImplementation(() => {
throw new Error("Invalid signature");
});- Simula
constructEventpara que devuelva el evento o lance una excepción. - Verifica que la ruta devuelva 200 para firmas válidas y 400 para inválidas.
¿Cómo ayuda Stripe CLI con las pruebas locales de webhooks?
stripe listen --forward-to localhost:3000/api/webhooks/stripe \
--events checkout.session.completed,invoice.paid- Crea un túnel que reenvía eventos de prueba de Stripe a tu servidor local.
- Puedes activar eventos específicos con
stripe trigger checkout.session.completed. - La CLI proporciona su propio secreto de firma
whsec_para desarrollo local.
¿Qué número de tarjeta de prueba activa un rechazo genérico y cuál activa 3D Secure?
4000 0000 0000 0002activa un rechazo genérico.4000 0000 0000 3220activa la autenticación 3D Secure 2.4242 4242 4242 4242siempre tiene éxito.
Trampa: ¿Por qué los tests de Playwright contra páginas de Stripe Checkout suelen ser inestables?
- La UI alojada por Stripe puede cambiar sin aviso, rompiendo los selectores.
- Stripe Elements se renderiza dentro de iframes, lo que requiere
frameLocatorpara interactuar. - Prefiere probar tu propia UI con llamadas a Stripe simuladas para mayor fiabilidad.
Trampa: ¿Cuál es el límite de tasa del modo test y cómo puede afectar a tu suite de tests?
- Stripe limita el modo test a 25 solicitudes por segundo (menos que las 100 del modo live).
- Las suites de tests muy paralelizadas pueden alcanzar este límite y recibir
rate_limit_error. - Usa Stripe simulado para la mayoría de los tests y reserva las llamadas reales a la API para una suite de integración pequeña.
¿Cómo debes tipar un evento mock de Stripe en 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;
}¿Cómo tipas los mocks de Stripe con vi.fn() para que coincidan con los tipos de retorno esperados?
- Usa
vi.fn()sin genéricos explícitos: Vitest infiere el tipo a partir de los valores de retorno del mock. - Al hacer aserciones, usa
mockResolvedValuecon objetos que coincidan con la interfazStripe.*relevante. - Haz cast a
Partial<Stripe>si necesitas un mock parcial del cliente completo.
¿Cuál es la diferencia entre el secreto de webhook de Stripe CLI y el del Dashboard?
- El secreto de la CLI se genera localmente al ejecutar
stripe listeny empieza porwhsec_. - El secreto del Dashboard se configura en los ajustes de webhooks de Stripe para tu endpoint desplegado.
- Usa el secreto de la CLI en
.env.testsolo para desarrollo local.
¿Cómo interactúas con Stripe Elements dentro de iframes en Playwright?
const stripeFrame = page.frameLocator(
"iframe[name*='__privateStripeFrame']"
).first();
await stripeFrame
.locator('[placeholder="1234 1234 1234 1234"]')
.fill("4242424242424242");- Usa
page.frameLocator()con un selector que coincida con el iframe de Stripe. - Todas las interacciones con los campos de tarjeta deben pasar por el frame locator.