Alle Artikel

KI-Modelle

Die subtilen Bugs, die durch Tests rutschen

Welche Bugs produzieren KI-Modelle, die durch alle Tests rutschen? Singleton-Snapshot-Race, fehlendes letzteAktionAm, HTTP-String-Matching — mit echten Code-Diffs.

9 min min
Die subtilen Bugs, die durch Tests rutschen

Teil 3b der Reihe „5 KI-Modelle, dieselbe Aufgabe". Stand: Juni 2026.


Auf einen Blick

  • Alle fünf Modelle hatten denselben Rest-Bug: Singleton-Snapshot beim Optimistic Rollback. Race Condition bei Parallel-Klicks — durch alle Test-Suites durchgerutscht.
  • letzteAktionAm als Pflichtfeld vs. Optional ist eine Zeile im Interface. Sie verhindert eine ganze Klasse von Fallback-Bugs.
  • Claude Opus hat HTTP-Status-Codes per deutschsprachigem String-Matching bestimmt. Elegantester Gesamtcode, eine strukturelle Schwäche.
  • DeepSeek v4-pro hatte die sauberste Architektur-Disziplin. Server Component, Domänen-Service, funktionale State-Updates.
  • Die Bugs hier sind die, die man erst im Betrieb sieht.

Worum es in diesem Teil geht

In Teil 3a hab ich beschrieben, warum drei von fünf Modellen die App nicht out-of-the-box starten konnten — fehlende Route, 0-Byte-Abhängigkeit, Tailwind-Versions-Mix.

Das hier ist der andere Teil: Was haben die Modelle geliefert, die gebaut haben? Und wo lagen die Bugs, die durch alle Tests durchgerutscht sind?

Das sind die interessanteren Bugs. Sie sehen auf den ersten Blick korrekt aus. Die Tests waren grün. Sie brechen erst, wenn zwei Dinge gleichzeitig passieren — oder wenn die Maschine in einer anderen Zeitzone läuft, oder wenn ein Refactoring die Fehlermeldung umbenennt.


Der geteilte Rest-Bug: Singleton-Snapshot beim Optimistic Rollback

Das ist der Bug, den fast alle haben — auch die gut bewerteten. Er rutschte durch alle Test-Suites, weil er eine Parallel-Aktion braucht, um sichtbar zu werden.

Beim Optimistic Update wird vor dem Server-Call ein Snapshot des aktuellen State für den Rollback-Fall gespeichert. DeepSeek v4-pro macht das so:

// app/page.tsx (DeepSeek v4-pro) — Singleton-Snapshot
const handleDun = async (invoice: Invoice) => {
  setError(null);
  const snapshot = [...invoices];   // ← ein Array-Snapshot für alle
  const nextStufe = (invoice.mahnstufe + 1) as Mahnstufe;

  setInvoices((prev) =>
    prev.map((inv) =>
      inv.id === invoice.id
        ? { ...inv, mahnstufe: nextStufe, letzteAktionAm: today }
        : inv,
    ),
  );
  setLoadingId(invoice.id);

  try {
    const res = await fetch(`/api/invoices/${invoice.id}/dun`, { method: 'POST' });
    // ...
  } catch (e) {
    setError(e instanceof Error ? e.message : 'Netzwerkfehler.');
    setInvoices(snapshot);  // ← Rollback — aber welcher Snapshot?
  }
};

Das Problem: snapshot = [...invoices] wird zum Zeitpunkt des Klicks gezogen. Wenn der User unmittelbar danach auf eine zweite Zeile klickt, während der erste Request noch läuft, zieht handleDun einen neuen snapshot = [...invoices] — der bereits das optimistische Update der ersten Zeile enthält.

Schlägt jetzt der erste Request fehl: setInvoices(snapshot) stellt den Snapshot wieder her, der zum Zeitpunkt des zweiten Klicks gültig war — also mit dem optimistischen Stand der ersten Zeile drin. Der erste optimistische Update wird nicht korrekt zurückgerollt, weil der Snapshot ihn schon enthielt.

Das Muster ist bei GLM noch direkter sichtbar — dort mit einem Closure-Problem obendrauf:

// app/page.tsx (GLM-4.6) — Closure-Problem
const handleMahnstufeErhoehen = async (id: string) => {
  const alteRechnungen = [...optimisticRechnungen];  // ← Snapshot aus Closure
  const rechnung = optimisticRechnungen.find((r) => r.id === id);
  if (rechnung) {
    setOptimisticRechnungen(
      optimisticRechnungen.map((r) =>   // ← direkte Closure-Referenz, kein prev =>
        r.id === id ? { ...r, mahnstufe: (r.mahnstufe + 1) as 0|1|2|3|4, letzteMahnung: new Date() } : r
      )
    );
  }
  // ... bei Fehler:
  } else {
    setOptimisticRechnungen(
      optimisticRechnungen.map((r) => (r.id === id ? data.rechnung : r))
      //                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
      //                   Closure-Referenz — Stand beim Funktionsaufruf, nicht aktuell
    );
  }

optimisticRechnungen.map(...) ohne prev => greift auf die Closure-Variable zum Zeitpunkt des Aufrufs zu, nicht auf den State zum Zeitpunkt der Ausführung. Bei schnellen aufeinanderfolgenden Aktionen kann der State nach dem Server-Response auf einem veralteten Stand landen.

Der robuste Ansatz wäre eine Map<id, Invoice> als Rollback-Speicher pro Zeile, plus funktionale State-Updates:

// robust — funktionaler Updater, kein Closure-Problem
const rollbackMap = new Map<string, Invoice>();
rollbackMap.set(id, invoice);   // Snapshot pro Zeile, nicht global

setInvoices(prev => prev.map(inv =>
  inv.id === id ? { ...inv, mahnstufe: nextStufe } : inv
));

// Bei Fehler:
setInvoices(prev => prev.map(inv =>
  inv.id === id ? (rollbackMap.get(id) ?? inv) : inv
));

prev => liest immer den aktuellen State zum Zeitpunkt der Ausführung. rollbackMap speichert den Originalzustand pro Zeile, nicht den globalen Array-Stand. Kein Race, kein Closure-Problem.

DeepSeek v4-flash hatte dafür einen snapshotRef-Ansatz:

// app/page.tsx (DeepSeek v4-flash) — ref-basierter Snapshot
const snapshotRef = useRef<SnapshotEntry | null>(null);

const withOptimistic = useCallback(
  (id: string, transform: (inv: Invoice) => Invoice, action: () => Promise<void>) => {
    snapshotRef.current = [Date.now(), invoices.map((i) => ({ ...i }))];
    // ...
    action()
      .catch((err: Error) => {
        const snap = snapshotRef.current;
        if (snap && snap[0]) {
          setInvoices(snap[1]);  // ← Rollback aus ref
        }
      });
  },
  [invoices]  // ← [invoices] als Dep macht withOptimistic bei jedem Update neu
);

Besser als die Closure-Variante. Aber [invoices] als Dependency lässt withOptimistic bei jedem State-Update neu entstehen — was bei Parallel-Klicks zwischen zwei Re-Renders zu ähnlichen Race-Conditions führen kann.

Der Singleton-Snapshot-Bug ist in der Praxis selten — Parallel-Klicks auf eine Mahnwesen-Tabelle sind kein häufiger Use Case. Aber das Muster taucht überall auf, wo Optimistic UI mit mehreren gleichzeitigen Actions umgehen muss. Finanzielle Apps zum Beispiel.


letzteAktionAm: Pflichtfeld vs. Optional — eine Zeile, eine Klasse von Bugs

Die 14-Tage-Frist zwischen Mahnstufen braucht ein verlässliches Datum der letzten Aktion. Diese eine Entscheidung — Pflichtfeld oder Optional — unterscheidet die Modelle mehr als jedes andere Architektur-Merkmal.

DeepSeek v4-pro hat es als Pflichtfeld modelliert:

// lib/domain.ts (DeepSeek v4-pro) — letzteAktionAm ist Pflicht
export interface Invoice {
  id: string;
  kunde: string;
  betrag: number;            // Cent
  rechnungsdatum: string;    // "YYYY-MM-DD"
  faelligkeitsdatum: string; // "YYYY-MM-DD"
  bezahltAm?: string;        // "YYYY-MM-DD" | undefined
  mahnstufe: Mahnstufe;
  letzteAktionAm: string;    // "YYYY-MM-DD" — Pflichtfeld, kein Optional
}

Und der canAdvanceDunning-Check:

// lib/domain.ts (DeepSeek v4-pro) — deterministisch
export function canAdvanceDunning(
  invoice: Invoice,
  today: string,
): { allowed: boolean; reason?: string } {
  if (isPaid(invoice)) {
    return { allowed: false, reason: 'Rechnung ist bereits bezahlt.' };
  }
  if (invoice.mahnstufe >= 4) {
    return { allowed: false, reason: 'Maximale Mahnstufe (Inkasso) bereits erreicht.' };
  }

  const tageSeit = daysBetween(invoice.letzteAktionAm, today);  // ← reines String-Diff
  if (tageSeit < 14) {
    return {
      allowed: false,
      reason: `Mahnung erst in ${14 - tageSeit} Tagen möglich (14-Tage-Frist).`,
    };
  }

  return { allowed: true };
}

daysBetween arbeitet auf ISO-Strings ("YYYY-MM-DD"), nicht auf Date-Objekten. Lexikographischer Vergleich funktioniert für ISO-Datumsstrings korrekt. Kein TZ-Drift möglich — es gibt nichts zu driften.

Claude Opus löste es mit UTC-expliziten Hilfsfunktionen:

// src/domain/dunning.ts (Claude Opus) — UTC explizit
function parseTag(iso: string): number {
  const [jahr, monat, tag] = iso.split("-").map(Number);
  return Date.UTC(jahr ?? 0, (monat ?? 1) - 1, tag ?? 1);
}

function tagVon(d: Date): number {
  return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
}

export function tageDifferenz(aIso: string, b: Date): number {
  const MS_PRO_TAG = 86_400_000;
  return Math.floor((tagVon(b) - parseTag(aIso)) / MS_PRO_TAG);
}

Kein getDate(), kein setHours() — nur UTC-Millisekunden. Das ist der Weg, der unter TZ=Asia/Kolkata und TZ=America/New_York genauso läuft wie unter TZ=Europe/Berlin.

DeepSeek v4-flash dagegen ließ letzteAktionAm in manchen Läufen optional (Date | null) und fiel auf einen Fallback zurück:

// Fallback-Logik (DeepSeek v4-flash, Lauf A) — approximiert
const referenzdatum = invoice.letzteAktionAm ?? invoice.rechnungsdatum;
const tageSeit = daysBetween(referenzdatum, today);

Das rechnungsdatum als Approximation für die erste Frist. Das bricht, sobald eine Mahnstufe übersprungen oder eine Mahnung außerplanmäßig spät gesetzt wird — dann zeigt das Datum nicht mehr, wann die letzte Aktion war, sondern wann die Rechnung gestellt wurde.

Qwen3-max hatte das Feld prinzipiell dabei — aber im optimistischen Update so gesetzt:

// InvoiceActions.tsx (Qwen3-max) — Datumsformat-Mismatch
letzteAktionAm: new Date().toISOString(),
//              ^^^^^^^^^^^^^^^^^^^^^^^^
//              "2026-06-15T10:23:44.000Z" — ISO-8601 mit Zeit
//              Vergleichsfunktionen erwarten "YYYY-MM-DD"

Alle Vergleichsfunktionen im selben Code erwarteten "YYYY-MM-DD"-Strings. Der optimistische Update setzte einen vollen ISO-Timestamp. Das führt zu einem Mismatch: daysBetween("2026-06-15T10:23:44.000Z", "2026-06-15") gibt je nach Implementierung ein falsches Ergebnis oder wirft einen Fehler.


Claude Opus: HTTP-Status per String-Matching

Opus war das sauberste Gesamtpaket — eine Schwäche fiel trotzdem auf. Die Route Handler übersetzen Result-Types auf HTTP-Status per String-Matching:

// src/app/api/invoices/[id]/dunning/route.ts (Claude Opus)
export async function POST(
  _request: Request,
  { params }: { params: Promise<{ id: string }> },
): Promise<NextResponse> {
  const { id } = await params;
  const res = mahneHoch(id);

  if (!res.ok) {
    const status = res.fehler.includes("nicht gefunden") ? 404 : 409;
    //                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //                        Lokalisierten String als Routing-Basis nutzen
    return NextResponse.json({ fehler: res.fehler }, { status });
  }

  return NextResponse.json({ rechnung: res.wert }, { status: 200 });
}

res.fehler.includes("nicht gefunden") — der HTTP-Status hängt davon ab, ob der deutsche Fehlermeldungsstring die Zeichenkette „nicht gefunden" enthält. Ändert sich die Fehlermeldung in der Domäne — Refactoring, Übersetzung, Tippfehler — verhält sich der Status anders, ohne dass ein Test rot wird.

Der saubere Ansatz: ein strukturierter Error-Typ im Result.

// besser: strukturierter Result-Typ
type Result<T> =
  | { ok: true; wert: T }
  | { ok: false; fehler: string; code: "NOT_FOUND" | "VALIDATION" | "CONFLICT" };

Dann wäre der Route-Handler unabhängig vom Wortlaut:

if (!res.ok) {
  const status = res.code === "NOT_FOUND" ? 404 : 409;
  return NextResponse.json({ fehler: res.fehler }, { status });
}

Außerdem hatte Opus keine Tests für den Service (lib/service.ts) oder die Route Handler — nur für die Domäne (domain/dunning.test.ts). 21 Tests, alle grün, aber die Integration zwischen Service und Store war untestiert.


Architektur-Vergleich: was die guten von den mittelmäßigen trennt

Wenn ich die Ausgaben nebeneinanderlege, ist ein Muster klar.

Domäne/UI-Trennung. DeepSeek v4-pro und Opus hatten die sauberste Trennung. Kein Domänencode in Route Handlern, keine Geschäftslogik in page.tsx:

// src/lib/service.ts (Claude Opus) — Service trennt Domäne vom Store
export function mahneHoch(id: string, store: InvoiceStore = invoiceStore): Result<Rechnung> {
  const rechnung = store.finde(id);
  if (!rechnung) return { ok: false, fehler: "Rechnung nicht gefunden." };

  const res = naechsteMahnstufe(rechnung, jetzt());
  if (!res.ok) return res;

  return { ok: true, wert: store.speichere(res.wert) };
}

Der Route Handler ruft nur mahneHoch(id) auf — weiß nichts von naechsteMahnstufe, nichts vom Store-Interface.

Opus page.tsx war eine Server Component — 27 Zeilen, kein useState, kein useEffect:

// src/app/page.tsx (Claude Opus) — Server Component
export const dynamic = "force-dynamic";

export default function DashboardPage() {
  const rechnungen = invoiceStore.alle();
  const kpis = berechneKpis(rechnungen, new Date());

  return (
    <main className="mx-auto max-w-6xl space-y-6 px-4 py-8">
      <KpiCards kpis={kpis} />
      <InvoiceTable initial={rechnungen} />
    </main>
  );
}

KPI-Berechnung und Store-Zugriff auf dem Server. InvoiceTable ist dann die Client Component mit Optimistic UI. Das ist Next.js App Router so, wie er gedacht ist.

DeepSeek v4-pro wählte bewusst anders: alles Client Component, aber mit useMemo für KPIs:

// app/page.tsx (DeepSeek v4-pro) — Client-seitige KPIs mit useMemo
function computeKPIs(invoices: Invoice[], today: string): KpiData {
  let summeOffen = 0;
  let summeUeberfaellig = 0;
  const counts: Record<Mahnstufe, number> = { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0 };

  for (const inv of invoices) {
    if (!inv.bezahltAm) {
      summeOffen += inv.betrag;
      counts[inv.mahnstufe]++;
      if (inv.faelligkeitsdatum < today) {   // ← String-Vergleich für ISO-Dates: korrekt
        summeUeberfaellig += inv.betrag;
      }
    }
  }
  return { summeOffen, summeUeberfaellig, counts };
}

const kpis = useMemo(() => computeKPIs(invoices, today), [invoices, today]);

String-Vergleich für Datumsfelder (inv.faelligkeitsdatum < today) funktioniert korrekt, weil beide "YYYY-MM-DD"-Strings sind — lexikographisch sortierbar. Das ist kein Hack, das ist bewusstes Design.

GLM dagegen hatte die KPI-Berechnung in sieben .filter()-Aufrufen direkt in der Render-Funktion — kein useMemo, kein Extrahieren, sieben Passes über dasselbe Array bei jedem Render. Funktioniert, skaliert aber nicht.


Was man daraus mitnimmt

Der Singleton-Snapshot-Bug ist harmlos — bis er es nicht mehr ist. Parallel-Klicks auf eine Mahnwesen-Tabelle sind selten. Aber das Muster taucht überall auf, wo Optimistic UI mit mehreren gleichzeitigen Actions umgeht. Ein Modell, das dieses Muster korrekt löst, zeigt, dass es den Unterschied zwischen lokalem State-Snapshot und funktionalem State-Update versteht. Kein Modell in diesem Test hat es vollständig korrekt gelöst.

letzteAktionAm als Pflichtfeld ist eine Zeile. DeepSeek v4-pro hat sie. v4-flash (Lauf A) nicht. Diese eine Zeile verhindert alle Bugs, die entstehen, wenn der Code auf einen Fallback zurückgreift, der unter bestimmten Bedingungen falsch ist. Kleine Architektur-Entscheidungen summieren sich.

Opus hat String-Matching für HTTP-Status benutzt. Das ist kein Show-Stopper — die App läuft, alle Tests sind grün. Es ist ein Beispiel dafür, dass auch das stärkste Modell an Stellen Abkürzungen nimmt, wo es keine nehmen sollte. Und dass „8,5 von 10" nicht bedeutet, dass es nichts zu finden gibt.

Der Unterschied zwischen 8,5 und 8,0 ist keine große Zahl. Opus und DeepSeek v4-pro teilen sich den ersten Platz. Beide sind in der Praxis produktionstauglich — mit Code-Review. Der Abstand zu GLM und Qwen ist dagegen strukturell: nicht „ein Bug mehr", sondern „ein anderer Prozess".


Häufige Fragen

Was ist ein Singleton-Snapshot beim Optimistic Rollback? Ein Snapshot des gesamten State-Arrays, der zum Zeitpunkt eines einzelnen Klicks gezogen wird. Wenn zwei Klicks fast gleichzeitig passieren, enthält der zweite Snapshot bereits den optimistischen Stand des ersten. Schlägt der erste Request fehl, wird dieser Stand wiederhergestellt — nicht der ursprüngliche. Der Bug ist schwerelos, bis tatsächlich zwei Requests gleichzeitig fehlschlagen.

Warum ist letzteAktionAm als Optional ein Problem? Weil der Code dann auf einen Fallback ausweicht — meistens das Rechnungsdatum. Das stimmt für die erste Mahnung. Für alle weiteren nicht mehr. Ein optionales Feld erzwingt Fallback-Logik, und Fallback-Logik hat immer Randfälle.

Warum ist String-Matching für HTTP-Status ein Problem, wenn es funktioniert? Weil es eine versteckte Abhängigkeit zwischen der Fehlermeldung (Domäne) und dem HTTP-Status (Route Handler) erzeugt. Ändert sich der Wortlaut der Fehlermeldung, ändert sich der Status — ohne dass ein Test rot wird, weil kein Test diese Abhängigkeit prüft. Das ist der klassische Fall eines Bugs, der nicht durch Tests entdeckbar ist, weil der Test nicht existiert.

Hätte ein menschlicher Entwickler diese Bugs geschrieben? Ja. Der Singleton-Snapshot-Bug ist ein bekanntes React-Anti-Pattern. String-Matching für HTTP-Status ist eine Abkürzung, die in vielen Projekten vorkommt. Optionale Felder, die eigentlich Pflichtfelder sein sollten, auch. Der Unterschied: ein erfahrener Entwickler erkennt diese Muster im Review. Das setzt voraus, dass es ein Review gibt.

Welches Modell war insgesamt am besten? Claude Opus und DeepSeek v4-pro teilen sich 8,5/10. DeepSeek v4-pro hatte die sauberste Architektur-Disziplin und lief out-of-the-box — für ein Modell, das als non-US-Alternative zu Claude getestet wurde, ist das das stärkste Ergebnis. Zur Kosten-Frage (DeepSeek war praktisch gratis, andere Modelle deutlich teurer) erscheint in Kürze Teil 4.


Zurück zu Teil 3a: Die letzte Meile — welche KI baut eine App, die startet.

Weiter zu Teil 4: Das billigste Modell kam fast an die Spitze — was die Läufe wirklich gekostet haben (erscheint in Kürze).

Reihe: Methodik & Setup · Grüne Tests beweisen nichts (Teil 1) · Selbstbewusst falsch (Teil 2) · Die letzte Meile (Teil 3a) · Subtile Bugs (Teil 3b) · Kosten-Effizienz (Teil 4) · Souveränität (Teil 5)

Fragen zu diesem Thema?

Ruf an. Wir erklären gerne.

04481 99 79 00 00
04481 99 79 00 00