Save products you love by clicking the heart icon.
Evidenzbasierte Testing-Praxis aus Produktions-Codebasen — property-basierte Invarianten, gezielte Fehlerinjektion, Contract-Tests, benchmark-verifizierte Performance und Qualitäts-Gates, die durchgesetzt werden, nicht nur versprochen.
Evidenzbasierte Testing-Praxis aus Produktions-Codebasen — property-basierte Invarianten, gezielte Fehlerinjektion, Contract-Tests, benchmark-verifizierte Performance und Qualitäts-Gates, die durchgesetzt werden, nicht nur versprochen.
Stripe macht die Integration von Zahlungen einfach. Die Zahlungen zuverlässig zu testen ist eine ganz andere Sache. Ein einziger übersehener Randfall — eine unbezahlte Session, die als Kauf erfasst wird, ein zweimal zugestellter Webhook oder eine vom Client kontrollierte Download-URL — kann echtes Geld und echtes Kundenvertrauen kosten.
Dieser Leitfaden deckt das komplette Stripe-Testing-Paradigma ab, destilliert aus dem Aufbau und der Härtung eines Produktions-Checkout-Systems mit digitalen Downloads, physischen Bestellungen und Abo-Abrechnung.
Stripe arbeitet in zwei Modi, umschaltbar im Dashboard:
Kernaussage: Dein Servercode ändert sich zwischen den Modi nicht. Der einzige Unterschied sind die verwendeten API-Keys. Das bedeutet: Deine Test-Infrastruktur sollte die Produktion exakt spiegeln — gleiche Webhook-Handler, gleiche Idempotenz-Logik, gleiche Validierung.
Testkartennummern (wie 4242424242424242) funktionieren in zwei Szenarien:
sk_test_*) — funktionieren immer, ob über API oder Checkoutsk_live_*) — werden von der API akzeptiert (Session-Erstellung gelingt), aber von der gehosteten Checkout-Zahlungsseite zum Zeitpunkt der Zahlung abgelehntDas heißt: Du kannst Checkout-Sessions programmatisch mit Live-Keys und Testkarten erstellen, aber der kundenseitige Zahlungsschritt schlägt fehl. Für End-to-End-Tests immer Test-Keys verwenden.
Nicht alle Testkarten sind gleich. Stripe bietet einen reichhaltigen Satz an Karten zur Simulation spezifischer Szenarien:
| Kartennummer | Szenario | Erwartetes Ergebnis |
|---|---|---|
4242424242424242 | Standard-Visa | Zahlung erfolgreich |
4000056655665556 | Visa Debit | Zahlung erfolgreich |
5555555555554444 | Mastercard | Zahlung erfolgreich |
4000000000000002 | Generische Ablehnung | Zahlung abgelehnt |
4000000000009995 | Deckung unzureichend | Ablehnung mit insufficient_funds |
| Kartennummer | Szenario | Authentifizierung |
|---|---|---|
4000002500003155 | 3DS2 erforderlich | Challenge mit beliebigem Code |
4000002760002762 | 3DS2 erforderlich | Frictionless-Auto-Genehmigung |
4000000000003220 | 3DS erforderlich, schlägt fehl | Challenge schlägt immer fehl |
Pro-Tipp: Teste 3DS-Karten mit payment_method_options[card][request_three_d_secure] = 'any', um sicherzustellen, dass dein Flow Authentifizierungs-Challenges verarbeitet.
| Kartennummer | Szenario | Warum es zählt |
|---|---|---|
4000000000000069 | Abgelaufene Karte | Deine Fehlerbehandlung |
4000000000000127 | Falscher CVC | Validierungs-Feedback |
4000000000000101 | Verarbeitungsfehler | Retry-Logik |
4242424242424241 | Falsche Prüfsumme | Kartenvalidierung |
4000020000000003 | Später belasten (Auth) | Asynchrone Zahlungen |
Eine typische Stripe-Checkout-Integration hat vier Stufen:
Einer der häufigsten Bugs: Der checkout.session.completed-Webhook feuert für alle Checkout-Sessions — auch für unbezahlte. SEPA-Zahlungen kommen zunächst mit payment_status: "unpaid" an. Auch Abo-Checkouts feuern dieses Event.
// ❌ FALSCH — erfasst Käufe für unbezahlte Sessions
case "checkout.session.completed": {
const session = event.data.object;
// Fehlt: kein Check auf payment_status oder mode!
pushPurchase({ session_id: session.id, ... });
}
// ✅ RICHTIG — nur abgeschlossene Zahlungen erfassen
case "checkout.session.completed": {
const session = event.data.object;
if (session.mode !== "payment" || session.payment_status !== "paid") {
logger.info(`Skipping non-payment: ${session.id}`);
break;
}
pushPurchase({ session_id: session.id, ... });
}
Ein ausgefeilter Angriff: Ein Angreifer kauft das billigste Produkt, überschreibt aber downloadUrl im Checkout-Request, sodass sie auf die Datei eines teuren Produkts zeigt. Der Flow:
Der Fix: Download-Pfade serverseitig aus dem Content-Frontmatter auflösen und die Client-Metadaten komplett ignorieren.
// Serverseitiger Resolver — einzige Quelle der Wahrheit
export async function resolveProductDownloadUrl(slug: string) {
const content = await getContent("products", slug);
return content?.frontmatter?.download_url || null;
}
// Im Webhook: aus dem Content auflösen, nicht aus Client-Metadaten
const downloadUrl = await resolveProductDownloadUrl(item.slug);
const token = signDownloadToken({ product_slug: slug, download_url: downloadUrl });
Webhook-Idempotenz deckt Stripe → dich ab. Die umgekehrte Richtung braucht gleich viel Sorgfalt: dein Server → Stripe. Ohne Idempotenz-Key erzeugt ein Client-Netzwerk-Retry (POST /api/stripe/create-checkout) zwei Checkout-Sessions — und zwei potenzielle Käufe.
// Checkout mit einem aus Warenkorb + User abgeleiteten Idempotenz-Key erstellen
const session = await stripe.checkout.sessions.create(
{ mode: "payment", ... },
{ idempotencyKey: `${userId}:${cartFingerprint}` }
);
Derselbe Key muss dieselbe Session zurückgeben (Stripe dedupliziert); ein anderer Key erzeugt eine neue. Deine Tests müssen abdecken: gleicher Key zweimal → eine Session; anderer Key → getrennte Sessions; ein wiederholter Request nach Netzwerkfehler → kein doppelter Kauf.
Mocke Stripe's Webhook-Konstruktion und teste deine Handler-Logik isoliert:
// tests/unit/api/stripe/webhook.test.ts
vi.mock("@/lib/stripe", () => ({
constructWebhookEvent: mockConstructEvent,
}));
it("records purchase for paid checkout", async () => {
mockConstructEvent.mockReturnValue({
type: "checkout.session.completed",
data: {
object: {
mode: "payment",
payment_status: "paid",
id: "cs_test_123",
metadata: { productName: "Test", productSlug: "test" },
customer_details: { email: "buyer@test.com" },
},
},
});
const res = await POST(mockRequest("{}", "sig_abc"));
expect(res._status).toBe(200);
expect(mockPurchases).toHaveLength(1);
expect(mockSendMail).toHaveBeenCalled();
});
Coverage-Ziele für Webhook-Tests:
| Testfall | Warum |
|---|---|
| Standard-Einzelproduktkauf | Happy Path |
| Mehrpositionen-Warenkorb | Array-Behandlung, UNIQUE-Constraint |
| Abo-Checkout (mode=subscription) | Sollte übersprungen werden |
| Unbezahlte Session (payment_status=unpaid) | Sollte übersprungen werden |
| Doppelte Webhook-Zustellung | Idempotenz |
| Fehlende Metadaten-Felder | Graceful Degradation |
| Korruptes Items-JSON | Fehlerbehandlung |
| Physische/Druck-Bestellungen | Versand-Benachrichtigungsmail |
| E-Mail-Sendefehler | Fehler crasht den Handler nicht |
| Wiederholter API-Request (gleicher Idempotenz-Key) | Dedupe auf der Stripe-API-Seite |
| Dispute erstellt (charge.dispute.created) | Chargeback-Behandlung |
| Langsame Webhook-Antwort | Retry-Semantik, asynchrone Verarbeitung |
Installiere die Stripe-CLI und leite echte Webhook-Events an deinen lokalen Server weiter:
# Installation (macOS)
brew install stripe/stripe-cli/stripe
# Login (holt deinen Testmodus-API-Key)
stripe login
# Webhooks an localhost:3000 weiterleiten
stripe listen --forward-to localhost:3000/api/stripe/webhook
# Das gibt dein Webhook-Signing-Secret aus (whsec_...)
# Füge es in deine .env.local ein:
# STRIPE_WEBHOOK_SECRET=whsec_xxx
Danach ein Test-Event auslösen:
# Checkout-Abschluss-Event auslösen
stripe trigger checkout.session.completed
# Zahlungsfehler auslösen
stripe trigger payment_intent.payment_failed
# Abo-Event auslösen
stripe trigger customer.subscription.updated
Teste den gesamten Flow: Checkout → Webhook → Download, inklusive State-Management über Aufrufe hinweg:
it("checkout → webhook → download flow succeeds", async () => {
// 1. Checkout erstellen
const checkout = await createCheckout({ priceId: "price_test", ... });
expect(checkout._data.checkoutURL).toBeDefined();
// 2. Webhook feuern (simuliert Stripe, der deinen Server aufruft)
mockConstructEvent.mockReturnValue({
type: "checkout.session.completed",
data: { object: { mode: "payment", payment_status: "paid", ... } },
});
const webhook = await webhookHandler(mockRequest({}, "sig"));
expect(webhook._status).toBe(200);
// 3. Download mit signiertem Token
const token = signDownloadToken({ session_id: "cs_123", ... });
mockRetrieve.mockResolvedValue({ payment_status: "paid" });
const download = await downloadHandler(mockDownloadRequest(token));
expect(download._status).toBe(200);
});
Stripe wiederholt Webhook-Zustellungen, wenn dein Endpoint nicht schnell mit 2xx antwortet — mit exponentiellem Backoff, der Tage umfassen kann. Ein Handler, der schwere Arbeit synchron erledigt (E-Mails senden, Rechnungen erzeugen, Downloads bauen), riskiert ein Timeout: Stripe wiederholt, und jetzt ist dasselbe Event zweimal unterwegs. Zwei Regeln:
2xx antworten nach Signaturprüfung und Validierung — die eigentliche Arbeit einreihen// ✅ Webhook: validieren, bestätigen, einreihen
export async function POST(request: Request) {
const event = verifyAndParseBody(await request.text(), request.headers.get("stripe-signature")!);
await purchaseQueue.enqueue({ session_id: event.data.object.id }); // durable queue
return Response.json({ received: true }); // fast 200 — kein Stripe-Retry
}
Teste diesen Contract explizit: Simuliere Stripe-artige Redelivery (dasselbe Event zweimal mit derselben ID), behaupte, dass der Handler beide Male 200 liefert, und behaupte, dass die Queue das Event genau einmal verarbeitet.
| Stufe | Tests | Geschwindigkeit | Stripe-Interaktion |
|---|---|---|---|
| CI (PR) | Unit-Tests mit Mocks | ~20s | Keine (gemockt) |
| Staging | Stripe-CLI-Forwarding | ~2m | Echte Testmodus-API |
| Produktions-Deploy | Smoke-Tests | ~10s | Nur API, keine Zahlungen |
| Manuell | Volles E2E mit Testkarte | ~5m | Vollständiger Testmodus-Flow |
# .env.local (Entwicklung)
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
# .env.production (Produktion)
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_live_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
Echte Keys niemals committen. Für die Entwicklung .env.local (gitignored) verwenden, für die Produktion einen Secrets-Manager.
Die obige Testmatrix ist beispielbasiert: Jeder Fall ist handgeschrieben und behauptet ein konkretes Szenario. Beispiel-Tests sind das Fundament, aber sie haben eine Decke — „we've exhausted example-based tests; property-based/fuzz testing finds what examples miss." Webhook-Handler sind stateful und concurrency-anfällig: doppelte Zustellungen, Events in falscher Reihenfolge und Races zwischen parallelen Webhook-Aufrufen sind genau die Bugs, die handgeschriebene Beispiele übersehen, weil ihr Timing den Race maskiert.
Mit fast-check (oder Hypothesis in Python) Invarianten behaupten, die für jedes Event gelten müssen, das der Handler erhält:
import { fc, it } from "fast-check";
// Invariante: Für unbezahlte oder Nicht-Payment-Sessions wird nie ein Kauf erfasst
it("never records a purchase for non-payment sessions", () => {
fc.assert(
fc.property(
fc.record({
mode: fc.constantFrom("payment", "subscription", "setup"),
payment_status: fc.constantFrom("paid", "unpaid", "no_payment_required"),
id: fc.uuid(),
}),
(session) => {
const handled = handleWebhook({
type: "checkout.session.completed",
data: { object: session },
});
const expected = session.mode === "payment" && session.payment_status === "paid";
expect(handled.purchaseRecorded).toBe(expected);
},
),
);
});
// Invariante: Idempotenz — doppelte Zustellung erfasst nie doppelt
it("is idempotent under duplicate delivery", () => {
fc.assert(
fc.property(fc.uuid(), (sessionId) => {
const event = {
type: "checkout.session.completed",
data: { object: { id: sessionId, mode: "payment", payment_status: "paid" } },
};
handleWebhook(event);
handleWebhook(event); // Stripe kann zweimal liefern — oder 100x
expect(purchasesFor(sessionId)).toHaveLength(1);
}),
);
});
Was du dafür bekommst: Der mode/payment_status-Guard aus dem Abschnitt „Kritische Guards" hört auf, eine gemerkte Regel zu sein, und wird zu einer durchgesetzten Invariante. Das ist das Qualitätsprinzip, das sich in Produktionssystemen immer wieder auszahlt: ein Guard, der existiert, aber nicht durchgesetzt wird, ist schlimmer als kein Guard — er erzeugt falsche Sicherheit.
Eine Checkout-Session ist eine State-Machine: open → completed | expired | canceled, wobei payment_status unabhängig übergeht (SEPA kommt zuerst unpaid an). Modelliere den Lifecycle und lass das Framework dann zufällige Kommandosequenzen (create, complete, expire, Duplicate-Webhook) gegen Modell und deine echten Handler laufen — die Zustände müssen nach jedem Schritt übereinstimmen. Genau das hat in der Praxis einen echten Bug gefunden: Zwei parallele Webhook-Zustellungen passierten beide den Duplikat-Check und erfassten danach beide einen Kauf. Ein serieller Test sieht den Race nie; ein stateful Fuzz-Lauf schon.
Ergänze eine geseedete Fuzz-Schicht, die alles auf den Handler wirft:
invoice.paid kommt vor checkout.session.completed anJeder Fehler wird auf ein minimales Reproduktionsbeispiel geschrumpft (fast-check macht das automatisch), und genau dieses Beispiel wird zum Regressionstest. Die Regression-per-Fix-Regel ist nicht verhandelbar: Ein Fuzz-Fund ohne Regressionstest ist ein Bug, den du einmal gesehen hast und wiedersehen wirst.
payment_status-Check oder die Signaturprüfung killen und bestätigen, dass die Tests fehlschlagenWebhook-Signaturen immer verifizieren. Rohen Webhook-Payloads niemals vertrauen.
import crypto from "crypto";
function constructWebhookEvent(body: string, signature: string) {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
if (!webhookSecret) throw new Error("Not configured");
const elements = signature.split(",");
const timestamp = elements.find((e) => e.startsWith("t="))?.slice(2);
const sig = elements.find((e) => e.startsWith("v1="))?.slice(3);
if (!timestamp || !sig) throw new Error("Invalid signature format");
// Events älter als 5 Minuten ablehnen (Replay-Schutz)
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
if (age > 300) throw new Error("Stale webhook");
const signedPayload = `${timestamp}.${body}`;
const expected = crypto.createHmac("sha256", webhookSecret).update(signedPayload).digest("hex");
if (sig !== expected) throw new Error("Signature mismatch");
return JSON.parse(body);
}
Webhooks können mehrfach zugestellt werden. Immer auf Duplikate prüfen:
// Vor der Verarbeitung
const existing = loadPurchases();
if (existing.some((p) => p.session_id === session.id)) {
logger.info("Duplicate webhook — skipping");
break;
}
Alle Stripe-zugewandten Endpoints rate-limiten:
// Checkout erstellen — externer API-Aufruf
const isRateLimited = createRateLimiter(60_000, 10); // 10 pro Minute pro IP
Für OTPs nicht Math.random() verwenden. Die Web-Crypto-API nutzen:
export function generateOTP(): string {
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
return String(100000 + (buf[0] % 900000));
}
Download-Tokens müssen sein:
Stripe-Metadatenwerte sind auf 500 Zeichen begrenzt. Ein Warenkorb mit 20+ Artikeln kann das überschreiten:
// ❌ Unbegrenzt
metadata.items = JSON.stringify(items);
// ✅ Größe bewachen
const itemsJson = JSON.stringify(items.slice(0, 20));
metadata.items = itemsJson.length > 490 ? itemsJson.slice(0, 487) + '..."' : itemsJson;
checkout.session.completed feuert für Zahlungs- UND Abo-Checkouts. Dein Handler muss session.mode prüfen:
if (session.mode !== "payment") break; // Abos nutzen customer.subscription.*-Events
customer.subscription.updated feuert bei jeder Abo-Änderung, auch bei Kündigung. canceled_at nicht blind löschen:
// ❌ Falsch — löscht das Kündigungsdatum
canceled_at: undefined;
// ✅ Richtig — erhalten, außer reaktiviert
canceled_at: sub.status === "active"
? undefined
: sub.canceled_at
? new Date(sub.canceled_at * 1000).toISOString()
: undefined;
Wenn dein Rate-Limiter erst nach request.json() läuft, kann ein Angreifer deine Server-Ressourcen erschöpfen, bevor er gedrosselt wird:
// ❌ Falsch — Body vor dem Rate-Check geparst
const body = await request.json();
if (isRateLimited(ip)) return 429;
// ✅ Richtig — Rate-Check vor dem Parsen
if (isRateLimited(ip)) return 429;
const body = await request.json();
Im Privatmodus oder wenn Speicher blockiert ist, wirft localStorage.getItem():
// ❌ Crasht im Privatmodus
const [email] = useState(() => localStorage.getItem(KEY));
// ✅ Sicher
const [email] = useState(() => {
try {
return localStorage.getItem(KEY) || "";
} catch {
return "";
}
});
Diese Checkliste vor jedem Release durchgehen:
4242 erfolgreich end-to-end (Stripe-CLI-Modus)4000000000000002 elegant behandeltpayment_status: unpaid) wird übersprungenmode: subscription) wird übersprungenStripe-Integrationen zu testen ist keine Option — es ist eine Sicherheitsanforderung. Eine einzige Schwachstelle in der Zahlungsverarbeitung kann zu Inventardiebstahl, Kundendaten-Exposition oder Umsatzverlust führen.
Das Muster ist immer dasselbe: Client-Eingaben niemals vertrauen, alles serverseitig verifizieren und Randfälle testen, die die Happy-Path-Dokumentation von Stripe nicht abdeckt.
Die Investition in umfassendes Testing zahlt sich beim ersten Mal aus, wenn ein Webhook wiederholt wird, eine Karte abgelehnt wird oder ein Kunde einen Chargeback einreicht. Baue die Testing-Infrastruktur einmal, führe sie bei jedem Commit aus und liefere Zahlungen mit Vertrauen aus.