Dein Idempotenz-Test kann wahrscheinlich gar nicht fehlschlagen
Mein Test für „denselben Zahlungseingang nicht zweimal melden" lief grün durch. In Produktion kamen zwei identische Nachrichten im Abstand von 142 Millisekunden. Der Test war nicht schwach – er konnte diesen Bug strukturell gar nicht erkennen und sah dabei wie ein Beweis für das Gegenteil aus.
Ich hatte einen Test namens „bleibt still, wenn der Plan bereits dem entspricht, was das Event vorgibt". Er lief zwei Wochen lang bei jedem Durchlauf grün.
Dann kamen zwei identische „Zahlung erhalten"-Nachrichten auf mein Handy, im Abstand von 142 Millisekunden.
Der Test war nicht schwach. Er konnte gar nicht fehlschlagen. Und das ist ein interessanteres Problem als ein fehlender Test, denn ein fehlender Test sieht wenigstens wie eine Lücke aus – dieser hier sah wie ein Beweis aus.
Was der Code tat
Paddle schickt für einen einzigen Kauf sowohl subscription.created als auch subscription.activated, und alles, was nicht mit 200 antwortet, wird erneut zugestellt. Zwei Zustellungen für eine Zahlung sind also kein Sonderfall, sondern der ganz normale Alltag.
Das wusste ich. Ich hatte die Deduplizierung geschrieben:
const { data: profile } = await supabase
.from('profiles')
.select('plan')
.eq('id', userId)
.single()
const alreadyOnPlan = profile?.plan === plan
await supabase.from('profiles').update({ plan }).eq('id', userId)
if (!alreadyOnPlan) await notifyPayment({ userId, plan, amount })
Aktuellen Plan lesen, vergleichen, schreiben, nur bei Änderung melden. Liest sich richtig. Und es ist richtig – für einen einzelnen Aufrufer.
Zwei Zustellungen kommen an. Beide lesen free. Beide sehen alreadyOnPlan === false. Beide schreiben basic. Beide melden.
Der ganze Bug lebt in dem Fenster zwischen Lesen und Schreiben, und dieses Fenster ist genau so breit wie ein Hin- und Rückweg zur Datenbank.
Warum der Test mir zustimmte
Das hatte ich geschrieben:
it('bleibt still, wenn der Plan bereits dem entspricht, was das Event vorgibt', async () => {
db.setRow('profiles', { id: 'user-1', plan: 'basic' })
await post(activation())
expect(notifyPayment).not.toHaveBeenCalled()
})
Lies das noch einmal mit dem Bug im Hinterkopf. Der Handler wird einmal aufgerufen, gegen eine Zeile, die sich bereits im Zielzustand befindet. Die Frage lautet: Bleibt es still, wenn ich das ausführe, nachdem der Plan schon gesetzt ist?
Das ist eine echte Frage. Nur nicht die Frage, um die es bei dem Bug geht.
Eine Race Condition braucht zwei Dinge, die sich zeitlich überlappen. await post(...) lässt einen Handler vollständig durchlaufen, bevor die nächste Zeile ausgeführt wird – die beiden Zustellungen existieren also nie gleichzeitig. Ich hätte einen zweiten Aufruf hinzufügen können, einen dritten, hundert: nacheinander ausgeführt wären sie alle für immer grün geblieben, während die Produktion weiter Duplikate verschickte.
Das ist der Teil, den man mitnehmen sollte. Mein Test war kein schlechter Test für Nebenläufigkeit. Er war ein Test für etwas ganz anderes, das ich unter „Nebenläufigkeit" abgelegt hatte, und das grüne Häkchen richtete aktiv Schaden an: Es sagte mir, der Fall sei abgedeckt.
Ihn zuerst zum Scheitern bringen
Die Lösung im Produktcode ist klein, dazu komme ich noch. Aber der Test musste vor der Lösung fehlschlagen – sonst hätte ich keinen Beweis, dass die Lösung überhaupt etwas bewirkt.
Zwei Handler müssen sich überlappen. In JavaScript braucht es dafür keine Threads – nur, dass der erste Handler bei einem await die Kontrolle abgibt, während der zweite startet:
it('meldet genau einmal, wenn zwei Zustellungen sich ein Rennen liefern', async () => {
db.setRow('profiles', { id: 'user-1', plan: 'free' })
await Promise.all([
post(activation()),
post(activation('subscription.created')),
])
expect(notifyPayment).toHaveBeenCalledTimes(1)
})
Promise.all startet beide, und der erste hält bei seinem ersten await an – dem Lesevorgang. Der zweite Handler führt sein eigenes Lesen gegen eine Zeile aus, die noch niemand geschrieben hat. Genau das ist die Verzahnung aus der Produktion, deterministisch reproduziert, in einem Unit-Test, ganz ohne Timing-Tricks.
Führt man das gegen den alten Code aus, scheitert es: zwei Meldungen. Genau dafür ist ein Test da.
Der Teil, den ich nicht erwartet hatte
Er scheiterte nicht. Er lief auch gegen den kaputten Code grün durch.
Die Mock-Datenbank ignorierte genau die Bedingung, auf die ich mich verlassen wollte. Sie protokollierte, dass ein Update stattgefunden hatte, und meldete Erfolg; ob die Zeile tatsächlich passte, bildete sie gar nicht ab. Eine bedingte und eine unbedingte Schreiboperation lieferten also identische Ergebnisse, und kein Test der Welt hätte sie unterscheiden können.
Ich will präzise sein, wie schlimm das ist. Ein fehlender Test hinterlässt eine bekannte Lücke. Ein Test-Double, das das zu testende Verhalten still vereinfacht, liefert selbstbewusst falsche Antworten – und zwar genau in dem Bereich, von dem man dachte, er sei abgedeckt. Es ist derselbe Fehlermodus wie der ursprüngliche Bug, nur eine Ebene höher.
Also musste der Mock genau das eine Verhalten lernen, auf das es hier ankommt: Ein bedingtes Update ist Prüfung und Schreiben in einem einzigen Schritt, und die Zeile ändert sich, bevor sie jemand anderes lesen kann.
if (pendingWrite === 'update' && notEquals.length) {
const current = state.rows[table]
const blocked = notEquals.some(([col, val]) => current?.[col] === val)
if (blocked) return { data: [], error: null }
if (current && writeValues) state.rows[table] = { ...current, ...writeValues }
}
Jetzt sieht der zweite Aufrufer im Promise.all, was der erste geschrieben hat. Jetzt scheitert der Test am alten Code und läuft grün beim neuen – die einzige Eigenschaft, die einen Test überhaupt lohnenswert macht.
Die Lösung
Sobald Prüfen-und-Handeln ein einziger Schritt sein muss, ist die Form der Lösung vorgegeben. Die Bedingung wandert aus dem Prozess heraus und in den Schreibvorgang hinein:
async function setPlan(userId: string, plan: Plan): Promise<{ changed: boolean }> {
const { data, error } = await supabaseAdmin
.from('profiles')
.update({ plan })
.eq('id', userId)
.neq('plan', plan) // ← die ganze Lösung
.select('id')
if (error) throw new Error(`Failed to update plan: ${error.message}`)
return { changed: Array.isArray(data) ? data.length > 0 : Boolean(data) }
}
Postgres entscheidet, wer zuerst war. Das Update, das tatsächlich eine Zeile traf, bekommt sie zurück; der Verlierer erhält eine leere Liste. Nur der Gewinner meldet.
Achte darauf, was sich nicht geändert hat: Beide Zustellungen versuchen weiterhin zu schreiben. Das ist wichtig – geht der Schreibvorgang der ersten wegen eines Netzwerkfehlers verloren, heilt ihn die zweite. Eine Deduplizierung, die das Schreiben bei der zweiten Zustellung überspringt, würde aus einer verlorenen Nachricht einen verlorenen Kauf machen.
Drei Fragen, die ich meinen Tests jetzt stelle
Kann dieser Test scheitern? Nicht „läuft er durch" – sondern: Kann ich eine Version des Produktcodes schreiben, plausibel genug, dass ich sie selbst hätte schreiben können, die von diesem Test nicht erkannt wird? Wenn die Antwort Nein lautet, ist der Test Dekoration. Die Lösung zu löschen und zu beobachten, ob der Test rot wird, dauert zehn Sekunden und ist der einzige Weg, es herauszufinden.
Passt die Form des Tests zur Form des Bugs? Race Conditions brauchen Überlappung. Retries brauchen Wiederholung. Reihenfolge-Bugs brauchen die falsche Reihenfolge. Ein sequenzieller Test kann eine Race Condition nicht ausdrücken, genauso wenig wie ein Unit-Test ein Deployment-Problem ausdrücken kann – nicht „erwischt es vermutlich nicht", sondern kann es nicht ausdrücken.
Bildet mein Test-Double die Eigenschaft ab, auf die ich mich verlasse? Ich verließ mich auf eine bedingte Schreiboperation. Mein Mock kannte keine Bedingungen. Jede Assertion, die darauf aufbaute, maß den Mock, nicht den Code. Hängt die Lösung von einer Garantie der Datenbank ab, muss das Double diese Garantie implementieren – sonst ist der Test Theater.
Warum das immer wieder passiert
Ich schreibe dieses Produkt zusammen mit einer KI, und dieser Bug zeigt gut, was sich dadurch ändert – und was nicht.
Die Deduplizierung, die sie schrieb, war vernünftig. Lesen, vergleichen, schreiben – so klang die Aufgabe, und so schreiben es auch die meisten Menschen von Hand. Für das Versagen brauchte es Kontext, den niemand ausgesprochen hatte: dass genau dieser Anbieter einen Kauf in zwei Events auffächert und obendrein noch wiederholt zustellt. Im Code steht davon nichts. Das ist Wissen über die Außenwelt, und genau solches Wissen taucht im Diff nie auf.
Der Test war meiner. Ich schrieb ihn, um mich abgesichert zu fühlen, und es funktionierte – ich fühlte mich zwei Wochen lang abgesichert, bis mein Handy zweimal vibrierte.
Die Lehre daraus lautet nicht „KI-Code sorgfältiger reviewen". Sie ist enger und nützlicher: Ein grüner Test ist eine Behauptung, und Behauptungen über Nebenläufigkeit, die von sequenziellem Code aufgestellt werden, sind nichts wert. Das galt schon vor alldem, und es wird auch danach gelten.