Jeder Ingenieur misstraut einem roten Build. Das gefährliche Artefakt ist das grüne, denn Grün trägt eine implizite Behauptung: Dies wurde gemessen, und es hat gehalten. Diese Behauptung hat zwei Fehlermodi, und nur einer ist berühmt. Ein Test kann sich über das System irren. Ein Test kann auch über das Nichts recht haben: gerichtet auf ein Artefakt, das niemand ausliefert, eine Datei, die niemand lädt, ein Versprechen, das niemand laufen lässt. Er besteht für immer, und er schützt nichts.

Wir haben eine Saison lang Exemplare der zweiten Art gesammelt, in unseren eigenen Repositories, wo eine solche Sammlung beginnen sollte. Fünf davon, mit dem Code, und die Gewohnheit, die jedes früher gefangen hätte.

Exemplar eins: die Suite, die einen Geist prüfte

Beim Migrieren einer Website zwischen zwei Renderern las unsere Suite das veröffentlichte Ausgabeverzeichnis und prüfte Hunderte von Eigenschaften dagegen:

Python
SWA = ROOT / "swa"                    # the directory the deploy uploads

def test_every_route_declares_its_canonical():
    for page in SWA.rglob("index.html"):
        assert '<link rel="canonical"' in page.read_text()

Wochenlang grün. Vierhundertzwanzig Routen geprüft. Das Problem: swa/ wurde weiterhin vom alten Renderer geschrieben. Der neue, der der ganze Zweck des Projekts war, schrieb nach .build/astro/, und kein Test öffnete diesen Ordner. Wir prüften das Artefakt, das wir ersetzten.

Das Heilmittel waren nicht mehr Assertions. Es war eine Frage an die ganze Suite, welche Bytes öffnet dies wirklich?, beantwortet dadurch, dass die Suite zuerst das publizierbare Artefakt komponiert und ein Test hinzukommt, dessen einzige Aufgabe es ist zu scheitern, wenn das veröffentlichte Verzeichnis nicht diese Komposition ist:

Python
def test_the_published_artefact_is_the_composition():
    # `data-page-owner` is written by the new renderer and nothing else.
    # If no published page carries it, swa/ is still the old site.
    owned = [p for p in SWA.rglob("*.html") if "data-page-owner" in p.read_text()]
    assert owned, "the published artefact is the old renderer; the composition never shipped"

Langweilig, strukturell, und der wertvollste Test des Repositories, weil er die eine Beziehung validiert, die alle anderen still voraussetzen.

Exemplar zwei: das Tor, das Text las, nicht Verhalten

Unser Evidenz-Collector trägt ein hartes Versprechen: nur lesen. Erzwungen wird es, indem jede PowerShell-Datei geparst und der Syntaxbaum nach mutierenden Verben durchlaufen wird:

Python
def test_no_powershell_file_has_a_write_path():
    for f in COLLECTOR.rglob("*.ps1"):
        ast = parse_powershell(f.read_text())
        verbs = [c.command_name for c in ast.find_all(CommandAst)]
        assert not [v for v in verbs if v.split("-")[0] in MUTATING], f

Ein gutes Tor. Auch ein Tor über Text. Ein Release lehrte uns den Unterschied: Ein verschachteltes Import-Module -Force entlud einen geteilten Helfer aus der Session des Aufrufers, und der Collector konnte überhaupt nicht laufen, auf keinem Tenant und in keinem Modus:

PowerShell
# The defect. -Force on a nested import re-imports the module fresh,
# evicting the already-loaded copy from the caller's session.
Import-Module (Join-Path $PSScriptRoot 'Evidence.psm1') -Force   # Initialize-Evidence now gone upstream

Jedes Text-Tor blieb grün, denn der Text war tadellos. Das Programm, das der Text beschrieb, war kaputt. Die Korrektur war eine zweite Wache, die die Module genau so lädt, wie der Collector sie lädt, und die lebende Session fragt, was überlebt hat:

PowerShell
Describe 'the modules load in the collector''s own order' {
    It 'keeps Initialize-Evidence reachable after the siblings import' {
        Import-Module ./modules/Evidence.psm1
        Import-Module ./modules/Sharing.psm1        # must not evict the above
        Get-Command Initialize-Evidence -ErrorAction Stop | Should -Not -BeNullOrEmpty
    }
}

Die Lektion reicht über PowerShell hinaus: Fragen Sie für jede statisch erzwungene Eigenschaft, was geschieht, wenn der Code die Prüfung besteht und die Sache trotzdem nicht tut. Würde nichts es bemerken, beweist Ihr Tor Analysierbarkeit, nicht Verhalten, und sollte das laut sagen, statt die Autorität der stärkeren Behauptung zu borgen.

Exemplar drei: die veröffentlichte Tabelle, die nie lief

Unsere Engine dokumentiert, wie sie über unvollständige Evidenz schließt: eine Tabelle aus Operatoren und Schranken, welche Seite entscheiden kann, welche nie:

Text
operator      lower bound proves    what it cannot prove
>  (min)      pass                  fail  (needs an upper bound)
not-contains  fail on known part    pass  (needs the whole set)
==            nothing               decides only when single and fully observed

Sie stand im Architekturdokument, zitiert, poliert, und wurde, wie ein Coverage-Bericht zeigte, kaum von einem Test ausgeführt. Wahr am Tag ihrer Niederschrift und für immer unbewacht; jeder Refactor konnte still eine Zeile umkehren, und das Dokument hätte mit ernstem Gesicht das alte Verhalten weiter versprochen. Also wurde die Tabelle ausführbar, ein Fall pro Zelle, generiert aus derselben Quelle, die das Dokument darstellt:

Python
@pytest.mark.parametrize("operator,bound,expected", CASES_FROM_THE_TABLE)
def test_each_cell_of_the_published_table(operator, bound, expected):
    assert decide(operator, bound) is expected

def test_a_bound_of_none_decides_nothing():
    assert decide(">", bound=None) is Outcome.UNKNOWN

Eine veröffentlichte Garantie läuft in der CI, oder sie ist eine Hoffnung mit Typografie.

Exemplar vier: die Fixture, die dem Test schmeichelte

Das leiseste. Ein Test bewertet eine Regel und erwartet unknown; er besteht:

Python
def test_denied_sharing_run_is_all_unknown():
    doc = load("coverage-denied-sharing.json")
    assert doc.counts.unknown == 13        # ← where does 13 come from?

Die Fixture war erzeugt worden, als ein Auswahlfehler die Engine alle Regeln statt der zwei des Profils ausführen ließ. Die 13 war aus dieser kaputten Ausgabe abgeschrieben. Sie war keine Spezifikation; sie war das Fossil eines Defekts, am Leben gehalten von einem Fixture-Verzeichnis, das nichts auffrischte. Wir fingen es erst, als die Fixtures aus der aktuellen Engine regeneriert wurden und das Fossil der Wirklichkeit widersprach: das Profil wählt zwei Regeln, die ehrliche Zahl war also drei, nicht dreizehn.

Die Gewohnheit, die es früher fängt, ist ein Kommentar:

Python
    # Three, because `--profile sharing` selects three rules since SPO-SHARE-005.
    # It read thirteen for as long as a bare profile name failed open to every
    # rule on disk, and this fixture was generated then.
    assert doc.counts.unknown == 3

Eine angeheftete Zahl, die Sie nicht belegen können, ist eine Zahl, abgeschrieben von dem, was das System am Tag des Testschreibens tat, und das System konnte an jenem Tag falsch liegen.

Exemplar fünf: die Tests, die die Regeln prüften und nie die Verdrahtung

Das größte, und das nächste zu Hause. Fünfundsechzig Tests, alle grün, deckten eine API Regel für Regel ab: Validierung, Ratengrenzen, die Obergrenze der Moderation, die Form eines Tokens. Keiner öffnete den Socket, den eine Anfrage öffnet. Jeder Test übte einen reinen Helfer; keiner steuerte den HTTP-Handler, der diese Helfer zu einer Antwort zusammenfügt. Die Regeln waren bewiesen. Die Verdrahtung dazwischen war angenommen.

Hinter diesem Grün saß ein Mechanismus, der in der Produktion nicht funktionieren konnte. Eine Newsletter-Ausgabe hält fest, wer sie schon erhalten hat, damit ein erneuter Lauf nur an die Fehlenden sendet. Der Marker geht in eine Tabelle namens newssent:

TypeScript
// The whole idempotency of a send rests on this row, and nothing creates the
// table it lives in.
await marker().upsertEntity(
  { partitionKey: editionId, rowKey: key(subscriber.email) },
  "Replace",
);

Die Produktion hatte keine newssent-Tabelle. Der erste Versand schickte die Ausgabe, scheiterte daran, den Marker in eine Tabelle zu schreiben, die nicht existierte, und meldete den Empfänger als Fehler. Schlimmer: die Wache, die den Marker liest, behandelt eine fehlende Tabelle als „noch nicht gesendet", also schickte jeder erneute Lauf die ganze Liste noch einmal. Die Idempotenz war bei der Geburt tot, und jeder Test blieb grün, weil das In-Memory-Double, das jeder Test benutzte, auf createTable mit einem Achselzucken und auf upsert mit Erfolg antwortete. Ein Falsches kann nicht nicht existieren.

Was es maß, war keine weitere Assertion. Es war derselbe Handler, gegen ein echtes, lokal laufendes Table Storage gesteuert, die persistierte Zeile zurückgelesen:

TypeScript
// Real handler, real Table Storage, real OData. Only a table that can truly be
// absent exposes the missing create; only a real query proves the filter.
const res = await handler("newsletter-send")(admin(edition), ctx());
const written = await readAll(sent);       // read the state back, do not trust the return
assert.equal(written.length, res.sent);    // "sent" must imply "recorded"

Die Korrektur war eine Zeile, die die anderen Speicher schon hatten, ein defensives createTable vor der Schleife. Der Defekt ist nicht der Punkt. Der Punkt ist, dass eine Suite jede Regel beweisen kann, die ein System besitzt, und nie beweist, dass das System läuft, und die Lücke bleibt unsichtbar, bis ein Test denselben Socket und denselben Speicher öffnet, den die Produktion öffnen wird.

Eine Garantie lohnt es sich hier zu benennen, denn sie zu benennen ist die halbe Arbeit. Die Zustellung einer bestätigten Nachricht an den Posteingang, und einer Newsletter-Ausgabe, ist mindestens einmal (at-least-once): ein Absturz zwischen dem Versand und seiner Aufzeichnung wiederholt den Versand beim erneuten Lauf, er lässt ihn nie fallen. Das ist eine Wahl. Ein Duplikat ist ein Ärgernis; eine verlorene Nachricht ist ein Kunde. Der Test behauptet nicht „genau einmal", was eine Lüge wäre; er behauptet, dass nach einem Absturz an jedem Punkt die Arbeit noch wiederherstellbar ist und nichts still übersprungen wird.

Grün ist eine Behauptung, kein Zustand

Jeder bestehende Test behauptet eine Beziehung zwischen drei Dingen: dem Code, dem Artefakt unter Test und der Welt, die das Artefakt treffen wird. Jedes Exemplar brach ein anderes Bein. Die Suite maß das falsche Artefakt. Das Tor maß Text statt Verhalten. Das Dokument versprach, was nichts ausführte. Die Fixture fror eine kaputte Welt ein und nannte sie erwartet. Die letzte Suite prüfte jede Regel und führte sie nie zusammen aus.

Das Heilmittel ist eine Frage in fünf Verkleidungen, und sie lohnt sich heute an Ihrer grünsten Suite: Wenn dies aufhörte, wahr zu sein, würde hier irgendetwas rot? Nehmen Sie Ihr beruhigendstes Dashboard, Ihr stabilstes CI-Abzeichen, und folgen Sie einem grünen Licht bis zu den Bytes, die es öffnet. Endet die Spur bei einem Artefakt, das niemand ausliefert, einem Versprechen, das niemand laufen lässt, oder einer Zahl ohne Quelle, dann misst das Licht nicht das System.

Es dekoriert es.

Schlagwörter#testing#engineering#ci#havecode

Kommentare