12k
All articles

Testen im KI-Zeitalter: KI zum Schreiben von Tests einsetzen

KI-generierte Tests können die Coverage erhöhen, ohne Bugs zu finden. Nutze anforderungsbasierte Prompts, Mutationstests und Mock-Review.

OpenReplay Team
OpenReplay Team
Testen im KI-Zeitalter: KI zum Schreiben von Tests einsetzen

Ein Test, dessen Erwartungswert durch Ausführen des zu testenden Codes entstanden ist, kann nur bestätigen, was der Code bereits tut; steckt der Fehler schon darin, schreibt der Test ihn fest.

Wer Tests mit einem Assistenten generiert, kennt das Muster vielleicht: Der PR fügt vierzig Tests hinzu, die Coverage steigt um zwei Punkte, die CI ist grün – und derselbe Billing-Bug landet am Donnerstag erneut in der Produktion. Die Tests hatten nie die Möglichkeit, dem Code zu widersprechen.

Dieser Artikel behandelt die drei Formen, die generierte Tests annehmen, wenn sie bestehen, ohne etwas zu schützen, sowie die drei Gegenmaßnahmen: die Anforderung statt der Implementierung als Prompt-Grundlage nehmen, den Code absichtlich kaputt machen und die Mocks vor den Assertions lesen.

Die wichtigsten Erkenntnisse

  • Line Coverage erfasst, welche Zeilen ausgeführt wurden, und erfasst nichts darüber, ob eine Assertion fehlschlagen würde, falls diese Zeilen ein falsches Ergebnis liefern.
  • Ein von Hand berechneter Erwartungswert und einer, der aus der Ausgabe der Funktion kopiert wurde, sehen im Diff identisch aus; sie unterscheiden sich nur in ihrer Herkunft – genau deshalb passieren generierte Tests das Review.
  • Bittet man ein Modell, Tests für einen Funktionsnamen zu schreiben, steht ihm nur die Implementierung als Grundlage zur Verfügung; liefert man die Geschäftsregeln mit, erhält es eine Quelle der Wahrheit, die dem Code widersprechen kann.
  • Die schnellste Prüfung einer generierten Testsuite besteht darin, eine Konstante zu ändern oder einen Vergleich im Quellcode umzudrehen und erneut auszuführen; bleibt alles grün, hat nichts diese Zeile geschützt.
  • Ersetzt ein Test die Funktion, die er zu testen behauptet, durch einen Mock, prüft seine Assertion den konfigurierten Rückgabewert des Mocks – ein solcher Test sollte gelöscht und nicht reparariert werden.

Warum steigt die Coverage, während Bugs weiterhin in Produktion gehen?

Coverage misst Ausführung, nicht Verifikation. Die Coverage-Provider von Jest (standardmäßig Babel/Istanbul, optional V8) und die Coverage-Provider von Vitest (standardmäßig V8, optional Istanbul) berichten dieselben vier Metriken: Statements, Branches, Functions und Lines. Keiner von beiden bewertet, ob irgendeine Assertion fehlschlagen könnte. Ein Test, der eine Funktion aufruft und auf das prüft, was zurückkam, deckt jede berührte Zeile genauso ab wie ein Test mit einer korrekten Assertion.

Deshalb klettert die Zahl weiter nach oben, während Bugs weiterhin ausgeliefert werden. Generierte Tests sind günstig, also wird mehr Code unter Test ausgeführt. Aber ein Test, der aus der Implementierung abgeleitet ist, teilt jeden Defekt mit der Implementierung – und der Coverage-Report hat dafür keine Spalte.

Fehlerbild eins: Der Test behauptet, was der Code ohnehin zurückgibt

Ein Modell, dem nur der Quellcode vorliegt, hat für Erwartungswerte genau ein Orakel: den Quellcode. Es schließt auf das Verhalten der Funktion (oder führt sie aus), beobachtet die Ausgabe und schreibt diese Ausgabe als Literal in die Assertion. Ist die Funktion falsch, ist das Literal auf dieselbe Weise falsch.

Nehmen wir eine Discount-Funktion mit einem Copy-Paste-Bug:

// discount.ts
export function calculateDiscount(price: number, tier: "silver" | "gold"): number {
  const rate = tier === "gold" ? 0.25 : 0.25; // bug: silver should be 0.15
  return price * (1 - rate);
}

Die folgende describe/it/expect-API funktioniert sowohl in Jest als auch in Vitest; Jest stellt sie global bereit, während Vitest einen Import oder globals: true erfordert.

import { calculateDiscount } from "./discount";

// Generated from the implementation. 75 is what the buggy function returned.
it("applies silver discount", () => {
  expect(calculateDiscount(100, "silver")).toBe(75);
});

// Written from the rule: silver is 15% off, so 100 * 0.85.
it("charges 85 for a 100 silver order", () => {
  expect(calculateDiscount(100, "silver")).toBe(85);
});

Der erste Test besteht gegen die fehlerhafte Funktion. Der zweite schlägt fehl – und genau das ist der Punkt. Im Diff sehen 75 und 85 gleichermaßen legitim aus. Nichts in der Syntax verrät einem Reviewer, dass der eine Wert aus der Preisregel berechnet und der andere aus der Funktionsausgabe kopiert wurde. Der einzige Schutz ist die Frage, woher die Zahl stammt.

Fehlerbild zwei: Generierte Tests decken nur den Happy Path ab

Eine generierte Testsuite testet, was der Prompt beschrieben hat – und ein Prompt, der lediglich eine Funktion benennt, beschreibt nur deren Normalbetrieb. Die Fälle, die eine generierte Suite am häufigsten auslässt (fehlerhafte Eingaben, eine nicht erreichbare Abhängigkeit, ein Timeout), sind genau jene, die im Prompt nie erwähnt wurden – das Modell hatte also keinen Anlass, sie zu schreiben.

Das Ergebnis sind fünf nahezu identische Tests für wohlgeformte Eingaben mit gültigen Tiers und keiner für einen negativen Preis, einen unbekannten Tier-String oder einen Upstream-Service, der nie antwortet. Das sind die Pfade, die untestet in Produktion gelangen – denn es sind auch die Pfade, die Entwickler am seltensten von Hand durchspielen.

Fehlerbild drei: Die getestete Unit ist gemockt

Ersetzt ein Test die Funktion, die er zu testen behauptet, durch einen Mock, verifiziert seine Assertion den konfigurierten Rückgabewert des Mocks und nicht das Verhalten der Funktion. Modelle mocken aggressiv, weil Mocking Tests zuverlässig bestehen lässt – und ein Test, der die Datenbank, den Netzwerk-Client und die zu testende Funktion mockt, besteht unter jeder Implementierung.

Das Muster lässt sich mit einer injizierten Abhängigkeit leichter erkennen, was das Beispiel zugleich frei von Unterschieden zwischen jest.mock und vi.mock hält:

// cart.ts
import type { calculateDiscount } from "./discount";

export function cartTotal(price: number, tier: "silver" | "gold",
                          discount: typeof calculateDiscount): number {
  return Math.round(discount(price, tier) * 100) / 100;
}

// cart.test.ts
import { cartTotal } from "./cart";

// Before: the fake is the thing being checked.
it("returns discounted total", () => {
  const fake = () => 85;
  expect(cartTotal(100, "silver", fake)).toBe(85);
});

// After: assert on what cartTotal did, using a fake that exposes it.
it("rounds the discounted price to cents", () => {
  const fake = () => 85.004999;
  expect(cartTotal(100, "silver", fake)).toBe(85);
});

Der „Before“-Test würde selbst dann bestehen, wenn cartTotal seine Argumente ignorierte und einfach 85 zurückgäbe. Der „After“-Test übergibt dem Fake einen Wert, der nur dann korrekt herauskommt, wenn die Rundungslogik tatsächlich läuft – er beobachtet also die Funktion und nicht den Stub.

Gegenmaßnahme eins: Der KI-Testgenerierung die Anforderung geben, nicht den Code

Bittet man ein Modell, Tests für einen Funktionsnamen zu schreiben, steht ihm nur die Implementierung als Grundlage zur Verfügung. Liefert man die Geschäftsregeln als ausformulierte Anforderungen mit, erhält es etwas, das der Code nicht liefern kann: eine Spezifikation, die dem Code widersprechen kann.

Der schwache Prompt:

Write unit tests for calculateDiscount.

Der stärkere Prompt:

Write unit tests for calculateDiscount in discount.ts.

Rules:
- Silver tier is 15% off the price.
- Gold tier is 25% off the price.
- Price must be non-negative; a negative price throws.
- The result is rounded to two decimal places.

Compute every expected value from these rules, not from the
current implementation. Include at least one invalid-input case
per rule. Name each test as the rule it checks.

Der zweite Prompt erzeugt expect(calculateDiscount(100, "silver")).toBe(85), weil 85 dem entspricht, was die Regel vorgibt – und er schlägt gegen die fehlerhafte Funktion beim ersten Lauf fehl. Testnamen, die sich wie Regeln lesen („charges 85 for a 100 silver order“), machen die Suite zudem als Spezifikation reviewbar. Der Prompt garantiert nicht, dass das Modell den Quellcode ignoriert; er gibt dem Modell eine Quelle der Wahrheit, die über dem Quellcode steht.

Gegenmaßnahme zwei: Den Code kaputt machen und auf Rot achten

Die schnellste Prüfung einer generierten Testsuite besteht darin, eine Konstante zu ändern oder einen Vergleich im zu testenden Code umzudrehen und die Tests erneut laufen zu lassen. Bleibt alles grün, hat die Suite diesen Code nicht geschützt.

1. Fix the bug in discount.ts (silver rate to 0.15), then change 0.15 to 0.05.
2. Run your test command.
3. Expected: "charges 85" fails with received 95.
   If nothing fails, no test asserts the silver rate.

Mutation Testing automatisiert das. StrykerJS (@stryker-mutator/core) injiziert kleine Änderungen in den Quellcode, führt die Suite für jede davon erneut aus und berichtet einen Mutation Score: das Verhältnis der von den Tests erkannten Mutanten (ein Test schlug fehl oder der Lauf lief in einen Timeout) zur Anzahl der validen Mutanten. Die unterstützten Mutatoren drehen Vergleichsoperatoren um, negieren Booleans, tauschen arithmetische Operatoren, leeren String-Literale und entfernen Block-Rümpfe. Runner-Plugins existieren für Jest und für Vitest; prüfe die Kompatibilität des Plugins mit deiner installierten Version, bevor du es einsetzt.

Gegenmaßnahme drei: Die Mocks vor den Assertions lesen

Prüfe ein Diff mit generierten Tests in dieser Reihenfolge: Was ist gefakt, woher kamen die Erwartungswerte, und erst dann: was wird behauptet. Was gefakt ist, bestimmt, was der Test überhaupt beobachten kann. Woher der Erwartungswert kam, bestimmt, ob die Assertion dem Code widersprechen kann. Die Assertion selbst ist die am wenigsten aussagekräftige Zeile.

  • Ein Mock, der für die zu testende Unit einsteht: Test löschen.
  • Ein Erwartungswert, der ein Funktionsaufruf ist, oder ein Literal ohne dahinterliegende Regel: aus der Anforderung neu berechnen.
  • Mocks nur für echte Systemgrenzen (Datenbank, Netzwerk, Uhr) und Assertions auf die eigene Ausgabe der Unit: behalten.

Das Urteilsvermögen behalten

KI ist zuverlässig bei den mechanischen Teilen des Testens: Fixtures, Setup und Teardown, parametrisierte Tabellen nahezu identischer Fälle, Boilerplate für asynchrone Fehlerpfade. Sie ist unzuverlässig bei der Entscheidung, welche Verhaltensweisen es wert sind, geprüft zu werden – denn diese Entscheidung steckt in der Anforderung, nicht im Code. Generiere das Gerüst, nimm dann den wichtigsten Test im Diff, mach die Zeile kaputt, die er absichern soll, und stelle sicher, dass er rot wird, bevor du mergst.

FAQs

Ersetzt Mutation Testing die Code Coverage?

Nein. Beide berichten unterschiedliche Lücken. Coverage sagt dir, welche Zeilen unter keinem Test ausgeführt wurden; Mutation Testing sagt dir, welche ausgeführten Zeilen von keiner Assertion geschützt werden. StrykerJS berichtet beides: Ein Mutant in nicht ausgeführtem Code erhält den Status No coverage, während ein Mutant in ausgeführtem Code, bei dem trotzdem alle Tests bestehen, den Status Survived erhält. Der Mutation Score zählt beides als nicht erkannt, sodass geringe Coverage den Score direkt senkt.

Funktioniert StrykerJS mit Vitest und Jest?

Ja, über Runner-Plugins. Für Vitest installierst du @stryker-mutator/vitest-runner und setzt testRunner in der Stryker-Konfiguration auf vitest; das Plugin liefert Vitest nicht selbst mit, lies also dessen package.json, um die älteste akzeptierte Vitest-Version zu finden. Tests, die über Vitests Browser Mode laufen, liegen außerhalb dessen, was der Runner abdeckt. Für Jest nutzt du @stryker-mutator/jest-runner. Unter dem Vitest-Runner schaltet Stryker Vitests eigene Coverage-Erfassung ab und lässt jeden Mutantenlauf beim ersten fehlschlagenden Test abbrechen, denn ein Fehlschlag genügt, um den Mutanten zu töten.

Wie erkenne ich KI-generierte Tests, die überhaupt keine Assertion enthalten?

Lass das Framework jeden Test mit null Assertions fehlschlagen. In Vitest setzt du expect.requireAssertions in der Konfiguration oder übergibst --expect.requireAssertions auf der CLI; ein Test, der ohne Aufruf von expect endet, schlägt fehl. In Jest rufst du expect.hasAssertions() im Testkörper auf oder aus einem beforeEach-Hook, um es überall anzuwenden. Keine der beiden Einstellungen prüft, ob eine Assertion fehlschlagen könnte – ein Test, der den eigenen Rückgabewert eines Mocks prüft, besteht also weiterhin.

Kann man KI-generierten Snapshot-Tests vertrauen?

Nicht ohne Review. Ein Snapshot, der von der aktuellen Ausgabe aufgezeichnet wurde, behauptet nur, dass der Code weiterhin das produziert, was er zum Zeitpunkt der Aufzeichnung produziert hat – Bugs inklusive. Das ist dieselbe Tautologie wie ein kopiertes Literal. Das Neuaufzeichnen ist nur ein Flag entfernt: jest -u und vitest -u überschreiben jeden fehlschlagenden Snapshot. In der CI lässt Jests --ci-Flag neue Snapshots fehlschlagen, statt sie stillschweigend zu schreiben. Prüfe Snapshot-Diffs gegen die Anforderung, nicht gegen die vorherige Ausgabe.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.