Gefälschte ID > Artikel > Ein Test, der nicht fehlschlagen kann: Prüfen, ob Ihre grüne Suite überhaupt etwas prüft

Ein Test, der nicht fehlschlagen kann: Prüfen, ob Ihre grüne Suite überhaupt etwas prüft

Wir haben zehn Regressionstests für den Namensgenerator. Alle zehn waren grün.

Das ist kein Beweis. Eine Testsuite berichtet zwei Dinge — dass der Code besteht, und dass die Suite bemerken könnte, wenn er es nicht täte —, und ein Lauf der Suite sagt Ihnen stets nur das Erste. Das Zweite muss auf anderem Wege festgestellt werden.

Also stellten wir es auf die einzige Weise fest, die wirklich funktioniert: Wir haben den Code absichtlich kaputtgemacht, immer nur eine Sache auf einmal, und geprüft, dass der richtige Test rot wurde. Elf bewusste Defekte. Zehn wurden erkannt. Der elfte konnte nicht erkannt werden — aus einem strukturellen Grund, der mehr wert ist als die zehn Erfolge zusammen.

Und unterwegs stellten wir fest, dass einer unserer Tests zu einem früheren Zeitpunkt grün auf defektem Code gewesen war — nicht durch Nachlässigkeit im getesteten Code, sondern durch einen Zwei-Zeichen-Fehler in der eigenen Fixture des Tests.

Die Methode

Die Mechanik ist unspektakulär und in etwa einer Stunde aufgesetzt. Für jeden Defekt:

  1. Den gesamten Quellbaum in ein Scratch-Verzeichnis kopieren.
  2. Genau eine Mutation auf die Kopie anwenden — eine Datei, eine Änderung.
  3. Die Suite in der Kopie laufen lassen, beschränkt auf den Test, der sie fangen soll.
  4. Sicherstellen, dass der Status FAIL ist.
  5. Die Kopie wegwerfen.

Dann, als Kontrolle, die Suite einmal auf einer unmutierten Kopie laufen lassen und bestätigen, dass sie vollständig grün ist. Ohne diese Kontrolle können Sie „der Test hat die Mutation gefangen" nicht von „der Test war im Scratch-Baum von Anfang an kaputt" unterscheiden.

Beachten Sie, was Schritt 3 sicherstellt. Nicht „irgendein Test schlug fehl" — genau dieser Test schlug fehl. Eine Mutation, die eine Datendatei beschädigt, löst oft drei Tests auf einmal aus, und wenn Sie nur auf einen von null verschiedenen Exit-Code prüfen, schreiben Sie den Fang dem falschen Test zu und glauben weiter an einen Test, der in Wahrheit tot ist.

Die elf Mutationen

Jede ist ein plausibler Defekt, kein zufälliger Zeichentausch. Diese Unterscheidung ist wichtig: < in einer Schleifengrenze zu <= zu mutieren erzeugt Defekte, die niemand je schreiben würde. Was Sie wollen, ist die Gestalt eines Fehlers, der irgendwo tatsächlich in die Produktion gelangt ist.

#MutationZieldateiSoll fehlschlagen
1Jedes Stadt-Bevölkerungsgewicht mit 20 multiplizierenlocality/en_US.tsvTest 5
2Postleitzahlspalte um eine Zeile rotierenlocality/de_DE.tsvTest 7
3Einer Datendatei ein UTF-8-BOM voranstellenlocality/fr_FR.tsvTest 9
4Die Datei auf CRLF-Zeilenenden umstellenstreets/it_IT.tsvTest 9
5Die Geburtskohorte 1941 über die Kohorte 2001 kopierencached/en_US/Test 10
6Ein PHP-Schließtag in einem //-Kommentar platzierenneue DateiTest 8
7Die Index-Offsets in einer kompilierten .bin gegen ihre .txt rotierencached/zh_TW/Test 4
8mt_srand($keep) aus ng_pick() entfernenng_source.phpTest 1
9mt_srand($keep) aus ng_pick_or() entfernenng_source.phpTest 1
10mt_srand($keep) aus ng_pick_first() entfernenng_source.phpTest 1
11Den einzelnen Zufallszug des Pickers mit der aktuellen Mikrosekunde XOR-verknüpfenng_source.phpTest 2

Die Mutationen 1 und 5 sind die beiden Defekte, bei denen sich das Verweilen am meisten lohnt, weil beide echt sind. Mutation 1 ist eine synthetische Fassung eines Defekts, den wir tatsächlich ins Staging ausgeliefert haben: eine Ortsdatei, deren Gewichte Ballungsraum- statt Stadtwerte waren, wodurch sechzig taiwanische Städte in der Summe 106,9 % von Taiwan ergaben. Mutation 5 zeigt, wie ein defekter Kohorten-Build aussieht — Zwanzigjährige, die still die Vornamen von Achtzigjährigen erhalten, während jeder Name in der Ausgabe vollkommen echt ist.

Das Ergebnis: Zehn von elf Mutationen erzeugten den erwarteten FAIL. Eine nicht — und das war Mutation 8.

Der Test, der grün auf defektem Code war

Test 1 prüft eine Invariante, von der alles andere abhängt. Der Picker muss den mt_rand-Stream in derselben Position hinterlassen, ganz gleich, ob der angeforderte Datensatz existiert oder nicht. Tut er das nicht, dann ändert das Hinzufügen einer einzigen Datendatei zum Baum jedes Feld jeder generierten Karte, und jeder Permalink, den die Seite je ausgegeben hat, löst sich zu anderen Daten auf.

Die Umsetzung der Invariante ist ein Speichern-und-Wiederherstellen um die Verzweigung: eine Zahl ziehen, tun, was die Verzweigung verlangt, dann mt_srand($keep), um den Stream zurückzusetzen.

Die erste Version des Tests sah vollkommen vernünftig aus. Den Generator seeden, den Picker mit einem existierenden Datensatz aufrufen, den nächsten mt_rand() festhalten. Erneut seeden, den Picker mit einem nicht existierenden Datensatz und einem Fallback aufrufen, den nächsten mt_rand() festhalten. Sicherstellen, dass die beiden übereinstimmen.

Der Fallback war geschrieben als:

fn() => 'X'

Dieser Fallback rührt mt_rand nie an. Und das ist der ganze Defekt — im Test, nicht im Code.

Mit einem Fallback, der null Züge verbraucht, verbrauchen beide Zweige insgesamt genau einen Zug. Der Stream landet danach an derselben Stelle, ob das erneute Seeden existiert oder nicht. Die Zusicherung ist durch Arithmetik erfüllt, nicht durch Korrektheit. Der Test war grün, und er wäre grün geblieben, selbst wenn mt_srand($keep) vollständig aus der Datei gelöscht worden wäre.

Der echte Fallback ist ein Faker-Aufruf. Faker verbraucht eine unbekannte und variable Anzahl von Zügen — genau die Bedingung, zu deren Neutralisierung das erneute Seeden existiert, und genau die Bedingung, die die Fixture beseitigt hatte. Die Fixture hatte versehentlich das Einzige wegvereinfacht, was den Test aussagekräftig machte.

Die Lösung besteht darin, den Fallback bewusst hungrig zu machen — und zwar hungrig in unterschiedlichem Maße:

$burn = fn(int $k) => function () use ($k) {
    for ($i = 0; $i < $k; $i++) mt_rand();
    return 'X';
};

mt_srand(4242); ng_pick_first(['t/weighted', 't/flat'], $burn(3));  // dataset present
mt_srand(4242); ng_pick_first(['t/NEMA1', 't/NEMA2'],   $burn(3));  // absent, fallback burns 3
mt_srand(4242); ng_pick_first(['t/NEMA1', 't/NEMA2'],   $burn(10)); // absent, fallback burns 10

Drei Aufrufe, eine Zusicherung: Der nächste mt_rand() muss in allen dreien identisch sein. Der dritte Aufruf ist der, der das Gewicht trägt. Vorhanden-gegen-abwesend könnte zufällig bestehen; abwesend-verbrennt-3 gegen abwesend-verbrennt-10 kann es nicht, denn nur ein erneutes Seeden kann zwei verschiedene Appetite an derselben Stelle landen lassen. Die Mutationen 9 und 10 werden gegen diese Fixture beide rot. Gegen die alte wären sie es nicht geworden.

Die allgemeine Lehre handelt nicht von Zufallszahlen-Streams. Sie lautet: Eine auf Bequemlichkeit ausgelegte Fixture neigt dazu, genau jene Variation zu tilgen, zu deren Erkennung der Test existiert — und sie tut das lautlos, weil das Ergebnis ein bestehender Test ist.

Die Mutation, die nicht gefangen werden kann, und warum wir das festgehalten haben

Mutation 8 löscht dasselbe erneute Seeden aus ng_pick(), und Test 1 bleibt grün. Das ist keine Lücke, die man später schließt. Es ist eine strukturelle Grenze des Vergleichs, den der Test durchführt.

ng_pick() hat keinen Fallback. Es gibt nichts aufzurufen, wenn der Datensatz fehlt; die Funktion gibt null zurück. Also verbrauchen beide Zweige — Datensatz vorhanden, Datensatz abwesend — genau einen Zug, und der Vergleich vorhanden-gegen-abwesend ist erneut erfüllt, unabhängig davon, ob das erneute Seeden vorhanden ist. Der Trick, der ng_pick_or() und ng_pick_first() rettete, steht hier nicht zur Verfügung, weil es keinen zweiten Appetit zum Variieren gibt. Keine noch so große Cleverness in der Fixture ändert das; die beiden Zweige sind über die Stream-Position wirklich nicht zu unterscheiden.

Wir haben das in einem Kommentar direkt über dem Test festgehalten:

Wenn Sie mt_srand($keep) speziell aus ng_pick() entfernen, bleibt dieser Test grün, und das ist eine strukturelle Grenze, kein Versehen. Behandeln Sie einen grünen Test 1 nicht als Beweis dafür, dass das erneute Seeden in ng_pick() vorhanden ist.

Das erneute Seeden in ng_pick() wird also durch Code-Review und durch einen Kommentar im Quelltext an Ort und Stelle gehalten — nicht durch die Suite. Das ist eine schlechtere Garantie, und genau das auszusprechen ist der Punkt. Ein blinder Fleck, den Sie aufgeschrieben haben, ist ein bekanntes Risiko. Derselbe blinde Fleck, undokumentiert, ist eine Testsuite, die den Nächsten anlügt, der ihre grüne Ausgabe liest — höchstwahrscheinlich Sie selbst in acht Monaten.

Die andere Art von Test, der nicht fehlschlagen kann

Mutationsprüfung findet Tests, die versehentlich tot sind. Sie lenkt außerdem die Aufmerksamkeit auf Tests, die nie lebendig waren — und einen davon fanden wir in unserer eigenen Suite.

Wir hatten eine Prüfung, die pro Sprachregion drei Größen verglich: den Anteil eines Korpus, den seine zehn häufigsten Einträge halten, die vom Korpus behauptete Abdeckung der nationalen Bevölkerung und den daraus resultierenden Top-Ten-Anteil an der Bevölkerung. Für Taiwan gibt die Suite derzeit 52,81 % des Korpus aus, mal 99,9 % behaupteter Abdeckung, was 52,73 % ergibt.

Der beruhigende Teil ist die erste Zahl. Taiwans Innenministerium veröffentlicht für das vollständige Register einen Top-Ten-Anteil von 52,79 %. Unser Korpus errechnet 52,81 %. Zwei Hundertstel eines Prozentpunkts auseinander — genau die Art von Übereinstimmung, bei der man aufhört, genauer hinzusehen.

Die Lücke von zwei Hundertsteln ist selbst erklärbar statt geheimnisvoll, und es lohnt sich, sie festzunageln, weil ein vages „ungefähr 52,8 %" genau die Art ist, in der sich eine echte Diskrepanz verstecken würde. Die 52,79 % des Registers beziehen sich auf 2.731 Nachnamen und 23.373.283 Träger. Unsere 52,81 % beziehen sich auf 2.707 Nachnamen und 23.367.536 Träger, weil wir 24 unbrauchbare Zeilen fallenlassen: die Dienstkategorie 其他 („other", 5.174 Träger, kein Nachname) und 23 Zeilen im Private Use Area, wo das Ministerium seltene Schriftzeichen kodiert, die keine Unicode-Zuweisung haben und als leere Kästchen dargestellt würden. Das sind 5.747 Träger, 0,0246 % der Bevölkerung, aus dem Nenner entfernt. Ein etwas kleinerer Nenner bei denselben zehn häufigsten ergibt einen etwas größeren Anteil. Die Zahlen stimmen überein, weil die Bereinigung klein und korrekt war.

Nichts davon macht die Identitätsprüfung zu einem Test. Sie ist als Verifikation wertlos, und wertlos aus einem Grund, der sichtbar ist, ohne irgendetwas laufen zu lassen. Beide Seiten werden aus denselben gespeicherten Gewichten berechnet. Die behauptete Abdeckung ist die Summe dieser Gewichte geteilt durch die nationale Bevölkerung; der Top-Ten-Anteil sind die zehn häufigsten dieser selben Gewichte geteilt durch ihre Summe. Die Identität reduziert sich auf:

(a/b) × (b/c) = a/c

was für beliebige a, b und c überhaupt wahr ist. Füllen Sie den Korpus mit erfundenen Zahlen, und die Prüfung stimmt weiterhin auf zwei Nachkommastellen überein. Sie kann bei schlechten Daten nicht rot werden, weil sie nie darauf schaut, ob die Daten gut sind — nur darauf, ob die Division funktioniert.

Eine Sache kann sie fangen: ein künftiges Refactoring, das die Abdeckung auf einem anderen Weg berechnet und damit die arithmetische Identität selbst zerbräche. Das ist ein echter, wenn auch enger Wert. Also blieb die Prüfung, aber sie wurde aus dem Testsatz heraus und in eine informative Ausgabe herabgestuft — mit festem PASS-Status und einer über den Zahlen ausgegebenen Warnung, dass dies Selbstkonsistenz ist und keine Verifikation.

Diese Herabstufung ist der richtige Zug, wann immer Sie eine solche finden. Die Prüfung zu löschen kostet eine nützliche Zahl. Sie als Test zu belassen bläht die scheinbare Abdeckung der Suite mit einer Zeile auf, die nie fehlschlagen kann. Sie als Information auszugeben hält die Zahl vor Ihren Augen und hält sie aus der Zählung dessen heraus, was tatsächlich verifiziert wird.

Hier gehört ein Nachtrag hin, der in einen Artikel über Tests passt, die still aufhören, etwas zu bedeuten. Der erläuternde Kommentar über jener Prüfung zitierte ein durchgerechnetes Beispiel: 48,14 % des Korpus mal 109,8 % behaupteter Abdeckung ergibt 52,84 %. Als wir die Prüfung beim Schreiben dieses Artikels ausführten, gab sie stattdessen 52,81 %, 99,9 % und 52,73 % aus. Der Kommentar war nicht falsch, als er geschrieben wurde — er beschrieb einen Build, in dem der taiwanische Korpus auf seine 401 häufigsten Nachnamen gekürzt war, sodass die Gewichte die Bevölkerung überzeichneten und die Abdeckung über 100 % herauskam. Der Korpus liefert nun alle 2.707 Nachnamen mit echten Trägerzahlen aus, und die Abdeckung liegt knapp unter 100 %. Nichts ging kaputt; die Prosa hörte schlicht auf, zum Code zu passen, und nichts auf der Welt hätte es uns je gesagt. Kommentare, die Zahlen tragen, sind unverifizierte Behauptungen, die wie Dokumentation aussehen, und sie veralten genau in jene Richtung, in der sie plausibel bleiben.

Was eine grüne Suite wert ist

Drei Zustände sind leicht zu verwechseln und wert, sie ausdrücklich zu trennen:

ZustandWas der Suite-Lauf Ihnen sagtWas er Ihnen nicht sagt
Code korrekt, Test fähigGrün
Code defekt, Test fähigRot
Code korrekt, Test unfähigGrünDass der Test dekorativ ist
Code defekt, Test unfähigGrünDass der Code defekt ist

Die Zeilen drei und vier sind von Zeile eins durch keinen noch so häufigen Lauf der Suite zu unterscheiden. Nur Mutation unterscheidet sie, denn Mutation ist das, was Sie absichtlich in Zeile vier versetzt und fragt, welche Farbe herauskommt.

Das ist auch der Grund, warum Abdeckungsprozente ein schlechter Stellvertreter sind. Jede einzelne unserer elf Mutationen sitzt in Code, der bereits abgedeckt war — von der Suite ausgeführt, im Prozentsatz mitgezählt. Abdeckung misst, ob eine Zeile lief. Sie sagt nichts darüber, ob sich irgendetwas beschwert hätte, wenn diese Zeile falsch gewesen wäre. Unser alter Test 1 führte das erneute Seeden bei jedem Lauf aus und sicherte nichts darüber zu.

Die Checkliste

  1. Machen Sie den Code kaputt und verlangen Sie ein bestimmtes Fehlschlagen. Nicht „die Suite wurde rot" — dieser Test wurde rot. Sonst lässt eine breite Mutation einen toten Test den Fang eines lebendigen für sich verbuchen.
  2. Lassen Sie eine unmutierte Kontrolle laufen. Eine grüne Kontrolle ist das, was die roten Ergebnisse überhaupt bedeutsam macht.
  3. Mutieren Sie immer nur eine Sache auf einmal. Zwei gleichzeitige Mutationen verdecken einander, und Sie lernen über keine von beiden etwas.
  4. Wählen Sie Defekte, die jemand plausibel ausliefern würde, keine zufälligen Operatortausche. Falsche Einheiten in einer Datenspalte, ein veralteter Cache, ein verschobener Index, eine falsche Kodierung.
  5. Prüfen Sie Ihre Fixtures auf versehentliche Vereinfachung. Ein Fallback, der nichts tut, ein Stub, der eine Konstante zurückgibt, eine Fixture mit einer Zeile, wo die Produktion Tausende hat — so hört ein Test still auf, fehlschlagen zu können.
  6. Variieren Sie das, wogegen der Code unempfindlich sein soll. Behauptet der Code Robustheit dagegen, wie viel der Fallback verbraucht, muss die Fixture unterschiedliche Mengen verbrauchen. Ein einziger Wert testet nichts über Invarianz.
  7. Halten Sie Ausschau nach Identitäten, die sich als Prüfungen tarnen. Leiten sich beide Seiten aus derselben Quelle ab, schreiben Sie die Algebra aus. Reduziert sie sich auf etwas, das für alle Eingaben wahr ist, ist es kein Test.
  8. Stufen Sie herab, statt zu löschen — bei einer Prüfung, die nicht fehlschlagen kann: die Zahl als informative Ausgabe behalten, aus der Zählung der Tests entfernen.
  9. Schreiben Sie auf, was Ihre Suite nicht sehen kann. Ein dokumentierter blinder Fleck ist ein gesteuertes Risiko. Ein undokumentierter ist eine falsche Garantie mit Ihrem Namen darauf.

Wir führten diese Übung nicht durch, weil wir die Suite verdächtigten. Wir führten sie durch, weil ein grünes Ergebnis konstruktionsbedingt unfalsifizierbar ist, und die einzige ehrliche Antwort auf ein unfalsifizierbares Ergebnis lautet, die Falsifikation selbst zu konstruieren. Es kostete einen Nachmittag, es fand eine tote Zusicherung und eine Identität, die vorgab, ein Test zu sein, und es verwandelte eine vage Zuversicht in eine konkrete, aufgeschriebene Aussage: zehn Defekte, die diese Suite fängt, und einen, den sie nicht fängt.

Zur Invariante selbst und dazu, warum der Generator den Stream speichert und wiederherstellt, siehe Wie unser Generator funktioniert. Zum echten Defekt, den Mutation 1 nachahmt — sechzig Städte, die in der Summe 106,9 % eines Landes ergeben —, siehe Sechzig Städte, 106,9 % eines Landes. Zur Selbstkonsistenz-Falle in ihrem ursprünglichen Kontext siehe Datenquellen: Taiwan.


Daten mit Stand 2026-07-18

Alle Zahlen wurden am 18. Juli 2026 neu aus den Dateien auf der Platte abgeleitet, und der Mutationslauf wurde am selben Tag gegen den ausgelieferten Baum ausgeführt.

Quellen und Anmerkungen:

  • Testsuiteng_regression_tests.php, ausgeliefert in source_share_1.1.8/set/. Zehn Tests, in einem Register am Kopf der Datei mit den Schlüsseln 1–10 geführt: (1) mt_rand-Stream-Invariante, (2) Kartendeterminismus nach Seed, (3) Gewichtsverteilung und Gleichverteilung der Flat-Files, (4) empirische gegen theoretische Top-10, (5) Summe der Stadtbevölkerungen gegen die nationale Bevölkerung, (6) Nenner-Identität (INFO, keine Verifikation), (7) Stadt-↔-Postleitzahl-Paarung, (8) Schließtag-im-Zeilenkommentar-Falle, (9) Datenkodierung (BOM, UTF-16, CRLF, fehlerhaftes UTF-8), (10) Geburtskohorten, die Generationen wirklich trennen.
  • Mutations-Harnessng_regr_mutation_check.php, ein Arbeitsskript statt eines ausgelieferten Artefakts. Elf Mutationen wie oben tabelliert; kopiert den Baum mit robocopy, wendet eine Mutation an, lässt die Suite mit --only=N laufen und stellt sicher, dass der geparste Status von Test N FAIL ist. Endet mit einem unmutierten Kontrolllauf.
  • Der Lauf vom 18. Juli 2026 — zehn gefangen, eine verpasst. Die Verpasste ist Mutation 8 (ng_pick), bei der Test 1 PASS meldete, weil beide Zweige denselben nächsten mt_rand-Wert zeigten; es ist eine konstruktionsbedingte Verpassung, kein Zufall. Kontrolllauf auf einer unmutierten Kopie: tests: 10 · PASS 10 · FAIL 0 · WARN 0 · SKIP 0 · ERROR 0 · time 15.1 s.
  • Der Fallback-Defekt — im Quelltext an der Fixture festgehalten, die besagt, dass ein Fallback, der mt_rand nicht anrührt, beide Zweige genau einen Zug ausführen lässt, sodass der Stream selbst ohne erneutes Seeden im Code übereinstimmt — und dass der Test auf diese Weise einst grün auf defektem Code war. Die aktuelle Fixture verbrennt 3 bzw. 10 Züge.
  • Die strukturelle Grenze — in einem Kommentar über Test 1 festgehalten und dort als durch Mutation verifiziert statt angenommen beschrieben. ng_pick() hat keinen Fallback-Zweig, sodass vorhanden-gegen-abwesend die Anwesenheit des erneuten Seedens nicht unterscheiden kann.
  • Die Nenner-Identität — Test 6 trägt ein festes PASS und gibt eine Warnung aus, dass es sich um Selbstkonsistenz handelt, nicht um Verifikation. Seine Live-Ausgabe am 18. Juli 2026 lautet zh_TW lastname_male · 52.81% top-10-in-corpus · 99.9% coverage · 52.73% product, für diesen Artikel neu ausgeführt. Das Beispiel 48.14% / 109.8% / 52.84%, das noch im Kommentar über jenem Test steht, beschreibt einen überholten 401-Einträge-Build und ist veraltet; data-sources-taiwan.md wurde am 18. Juli 2026 korrigiert und trägt nun die aktuellen Zahlen. Beachten Sie, dass sich die 99,9 % der Suite und die 99,98 % des Artikels nur im Nenner unterscheiden — die Suite teilt durch gerundete 23.400.000, der Artikel durch die Registerbasis 23.373.283.
  • Die taiwanischen Zahlen — am 18. Juli 2026 direkt aus cached/zh_TW/lastname_male.wgt neu berechnet: 2.707 Einträge, Gewichte mit einer Summe von 23.367.536, die zehn häufigsten mit einer Summe von 12.339.778, was 52,8074 % des Korpus ergibt. Gegen die vollständige Registerbasis von 23.373.283 sind dieselben zehn häufigsten 52,7944 %. Das Ministerium veröffentlicht 52,79 % über 2.731 Nachnamen. Die Differenz von 5.747 Trägern zwischen den beiden Nennern sind genau die 24 ausgeschlossenen Zeilen: 其他 mit 5.174 Trägern plus 23 Zeilen im Private Use Area. Die beiden Anteile sind daher zwei verschiedene Messungen, kein Widerspruch.

← Artikel