Ein Klick auf „Duplizieren“ in der Projektliste, ein roter Hinweis „Das Projekt konnte nicht dupliziert werden“, sonst nichts. Im Server-Log steht der Grund: Eine Prozedur liest eine Spalte, die es seit fünf Monaten nicht mehr gibt. In diesen fünf Monaten lief der Deploy auf vier Umgebungen grün durch, die Produktion eingeschlossen. Der Konventions-Check hatte nichts zu beanstanden, und mehr als tausend Unit-Tests waren grün. Keines dieser Netze hatte die Prozedur je aufgerufen. PL/pgSQL löst die Spaltennamen in einem Rumpf erst beim Aufruf auf, und in einer leeren Wegwerf-Datenbank ruft nichts die Prozedur auf.
Dieser Artikel zeigt, warum es für das Verhalten von Stored Procedures in Postgres keine billige Test-Ebene gibt, und wie sich Stored Procedures testen lassen, ohne dass dafür ein Werkzeug installiert wird. Das Mittel sind Sonden, im Test-Vokabular Integrationstests gegen die echte Datenbank: kleine SQL-Skripte, die in der Wegwerf-Datenbank des Gates einen Ausgangszustand aus synthetischen Daten bauen, die echte Prozedur aufrufen und jede Anforderung einzeln prüfen.
Das Wichtigste vorab:
- Drei Netze, eine Lücke: Ein Konventions-Check prüft die Form, der Deploy prüft, dass sich alles anlegen lässt, und Unit-Tests enden vor der Datenbank. Das Verhalten einer Prozedur prüft keines davon.
- Eine Sonde ist ein SQL-Skript, das Testdaten anlegt, die echte Prozedur aufruft und jedes Ergebnis in eine Tabelle schreibt. Geprüft wird außerhalb von SQL, und bei Rot steht zuerst der gespeicherte Zustand da, nicht eine Vermutung.
- Der Test besteht zum größten Teil aus Aufbau: Im DI²-Projekt entfallen 85 Prozent des Sonden-Codes auf die SQL-Dateien, die Testdaten anlegen und die Prozedur aufrufen, 7.266 Zeilen in 22 Sonden. Die Prüfungen dazu umfassen 1.328 Zeilen Shell-Skript.
- Eine Sonde gilt erst, wenn sie gegen den kaputten Stand rot wird. Für Leistungsanforderungen braucht es Mengen per
generate_series, ein Zeit-Gate im CI ist trotzdem keine gute Idee. - Transparenz: Skript, Fixtures und Prüfungen des Projekts stammen aus Claude-Code-Sessions. Der Maintainer hat die Fehler gemeldet oder die Prüfungen angestoßen, die Negativ-Proben kontrolliert und die Entscheidungen getroffen, um die es hier geht.
Voraussetzung: Postgres und psql, ein CI-Lauf mit Wegwerf-Datenbank, wie ihn GitHub Actions für Postgres-Deploys beschreibt, und Grundkenntnisse in PL/pgSQL. Alle Code-Blöcke sind gegen PostgreSQL 17.11 in einem Wegwerf-Docker-Container ausgeführt, einschließlich der Negativ-Probe und der Messung. Die Beispiele verwenden eine kleine eigene Domäne aus Kunden und Bestellungen, die Zahlen aus dem DI²-Projekt sind Stand 5. November 2026.
Inhalt
- Drei Netze, eine Lücke
- Welche Anforderung eine Sonde prüft und wo sie steht
- Der Ausgangszustand vor dem Test
- Das Sonden-Muster
- Wann eine Sonde gilt: Negativ-Probe und Fixture-Selbsttest
- Mengen statt Minimal-Zustand
- Fixtures zwischen den Deploy-Läufen: Backfill und Drift
- pgTAP, plpgsql_check und die Grenzen des Musters
- Zusammenfassung
- FAQ
- Verwandte Artikel
Drei Netze, eine Lücke
Im DI²-Projekt stehen vor jedem Merge drei automatische Netze. Ein Konventions-Check liest jede SQL-Datei zeilenweise und prüft die Form: Banner, Namensregeln, verbotene Konstrukte. Was er kann und was nicht, steht in Warum sqlfluff unsere SQL-Konventionen nicht prüfen kann. Der Deploy-Smoke spielt das komplette Schema gegen eine frische Wegwerf-Datenbank ein, und zwar zweimal, damit auch der zweite Lauf auf Bestand bewiesen ist. Dieses Gate beschreibt GitHub Actions für Postgres-Deploys. Die Unit-Tests der Anwendung prüfen TypeScript, und zwar nach einer ausdrücklichen Regel nur Code ohne Datenbankzugriff.
Jedes dieser Netze hat eine Aufgabe. Aber kein Netz prüft das Verhalten einer Prozedur. Wie groß die Lücke ist, hat eine Claude-Code-Session im September 2026 ausgemessen, indem sie einen funktionierenden Schutz absichtlich entfernt und danach geprüft hat, ob irgendein Netz anschlägt. Zuerst hat sie aus einer Prozedur den Block entfernt, der einen zu langen Text vor dem Einfügen auf die Spaltenbreite kürzt, und alle Gates laufen lassen. Dieser Block war die eine Hälfte eines Fixes, die andere lag im TypeScript. Dort schreibt eine Funktion einen Stapel von Meldungen in einer Transaktion. Seit dem Fix steht jeder Einfüge-Schritt in einem eigenen Savepoint, damit eine fehlgeschlagene Meldung nur sich selbst zurückrollt und nicht den ganzen Stapel. Die Session hat auch diese Savepoints absichtlich entfernt, wieder alle Gates laufen lassen und beide Dateien danach wiederhergestellt. Die Frage war nicht, ob der Schutz weg soll, sondern ob sein Verschwinden jemandem auffällt.
| Eingriff | Konventions-Check | Deploy-Smoke | Unit-Tests |
|---|---|---|---|
| Kürzungs-Block aus der Prozedur entfernt | 0 Meldungen | grün | nicht zuständig |
| Savepoint-Isolation im TypeScript entfernt | nicht zuständig | nicht zuständig | 1.101 von 1.101 grün |
Für die SQL-Hälfte ist der Grund leicht zu benennen. Der Konventions-Check sieht Form, nicht Verhalten. Der Deploy-Smoke lief damals gegen eine leere Datenbank, und in einer leeren Datenbank entsteht nie ein zu langer Text. Beide Netze waren grün, obwohl der Schutz weg war. Für die TypeScript-Hälfte ist er noch kürzer: Die Funktion greift auf die Datenbank zu, und solche Funktionen haben per Regel keinen Unit-Test. Es gab nichts, das hätte rot werden können.
Der Fall mit der verschwundenen Spalte liegt noch eine Stufe tiefer, weil dort nicht einmal ein Aufruf mit den richtigen Daten nötig gewesen wäre. Irgendein Aufruf hätte gereicht. Das Beispiel unten stellt ihn in der kleinen Domäne nach. Eine Prozedur kopiert einen Kunden samt Bestellungen und nimmt dabei eine Spalte legacy_state mit. Drei Tage nach dieser Prozedur entfernt ein Schema-Skript die Spalte. Der nächste Deploy legt die Prozedur erneut an:
1: CREATE TABLE shop.customer
2: (
3: id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY
4: ,name varchar(100) NOT NULL
5: ,legacy_state varchar(20) NULL
6: );
7:
8: CREATE TABLE shop.sales_order
9: (
10: id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY
11: ,customer_id bigint NOT NULL REFERENCES shop.customer (id)
12: ,amount numeric(12, 2) NOT NULL
13: ,approved_on timestamptz NULL
14: );
15:
16: CREATE OR REPLACE PROCEDURE shop.sp_dup_customer
17: (
18: IN p_customer_id bigint
19: ,INOUT p_new_customer_id bigint
20: )
21: LANGUAGE plpgsql
22: AS $procedure$
23: BEGIN
24: INSERT INTO shop.customer (name, legacy_state)
25: SELECT
26: name || ' (Kopie)'
27: ,legacy_state
28: FROM
29: shop.customer
30: WHERE
31: id = p_customer_id
32: RETURNING id INTO p_new_customer_id;
33:
34: INSERT INTO shop.sales_order (customer_id, amount)
35: SELECT
36: p_new_customer_id
37: ,amount
38: FROM
39: shop.sales_order
40: WHERE
41: customer_id = p_customer_id;
42: END;
43: $procedure$;
44:
45: -- Drei Tage später entfernt ein Schema-Skript die Spalte.
46: ALTER TABLE shop.customer DROP COLUMN legacy_state;
47:
48: -- Der nächste Deploy legt dieselbe Prozedur unverändert erneut an (hier gekürzt,
49: -- es sind die Zeilen 16 bis 43 noch einmal). Postgres antwortet wie beim ersten Mal:
50: -- CREATE PROCEDURE
51:
52: -- Erst der Aufruf löst den Rumpf auf, auch bei leeren Tabellen.
53: CALL shop.sp_dup_customer(1, NULL);
54: -- ERROR: column "legacy_state" of relation "customer" does not exist
55: -- CONTEXT: PL/pgSQL function shop.sp_dup_customer(bigint,bigint) line 3 at SQL statementDie Zeilen 48 bis 50 sind der Punkt. Das erneute CREATE OR REPLACE PROCEDURE läuft durch, obwohl der Rumpf eine Spalte liest, die es nicht mehr gibt. Postgres prüft beim Anlegen einer PL/pgSQL-Routine die Syntax und die Deklarationen. Die Bezeichner in den SQL-Anweisungen des Rumpfs löst es erst auf, wenn die Anweisung zum ersten Mal ausgeführt wird, im Beispiel also beim ersten Aufruf. Eine Anweisung in einem Zweig, den erst ein späterer Aufruf durchläuft, wird auch erst dann aufgelöst. So steht es in der Postgres-Dokumentation im Kapitel PL/pgSQL under the Hood, und so zeigt es der Kontext der Fehlermeldung in Zeile 55: line 3 at SQL statement, nicht CREATE PROCEDURE. Mit ausgeschaltetem check_function_bodies entfällt auch diese Prüfung beim Anlegen, Syntax und Deklarationen werden dann ebenfalls erst beim ersten Aufruf geprüft: Ein Rumpf mit einem Tippfehler im SELECT oder einem unbekannten Typ in DECLARE lässt sich anlegen, und erst der CALL meldet den Fehler. Aus demselben Grund durfte das Schema-Skript in Zeile 46 die Spalte überhaupt entfernen: Für die Tabellen und Spalten, die die SQL-Anweisungen eines PL/pgSQL-Rumpfs verwenden, registriert Postgres keine Abhängigkeit, deshalb findet DROP COLUMN kein Objekt, das an der Spalte hängt. Eine SQL-Funktion mit BEGIN ATOMIC-Rumpf registriert sie, dort scheitert schon der DROP COLUMN mit dem Hinweis auf die abhängige Funktion.
Für ein CI-Gate heißt das: Ein Deploy, der eine Prozedur anlegt, beweist, dass sie sich anlegen lässt. Was sie tut, und ob sie überhaupt läuft, beweist er nicht.
Welche Anforderung eine Sonde prüft und wo sie steht
Eine Anforderung ist hier ein Satz über das Verhalten, den der Code erfüllen muss. „Die Kopie übernimmt den Kunden und alle Bestellungen, aber keine Abnahme.“ „Ein zu langer Text wird vor dem Einfügen auf die Spaltenbreite gekürzt, ohne dass zwei verschiedene Texte dabei gleich werden.“ „Kein Weg über die Vater-Tabelle löscht den Fremdschlüssel einer abgenommenen Kind-Tabelle.“ Zwei dieser Sätze sprechen von einer Abnahme, einem fachlichen Zustand der Daten. Im DI²-Projekt nimmt der Eigentümer eine fertig konfigurierte Tabelle ab. Von diesem Moment an ist ihre Konfiguration gesperrt: Spalten lassen sich nicht mehr abwählen, die Tabelle lässt sich nicht mehr deaktivieren, und die Prozeduren, die das versuchen, weisen den Aufruf ab. In der kleinen Domäne dieses Artikels ist eine Bestellung abgenommen, wenn der Kunde die Lieferung geprüft und freigegeben hat. Im Schema steht dafür der Zeitstempel approved_on. Eine Kopie startet ohne Abnahme: Freigegeben wurde das Original, nicht die Kopie.
Jede Sonde im Projekt prüft eine oder wenige solcher Anforderungen, und sie stehen wörtlich im Kopf der Fixture-Datei, bevor die erste Zeile SQL kommt. Fixture ist der Begriff aus dem Testen für den vorbereiteten Ausgangszustand, den ein Test braucht, bevor er etwas prüfen kann. In diesem Artikel ist damit die SQL-Datei einer Sonde gemeint. Sie legt die Testdaten an, ruft die echte Prozedur auf und schreibt jedes Ergebnis in die Ergebnis-Tabelle.
Die Regel dazu ist kurz und hat sich aus der Messung von oben ergeben: SQL-Verhalten gehört in eine Sonde des Deploy-Smokes. Dort ist eine Prüfung billig, weil die Wegwerf-Datenbank ohnehin steht und das Schema frisch appliziert ist. Die Regel hat ein Gegenstück, das genauso wichtig ist. Nicht jede Anforderung bekommt eine Sonde. Die Savepoint-Hälfte von oben zum Beispiel liegt in einer TypeScript-Funktion mit Datenbankzugriff, und solche Funktionen sind im Projekt per Regel vom Unit-Test ausgenommen. Für jede Anforderung, die so bewusst ohne Netz bleibt, führt das Projekt eine Zeile in einer Inventur-Tabelle. Ihre vier Spalten halten fest, welche Anforderung es ist, warum kein Netz sie prüft, was ein Umbau still kaputt machen würde und welche geplante Änderung die Lücke später schließt. Ohne diesen Eintrag sähe eine bewusst ungeprüfte Anforderung genauso aus wie eine, an die schlicht niemand gedacht hat. In beiden Fällen gibt es keinen Test, der rot werden könnte, und die CI ist grün.
Der Ausgangszustand vor dem Test
Welche Daten eine einzige Anforderung braucht, zeigt die dritte von oben. „Kein Weg über die Vater-Tabelle löscht den Fremdschlüssel einer abgenommenen Kind-Tabelle“ ist ein Satz. Die Sonde dazu braucht zwei Vater-Tabellen und zwei Kind-Tabellen, zwei Fremdschlüssel, einen Spalten-Snapshot der ersten Vater-Tabelle, eine Abnahme auf genau einer Kind-Tabelle, und danach sieben benannte Fälle: Vater deaktivieren wird abgewiesen, Vater-Spalte abwählen wird abgewiesen, eine andere Spalte abwählen läuft durch, der zweite Vater ohne abgenommenes Kind darf deaktiviert werden, dazu drei Diagnose-Zeilen, die den Zustand danach festhalten. Das sind 183 Zeilen SQL für einen Satz.
Das ist kein Ausreißer, sondern die Regel. Die 22 Sonden des Projekts bestehen aus 7.266 Zeilen Fixture-SQL, die Prüfungen dazu aus 1.328 Zeilen Shell-Skript. Damit entfallen 85 Prozent des Sonden-Codes auf die SQL-Dateien, und in diesen Dateien gehören nur rund 15 Prozent der Zeilen zu den Aufrufen der Prozeduren und zum Protokollieren der Ergebnisse. Die übrigen bauen den Ausgangszustand auf. Die drei größten Sonden haben 1.030, 944 und 923 Zeilen, und der größte Teil davon ist Aufbau: Konten, Projekte, Mitgliedschaften, Verbindungen, Tabellen, Spalten, Regeln, Fremdschlüssel, Abnahmen, in der richtigen Reihenfolge und mit den richtigen Rollen.
Das ist die eigentliche Komplexität beim Testen von Datenbank-Verhalten, und sie liegt nicht in der Prüfung. Die Prüfung ist am Ende ein Zähler oder ein Vergleich. Die Arbeit steckt darin, einen Zustand herzustellen, in dem die Anforderung überhaupt eine Bedeutung hat. Eine Prozedur, die einen Fremdschlüssel schützt, braucht einen Fremdschlüssel, eine Abnahme und einen Aufrufer mit der passenden Rolle, sonst gibt es nichts zu schützen. Und weil die Sonde das Verhalten gegen die echte Datenbank prüfen soll, muss jeder dieser Bausteine echt sein. Ein Platzhalter würde genau den Teil ersetzen, um den es geht. Darin unterscheidet sich eine Sonde von einem Test der Oberfläche, der seinen Server durch einen Platzhalter mit vorbereiteter Antwort ersetzen darf: Ein Oberflächen-Test besteht aus viel Prüfung und wenig Aufbau. Eine Sonde besteht aus viel Aufbau und wenig Prüfung.
Das Sonden-Muster
Das Muster lässt sich an der kleinen Domäne vollständig zeigen. Zuerst die korrigierte Prozedur mit der Anforderung im Kommentar:
1: -- 155002: Die korrigierte Prozedur. Die Anforderung an sie: Die Kopie übernimmt den
2: -- Kunden und alle Bestellungen, aber keine Abnahme.
3: -- --------------------------------------------------------------------------------
4: -- Parameter
5: -- --------------------------------------------------------------------------------
6: -- p_customer_id bigint
7: -- Kunde, der kopiert wird
8: -- p_new_customer_id bigint
9: -- Kennung der Kopie (Rückgabe)
10: -- --------------------------------------------------------------------------------
11: CREATE OR REPLACE PROCEDURE shop.sp_dup_customer
12: (
13: IN p_customer_id bigint
14: ,INOUT p_new_customer_id bigint
15: )
16: LANGUAGE plpgsql
17: AS $procedure$
18: BEGIN
19: INSERT INTO shop.customer (name)
20: SELECT
21: name || ' (Kopie)'
22: FROM
23: shop.customer
24: WHERE
25: id = p_customer_id
26: RETURNING id INTO p_new_customer_id;
27:
28: -- Bestellungen kopieren, Abnahmen bewusst nicht (approved_on bleibt NULL).
29: INSERT INTO shop.sales_order (customer_id, amount)
30: SELECT
31: p_new_customer_id
32: ,amount
33: FROM
34: shop.sales_order
35: WHERE
36: customer_id = p_customer_id;
37: END;
38: $procedure$;Dann die Sonde. Sie besteht aus vier Teilen: einer Ergebnis-Tabelle, dem Ausgangszustand, den Fällen und einer Prüfung, die außerhalb von SQL läuft.
1: -- 155003: Die Sonde. Baut einen Ausgangszustand, ruft die echte Prozedur auf
2: -- und schreibt jedes Ergebnis in eine Tabelle. Geprüft wird außerhalb von SQL.
3: -- Nur für Wegwerf-Datenbanken.
4: SET client_min_messages = warning;
5:
6: DROP TABLE IF EXISTS shop._probe_dup_customer;
7: CREATE TABLE shop._probe_dup_customer
8: (
9: label varchar(40) PRIMARY KEY
10: ,sqlstate varchar(5) NULL
11: ,message text NULL
12: ,value text NULL
13: );
14:
15: BEGIN;
16:
17: -- --------------------------------------------------------------------------------
18: -- Ausgangszustand: ein Kunde, zwei Bestellungen, eine davon abgenommen
19: -- --------------------------------------------------------------------------------
20: INSERT INTO shop.customer (name) VALUES ('_probe_kunde');
21:
22: INSERT INTO shop.sales_order (customer_id, amount, approved_on)
23: SELECT
24: T01.id
25: ,T02.amount
26: ,T02.approved_on
27: FROM
28: shop.customer T01
29: CROSS JOIN (VALUES (100.00, NULL::timestamptz), (250.00, now())) AS T02 (amount, approved_on)
30: WHERE
31: T01.name = '_probe_kunde';
32:
33: -- --------------------------------------------------------------------------------
34: -- Fälle: ein Fehler wird gefangen und protokolliert, nicht durchgereicht
35: -- --------------------------------------------------------------------------------
36: DO $do$
37: DECLARE
38: l_source bigint;
39: l_copy bigint;
40: l_count int;
41: l_approved int;
42: BEGIN
43: SELECT id INTO l_source FROM shop.customer WHERE name = '_probe_kunde';
44:
45: -- Fixture-Selbsttest: zwei Bestellungen, genau eine davon abgenommen, sonst Abbruch.
46: SELECT count(*), count(approved_on) INTO l_count, l_approved
47: FROM shop.sales_order WHERE customer_id = l_source;
48: IF l_count <> 2 OR l_approved <> 1 THEN
49: RAISE EXCEPTION 'Fixture unvollständig: % Bestellungen, % abgenommen', l_count, l_approved;
50: END IF;
51:
52: -- Fall 1: Die Prozedur läuft durch (Soll: sqlstate leer).
53: BEGIN
54: CALL shop.sp_dup_customer(l_source, l_copy);
55: INSERT INTO shop._probe_dup_customer (label, value)
56: VALUES ('call_ok', l_copy::text);
57: EXCEPTION WHEN OTHERS THEN
58: INSERT INTO shop._probe_dup_customer (label, sqlstate, message)
59: VALUES ('call_ok', SQLSTATE, SQLERRM);
60: END;
61:
62: -- Fall 2: Beide Bestellungen sind kopiert (Soll: 2).
63: SELECT count(*) INTO l_count FROM shop.sales_order WHERE customer_id = l_copy;
64: INSERT INTO shop._probe_dup_customer (label, value)
65: VALUES ('order_count', l_count::text);
66:
67: -- Fall 3: Keine Abnahme wandert mit (Soll: 0).
68: SELECT count(*) INTO l_count
69: FROM shop.sales_order
70: WHERE customer_id = l_copy AND approved_on IS NOT NULL;
71: INSERT INTO shop._probe_dup_customer (label, value)
72: VALUES ('approved_copied', l_count::text);
73:
74: -- Fall 4: Die Beträge der Kopie stimmen mit dem Original überein (Soll: 0).
75: SELECT count(*) INTO l_count
76: FROM
77: (
78: (SELECT amount FROM shop.sales_order WHERE customer_id = l_source
79: EXCEPT ALL
80: SELECT amount FROM shop.sales_order WHERE customer_id = l_copy)
81: UNION ALL
82: (SELECT amount FROM shop.sales_order WHERE customer_id = l_copy
83: EXCEPT ALL
84: SELECT amount FROM shop.sales_order WHERE customer_id = l_source)
85: ) T01;
86: INSERT INTO shop._probe_dup_customer (label, value)
87: VALUES ('amount_diff', l_count::text);
88: END;
89: $do$;
90:
91: COMMIT;Die Ergebnis-Tabelle in den Zeilen 7 bis 13 ist der Kern des Musters. Jeder Fall schreibt eine Zeile mit einem sprechenden label, dazu entweder einen Wert oder, wenn ein Fehler aufgetreten ist, SQLSTATE und Meldung. Das Label ist der Primärschlüssel, eine Sonde, die dasselbe Label zweimal schreibt, bricht damit selbst ab. Die Sonde entscheidet nichts, sie protokolliert. Das hat zwei Gründe. Ein RAISE EXCEPTION beim ersten Befund würde die übrigen Fälle verdecken, und eine Fehlermeldung, die die Ursache rät, wäre regelmäßig falsch, weil mehrere Anforderungen gleichzeitig verletzt sein können.
Der Ausgangszustand in den Zeilen 17 bis 31 ist so klein wie möglich und so groß wie nötig: ein Kunde, zwei Bestellungen, eine davon abgenommen. Ob er so entstanden ist, prüft der Selbsttest in den Zeilen 45 bis 50, bevor der erste Fall läuft: zwei Bestellungen, genau eine davon abgenommen, sonst bricht die Sonde ab.
Die Fälle in den Zeilen 36 bis 89 rufen die echte Prozedur auf. Der Aufruf in Zeile 54 steht in einem eigenen BEGIN-Block mit EXCEPTION-Zweig. Ein Fehler wird gefangen und mit SQLSTATE protokolliert, er reißt die Sonde nicht ab. Dieser Rahmen setzt voraus, dass die Prozedur in der Transaktion der Sonde laufen kann. Eine Prozedur, die selbst COMMIT oder ROLLBACK ausführt, braucht einen anderen Aufruf, dazu mehr im Abschnitt über die Grenzen des Musters. Welcher Fehler im Aufruf gefangen werden kann, zeigt der erste Block: Mit der alten Prozedur stünde hier 42703, weil der Rumpf die Spalte legacy_state liest, die es nicht mehr gibt, und die Meldung käme erst beim CALL. Ein zweites Beispiel steckt in der Kopie selbst: Der Name bekommt den Zusatz „(Kopie)“, und bei einem Original mit mehr als 92 Zeichen passt er nicht mehr in varchar(100), Fehlercode 22001. Ohne den EXCEPTION-Zweig würde jeder dieser Fehler den DO-Block abbrechen, die Fälle 2 und 3 liefen nie, und die Prüfung fände eine leere Tabelle ohne Hinweis auf die Ursache. Bricht die Sonde trotzdem ab, stammt der Fehler nicht aus dem Aufruf, denn der ist abgefangen. Dann stimmt etwas an der Sonde selbst nicht, etwa ein falscher Tabellenname im Aufbau oder ein Tippfehler in einer Zähl-Abfrage. Ein solcher Abbruch sagt etwas über die Sonde, nicht über die Prozedur. Die beiden Zähler danach prüfen die zwei Hälften der Anforderung getrennt: Zeile 63 zählt die kopierten Bestellungen, Zeile 68 zählt die Abnahmen auf der Kopie. Ist die Anforderung verletzt, zeigt das Label, welche Hälfte es ist. Der Zähler in Zeile 63 prüft dabei nur die Anzahl der Bestellungen, nicht ihren Inhalt, und eine Prozedur, die zwei Bestellungen mit falschen Beträgen anlegt, bliebe bei ihm grün. Deshalb vergleicht Fall 4 ab Zeile 74 die Beträge selbst: EXCEPT ALL in beide Richtungen liefert jede Bestellung, die nur auf einer Seite vorkommt, Mehrfachvorkommen eingerechnet, und das Soll sind null Abweichungen.
Die Prüfung liest die Tabelle von außen:
1: #!/bin/bash
2: # 155004: Die Prüfung. Liest die Ergebnis-Tabelle der Sonde, zeigt bei Rot
3: # zuerst die gespeicherten Zeilen und bricht dann ab.
4: set -u
5: PSQL=(psql -v ON_ERROR_STOP=1 -tA -d "${PGDATABASE:-probe}")
6:
7: fail() {
8: echo "--- Sonde rot. Gespeicherte Zeilen (label | sqlstate | message | value): ---" >&2
9: "${PSQL[@]}" -c "SELECT label || ' | ' || coalesce(sqlstate, '') || ' | ' || coalesce(message, '') || ' | ' || coalesce(value, '') FROM shop._probe_dup_customer ORDER BY label;" >&2
10: echo "FEHLER: $1" >&2
11: exit 1
12: }
13:
14: # expect <label> <soll> <meldung>: genau eine Zeile mit diesem Label, ohne SQLSTATE,
15: # mit value = soll. Das Soll "*" verlangt nur die Zeile ohne SQLSTATE. Kann psql die
16: # Tabelle nicht lesen, ist auch das Rot.
17: expect() {
18: local row
19: row="$("${PSQL[@]}" -c "SELECT count(*) || '|' || coalesce(max(sqlstate), '') || '|' || coalesce(max(value), '') FROM shop._probe_dup_customer WHERE label = '$1';")" \
20: || fail "psql konnte die Ergebnis-Tabelle nicht lesen."
21: [[ "$row" == "1||"$2 ]] || fail "$3"
22: }
23:
24: expect call_ok "*" "Der Aufruf ist abgebrochen oder hat keine Zeile hinterlassen."
25: expect order_count "2" "Erwartet 2 kopierte Bestellungen."
26: expect approved_copied "0" "Eine Abnahme ist mitkopiert worden."
27: expect amount_diff "0" "Die Beträge der Kopie weichen vom Original ab."
28:
29: echo "Sonde grün: 4 von 4 Prüfungen bestanden."Sie läuft in der Shell, weil das Gate ohnehin ein Shell-Skript ist und einen Exit-Code braucht. Die Funktion fail in den Zeilen 7 bis 12 gibt bei Rot zuerst die gespeicherten Zeilen aus und bricht erst dann ab. Wer den Lauf im CI-Log liest, sieht den tatsächlichen Zustand und muss ihn nicht aus einer Meldung rekonstruieren. Die Funktion expect in den Zeilen 17 bis 22 verlangt für jedes Label genau eine Zeile ohne SQLSTATE und vergleicht den Wert mit dem Soll. Eine fehlende Zeile ist damit Rot und nicht Grün, und ein psql, das die Tabelle nicht lesen kann, ebenfalls. Die vier Zeilen 24 bis 27 sind die Anforderung, in vier Prüfungen zerlegt.
Die Werte einer Sonde werden so gewählt, dass jede verletzte Anforderung einzeln auffällt. Die Sonde für den Kunden legt deshalb zwei Bestellungen an statt einer, damit sich „kopiert alle Bestellungen“ von „kopiert eine“ unterscheiden lässt. Eine der beiden ist abgenommen, die andere nicht. Eine Prozedur, die Abnahmen mitkopiert, fällt damit in Fall 3 auf, eine, die abgenommene Bestellungen überspringt, in Fall 2, und eine, die beim Kopieren einen Betrag verändert, in Fall 4. Keiner dieser Defekte versteckt sich hinter einem anderen, weil das Label sagt, welche Prüfung rot ist.
Der Auftrag für eine solche Sonde ist kurz, weil das Muster steht. Der folgende Prompt ist aus dem Fehlerbericht und der Fixture-Datei nachgezeichnet, nicht wörtlich aus der Session:
Die Prozedur zum Duplizieren eines Projekts ist fünf Monate grün durch
den Deploy gelaufen und war trotzdem kaputt, weil nichts sie aufgerufen
hat. Schreib eine Sonde für den Deploy-Smoke nach dem Muster der
bestehenden Sonden: eigene Fixture-Datei, Ergebnis-Tabelle mit label,
sqlstate und message, Prüfungen im Skript. Die Anforderung, die sie prüft:
Duplizieren übernimmt alles, was den Arbeitsstand ausmacht, aber keine Abnahmen.
Das Original muss aus jedem Bereich mindestens einen Datensatz enthalten.
Akzeptanzkriterium: Gegen den Stand vor dem Fix wird die Sonde rot, danach grün,
und der CI-Lauf bleibt grün.
Daraus entstand die größte Sonde des Projekts mit 1.030 Zeilen, und fast alle davon bauen das Original auf, das jeden Bereich einmal trägt. Die Prüfungen am Ende sind Zähler.
Wann eine Sonde gilt: Negativ-Probe und Fixture-Selbsttest
Der letzte Satz des Prompts ist der wichtigste, und er ist derjenige, der am häufigsten weggelassen wird. Eine Sonde, die grün ist, hat noch nichts bewiesen. Sie kann grün sein, weil die Anforderung erfüllt ist, oder weil sie den Fall, der die Anforderung verletzen würde, gar nicht herstellt. Ohne die abgenommene Bestellung im Ausgangszustand wäre Fall 3 immer 0, mit jeder Prozedur. Den Unterschied zeigt nur die Negativ-Probe: Die Sonde läuft gegen den kaputten Stand und muss rot werden. In der kleinen Domäne heißt das, die Prozedur kopiert approved_on versehentlich mit. Die Sonde meldet dann:
--- Sonde rot. Gespeicherte Zeilen (label | sqlstate | message | value): ---
amount_diff | | | 0
approved_copied | | | 1
call_ok | | | 2
order_count | | | 2
FEHLER: Eine Abnahme ist mitkopiert worden.
Drei der vier Prüfungen sind bestanden, die zur Abnahme nicht, und der Dump sagt, mit welchem Wert. Die Negativ-Probe beweist damit, dass die Sonde auf diesen einen Defekt reagiert. Dass sie jeden anderen Defekt der Prozedur fände, beweist sie nicht. Wer das wissen will, baut nacheinander weitere Defekte ein, so wie es Mutationstests systematisch tun. Im Projekt gehört diese Probe dazu, bevor eine neue Sonde gilt. Mehr als vierzig Fehlerberichte halten fest, dass die neue Sonde gegen den Stand vor dem Fix rot wurde, bevor der Fix als erledigt galt. Das ist dasselbe Prinzip wie die Gegenprobe für automatisierte Tests in Der grüne Test, der nichts beweist: Ein Test, der auf den Code nicht reagiert, prüft ihn nicht.
Für den Ausgangszustand gibt es eine zweite Falle, die die Negativ-Probe nicht sieht. Eine Fixture kann wirkungslos werden, ohne dass etwas rot wird. Würde die abgenommene Bestellung in der Sonde für den Kunden durch einen Tippfehler ohne approved_on angelegt, bliebe Fall 3 für immer grün, und niemand würde es merken. Deshalb prüft eine Fixture vor dem Aufruf selbst, ob sie den Zustand hergestellt hat, den sie herstellen soll. In der Sonde oben sind das die Zeilen 45 bis 50: zwei Bestellungen, genau eine davon abgenommen, sonst bricht der Block mit einer Fehlermeldung ab, und die Prüfung findet keine Zeile call_ok. Im Projekt enthalten die Fixtures, die zwischen den beiden Deploy-Läufen eingespielt werden, einen solchen Fixture-Selbsttest, und alle beantworten dieselbe Frage: Prüft dieser Test noch etwas?
Mengen statt Minimal-Zustand
Ein minimaler Ausgangszustand beweist Verhalten. Für eine Leistungsanforderung ist er wertlos, weil ein Kunde mit zwei Bestellungen in jeder Implementierung schnell kopiert ist. Die Anforderung im Projekt lautete, dass ein Projekt einer bestimmten Größe in höchstens zehn Sekunden dupliziert wird. Dafür braucht die Sonde ein Projekt dieser Größe, und das erzeugt sie mit generate_series: zwei Verbindungen, 200 Tabellen, 10.000 Spalten, 500 Regeln und 300 Fremdschlüssel. In der kleinen Domäne sieht derselbe Aufbau so aus:
1: -- 155005: Mengen-Fixture und Mess-Lauf. 200 Kunden mit je 50 Bestellungen,
2: -- jeder Kunde wird einmal kopiert. Geprüft wird die Vollständigkeit, die Dauer wird ausgegeben.
3: -- Nur für Wegwerf-Datenbanken.
4: INSERT INTO shop.customer (name)
5: SELECT
6: 'perf_' || lpad(n::text, 3, '0')
7: FROM
8: generate_series(1, 200) n;
9:
10: INSERT INTO shop.sales_order (customer_id, amount, approved_on)
11: SELECT
12: T01.id
13: ,(n * 1.5)::numeric(12, 2)
14: ,CASE WHEN n % 10 = 0 THEN now() END
15: FROM
16: shop.customer T01
17: CROSS JOIN generate_series(1, 50) n
18: WHERE
19: T01.name LIKE 'perf\_%';
20:
21: DO $do$
22: DECLARE
23: l_started timestamptz;
24: l_source record;
25: l_copy bigint;
26: l_expected bigint;
27: l_actual bigint := 0;
28: BEGIN
29: SELECT count(*) INTO l_expected
30: FROM
31: shop.sales_order T01
32: INNER JOIN shop.customer T02
33: ON
34: T02.id = T01.customer_id
35: WHERE
36: T02.name LIKE 'perf\_%';
37:
38: -- Die Uhr startet erst vor der Schleife: gemessen wird die Dauer aller 200 Aufrufe zusammen.
39: l_started := clock_timestamp();
40: FOR l_source IN
41: SELECT id FROM shop.customer WHERE name LIKE 'perf\_%' ORDER BY id
42: LOOP
43: CALL shop.sp_dup_customer(l_source.id, l_copy);
44: SELECT l_actual + count(*) INTO l_actual
45: FROM shop.sales_order
46: WHERE customer_id = l_copy;
47: END LOOP;
48:
49: RAISE NOTICE 'Kopiert: % von % Bestellungen in % ms'
50: ,l_actual
51: ,l_expected
52: ,round(extract(epoch FROM clock_timestamp() - l_started) * 1000);
53:
54: -- Das Gate ist die Vollständigkeit, nicht die Zeit.
55: IF l_actual <> l_expected THEN
56: RAISE EXCEPTION 'Kopie unvollständig: % von % Bestellungen', l_actual, l_expected;
57: END IF;
58: END;
59: $do$;
60: -- NOTICE: Kopiert: 10000 von 10000 Bestellungen in 671 msDie Zeilen 4 bis 19 legen 200 Kunden mit je 50 Bestellungen an, jede zehnte abgenommen. Der Mess-Lauf ab Zeile 21 ermittelt zuerst die erwartete Zahl, startet in Zeile 39 die Uhr und kopiert dann jeden Kunden einmal. Er hält zwei Dinge fest: die Dauer der ganzen Schleife, also 200 Aufrufe samt der Zählung nach jedem, und die Zahl der kopierten Bestellungen. Zeile 55 ist dabei die eigentliche Prüfung, und sie prüft Vollständigkeit, nicht Zeit. Den Inhalt der Kopien prüft sie auch nicht, das hat die Sonde oben mit ihrem minimalen Ausgangszustand erledigt. Im Container lagen drei Läufe mit der kleinen Domäne an einem Tag zwischen 2,2 und 2,9 Sekunden, zehn weitere an den folgenden Tagen auf demselben Rechner zwischen 0,2 und 1,0 Sekunden. Die Sonde gibt diese Dauer aus, entscheidet aber nicht über sie.
Dass die Zeit kein Gate ist, war im Projekt eine bewusste Entscheidung mit zwei Gründen. Der erste ist der CI-Runner, der schwankt. Vor einer Optimierung lag die Kopie unter der Anwendungsrolle beim Elf- und beim Dreiundzwanzigfachen der Referenz, danach noch beim Siebenfachen, und keiner dieser Werte taugt als Schwelle. Der zweite Grund ist wichtiger: Die Zeit war ein Symptom, die Ursache war die Form des Ausführungsplans. Das Gate sitzt seitdem auf dieser Plan-Form, die Dauer wird nur ausgegeben, und die zehn Sekunden werden bei der manuellen Prüfung auf der Entwicklungsumgebung gemessen.
Der wichtigste Befund dieser Sonde kam aber erst mit der dritten Messung. Die erste Fassung maß als Superuser, weil der Smoke so läuft. Im Betrieb läuft die Prozedur unter der Anwendungsrolle, mit Zeilen-Rechten und einem Aktor. Der Aktor ist der angemeldete Benutzer, den die Anwendung der Datenbank-Session mitgibt, und auf ihn beziehen sich die Zeilen-Rechte. Erst die Messung unter dieser Rolle zeigte 144 Sekunden, in einem Fall fast fünf Minuten. Als Superuser war davon nichts zu sehen, weil die Zeilen-Rechte ihn nicht kosten. Seitdem misst die Sonde dreimal und verlangt für jede der drei Kopien Vollständigkeit. Der Auftrag dafür, nachgezeichnet:
Die Leistungs-Sonde misst das Duplizieren als Superuser. So läuft die
Prozedur in der Anwendung nicht: Dort gilt die Anwendungsrolle mit
Zeilen-Rechten und ein Aktor. Bitte die Kopie dreimal messen, als
Superuser als Referenz, unter der Anwendungsrolle mit Aktor und ohne
Aktor, und alle drei Zeiten ausgeben. Kein hartes Zeit-Gate, der
CI-Runner schwankt. Geprüft wird nur, dass jede der drei Kopien
vollständig ist.
Die Lehre daraus gilt für jede Mengen-Sonde: Die Rolle gehört zum Ausgangszustand. Eine Messung unter der falschen Rolle misst ein anderes System.
Fixtures zwischen den Deploy-Läufen: Backfill und Drift
GitHub Actions für Postgres-Deploys erklärt, warum das Gate das Schema zweimal einspielt: Der zweite Lauf beweist, dass jede Datei auf Bestand wiederholbar ist. Er beweist es allerdings nur für den Bestand, den der erste Lauf hinterlassen hat, und das ist eine leere Datenbank mit frischem Schema. Zwei Fehlerklassen sieht er deshalb nicht, und das Projekt schließt beide mit Fixtures, die zwischen den zwei Läufen eingespielt werden.
Ein Backfill ist ein Deploy-Skript, das nach einer Schema-Änderung die vorhandenen Daten nachzieht. Im Projekt läuft es in einer Schleife über alle aktiven Tabellen und ruft für jede eine Prozedur auf, die neue Prüfregeln ableitet. In der leeren Wegwerf-Datenbank gibt es keine aktive Tabelle. Die Schleife wird null Mal durchlaufen, der Aufruf findet nie statt, und der Deploy ist grün. Der Aufruf läuft unter der Rolle des Schema-Eigentümers, und dieser Rolle fehlte einmal ein Recht. Auf der ersten Umgebung mit Daten wäre der Deploy rot gewesen, im Gate hat es niemand gesehen. Die Backfill-Fixture legt deshalb vor dem zweiten Lauf drei Datensätze an: ein Projekt, eine Verbindung, eine aktive Tabelle. Damit wird die Schleife mindestens einmal durchlaufen, der Aufruf findet statt, und ein fehlendes Recht macht den zweiten Lauf rot. Wie solche Skripte genau einmal laufen, steht in Schema-Änderungen tracken ohne Framework.
Die Drift-Fixture simuliert eine Bestandsdatenbank, die dem Repository hinterherhinkt: Sie setzt acht Check-Constraints auf einen alten Stand und legt eine Zeile an, die nur unter dem alten Constraint gültig ist. Der zweite Lauf muss jede Definition zurück auf den Soll-Stand bringen und die Zeile migrieren. Ohne die Fixture gäbe es zu keinem Zeitpunkt einen alten Constraint, gegen den der Lauf arbeiten könnte.
Das ist die Antwort auf die offene Stelle, mit der „GitHub Actions für Postgres-Deploys“ endet. Dort heißt es, datenabhängige Effekte blieben im Gate unsichtbar, und die gängige Ergänzung sei ein nächtlicher Lauf gegen einen anonymisierten Produktions-Dump. Für Anforderungen an das Verhalten ist der synthetische Ausgangszustand im selben Gate die bessere Antwort: Er läuft bei jedem Pull Request, er ist versioniert, er enthält genau die Konstellation, um die es geht, und er braucht keine echten Daten. Der Dump behält seinen Platz, wo es um Datenverteilung, Sperrdauern und Laufzeiten auf echten Mengen geht. Das sind andere Fragen, und sie gehören nicht ins PR-Gate.
pgTAP, plpgsql_check und die Grenzen des Musters
Wer Postgres-Routinen testet, kennt pgTAP: eine Erweiterung, die Prüffunktionen wie ok(), is() und results_eq() in SQL bereitstellt, Ergebnisse im TAP-Format ausgibt und mit pg_prove in jeden Test-Runner passt. Im DI²-Projekt wurde pgTAP nie geprüft. Die Sonden sind aus dem vorhandenen Deploy-Smoke herausgewachsen, Fehler für Fehler, und eine Werkzeug-Entscheidung gab es nicht. Der Unterschied liegt deshalb in der Verpackung, nicht in der Idee. Eine pgTAP-Datei baut denselben Ausgangszustand und prüft dieselben Anforderungen, nur steht die Prüfung in SQL statt in der Shell. Dafür braucht sie eine Erweiterung in der Datenbank und einen zweiten Runner. Die Shell-Variante braucht nur psql und läuft im selben Lauf wie der Deploy. Wer bei null anfängt und ohnehin viele Sonden plant, sollte pgTAP ansehen. Wer ein Deploy-Gate hat, kommt mit dem Muster hier ohne neue Abhängigkeit aus.
Für die Fehlerklasse der verschwundenen Spalte gibt es außerdem ein billigeres Netz als jede Sonde. Die Erweiterung plpgsql_check prüft den Rumpf einer Routine statisch, löst dabei auch die Bezeichner auf und meldet die entfernte Spalte, ohne dass jemand die Prozedur aufruft. Gegen die kaputte Prozedur aus dem ersten Beispiel liefert sie in Version 2.10 im Container:
SELECT * FROM plpgsql_check_function('shop.sp_dup_customer(bigint, bigint)');
error:42703:3:SQL statement:column "legacy_state" of relation "customer" does not exist
Query: INSERT INTO shop.customer (name, legacy_state)
-- ^
Nach dem Fix liefert dieselbe Abfrage null Zeilen. Ein Aufruf von plpgsql_check_function über alle Routinen eines Schemas gehört deshalb als zusätzliches Netz an das Ende jedes Deploy-Laufs, auch im Gate. Er fängt verwaiste Spalten und Tabellen zum Zeitpunkt des Deploys, in Sekunden, ohne Testdaten. Nicht sehen kann er SQL, dessen Text erst zur Laufzeit von Parametern oder Daten abhängt und per EXECUTE läuft. Einen EXECUTE-String, den er aus Konstanten im Rumpf zusammensetzen kann, auch über format() oder eine Variable, prüft er dagegen mit. Umgekehrt meldet er eine temporäre Tabelle, die der Rumpf selbst anlegt, als fehlend, obwohl der Aufruf durchläuft. Jede Meldung der Prüfung braucht deshalb einen Blick in den Rumpf, bevor sie als Fehler gilt. Und was er grundsätzlich nicht fängt, ist alles, worum es in diesem Artikel geht: ob die Kopie die Abnahme mitnimmt, ob die Kürzung zwei verschiedene Texte auseinanderhält, ob der Fremdschlüssel den Umweg über die Vater-Tabelle überlebt. Das sind Anforderungen an das Verhalten, und für Verhalten braucht es einen Aufruf mit Daten.
Das Muster hat Grenzen. Drei davon sind im Projekt schon sichtbar, eine vierte setzt Postgres selbst. Sonden koppeln sich an Details: Die Sonde für die Textkürzung kennt die Breite der Spalte, und wer die Spalte verbreitert, muss zwei Dateien anfassen. Die Laufzeit des Gates wächst mit jeder Sonde, 22 Sonden laufen heute in einem Lauf nacheinander. Und eine Sonde prüft nur, was jemand als Anforderung aufgeschrieben hat. Was niemand aufgeschrieben hat, steht entweder in der Inventur oder bleibt am Klick. Die vierte Grenze betrifft Prozeduren, die ihre Transaktion selbst steuern. COMMIT und ROLLBACK in einer Prozedur erlaubt Postgres nur, wenn der CALL auf oberster Ebene im Autocommit-Modus steht. Der Aufruf in der Sonde steht dagegen zwischen BEGIN und COMMIT, also in einem ausdrücklich geöffneten Transaktionsblock, und zusätzlich in einem Block mit EXCEPTION-Zweig, der intern eine Subtransaktion öffnet. Jedes von beidem reicht für den Fehler 2D000, und die Sonde meldet Rot, obwohl die Prozedur in der Anwendung einwandfrei läuft. Für sie braucht der Aufruf einen eigenen psql-Aufruf ohne BEGIN davor, und die Prüfung liest dann dessen Exit-Code und den Zustand der Tabellen danach.
Zusammenfassung
- Das Anlegen einer PL/pgSQL-Routine prüft Syntax und Deklarationen. Die Spalten und Tabellen im Rumpf löst Postgres erst beim ersten Aufruf auf. Ein Deploy-Gate beweist deshalb, dass eine Prozedur anlegbar ist, nicht, dass sie läuft.
- Eine Sonde baut einen Ausgangszustand aus synthetischen Daten, ruft die echte Prozedur auf, fängt Fehler und schreibt jedes Ergebnis in eine Tabelle. Die Prüfung läuft außerhalb von SQL und zeigt bei Rot zuerst den Zustand.
- Der Test besteht zum größten Teil aus Aufbau. Im DI²-Projekt entfallen 85 Prozent des Sonden-Codes auf die SQL-Dateien, und in ihnen gehören nur rund 15 Prozent der Zeilen zu Aufruf und Protokoll. Der Ausgangszustand enthält auch die Rolle, unter der die Prozedur in der Anwendung läuft.
- Eine Sonde gilt erst nach der Negativ-Probe, eine Fixture erst nach ihrem Selbsttest.
- Für Leistungsanforderungen erzeugt
generate_seriesdie Menge. Das Gate ist die Vollständigkeit, die Zeit wird ausgegeben.
Für die Praxis als Entscheidungshilfe:
| Eingangslage | Empfohlener Weg |
|---|---|
| Eine Routine liest Spalten oder Tabellen, die sich ändern können | plpgsql_check über alle Routinen am Ende des Deploy-Laufs |
| Eine Anforderung an das Verhalten: kopiert X, aber nicht Y, weist Z ab | Sonde mit minimalem Ausgangszustand im Gate, abgenommen per Negativ-Probe |
| Eine Leistungsanforderung | Mengen-Fixture per generate_series, Vollständigkeit als Gate, Dauer als Ausgabe, Messung unter der Anwendungsrolle |
| Ein Deploy-Skript hängt von Bestandsdaten ab | Fixture zwischen den zwei Deploy-Läufen, mit Selbsttest |
| Datenverteilung, Sperrdauern, Laufzeiten auf echten Mengen | Lauf gegen einen anonymisierten Dump, außerhalb des PR-Gates |
FAQ
Mit einem SQL-Skript, das in einer Wegwerf-Datenbank die nötigen Tabellen und Zeilen anlegt, die Prozedur aufruft und jedes Ergebnis mit einem Label in eine Tabelle schreibt. Fehler werden in einem BEGIN-Block mit EXCEPTION-Zweig gefangen und mit SQLSTATE protokolliert. Ein Shell-Skript liest die Tabelle per psql -tAc, vergleicht die Werte mit dem Soll und beendet sich bei Abweichung mit einem Exit-Code ungleich null. Im CI läuft das Skript direkt nach dem Deploy gegen dieselbe Wegwerf-Datenbank.
Der zweite Deploy-Lauf beweist, dass jede Objekt-Datei auf Bestand wiederholbar ist. Er ruft aber keine Prozedur auf und arbeitet gegen eine Datenbank ohne Daten. Eine Prozedur, die eine entfernte Spalte liest, lässt sich beliebig oft anlegen, weil Postgres die Spaltennamen im Rumpf erst beim Aufruf auflöst. Und eine Prozedur, die etwas Falsches kopiert, tut das erst, wenn es etwas zu kopieren gibt. Das Erste braucht irgendeinen Aufruf, das Zweite einen Aufruf mit passenden Daten. Beides liefert eine Sonde, das Erste auch eine statische Prüfung mit plpgsql_check.
Teilweise, mit der Erweiterung plpgsql_check. Sie analysiert den Rumpf einer Routine statisch, löst dabei Tabellen- und Spaltennamen auf und meldet zum Beispiel eine Spalte, die es nicht mehr gibt, ohne dass jemand die Prozedur aufruft. Das Verhalten prüft sie nicht: Ob die Prozedur das Richtige kopiert, abweist oder kürzt, zeigt erst ein Aufruf mit Daten, also eine Sonde.
Durch die Negativ-Probe. Die Sonde läuft einmal gegen den kaputten Stand, also gegen die Prozedur vor dem Fix oder gegen eine Fassung, aus der die geprüfte Zeile absichtlich entfernt wurde, und muss dabei rot werden. Bleibt sie grün, erreicht sie die Anforderung nicht. Für Fixtures kommt ein Selbsttest dazu, der nachweist, dass die Fixture den Zustand wirklich hergestellt hat, zum Beispiel durch einen Zähler auf die angelegten Zeilen.
COMMIT oder ROLLBACK ausführt? Nicht im Sonden-Rahmen von oben, sondern in einem eigenen psql-Aufruf. Postgres erlaubt COMMIT und ROLLBACK in einer Prozedur nur, wenn der CALL auf oberster Ebene im Autocommit-Modus steht. Zwischen BEGIN und COMMIT oder in einem Block mit EXCEPTION-Zweig bricht der Aufruf mit dem Fehler 2D000 ab (invalid transaction termination), obwohl die Prozedur in der Anwendung einwandfrei läuft. Die Sonde ruft eine solche Prozedur deshalb ohne BEGIN davor auf, und die Prüfung liest den Exit-Code von psql und danach den Zustand der Tabellen.
Für Sonden braucht es sie nicht, und für das PR-Gate sind sie der falsche Ort. Eine Sonde prüft eine benannte Anforderung, und dafür reicht eine synthetische Konstellation aus wenigen Zeilen, die genau diese Anforderung berührt. Produktionsdaten enthalten personenbezogene Angaben, die vor jeder Nutzung außerhalb der Produktion anonymisiert werden müssen, und sie sind groß. Wo es um Datenverteilung, Sperrdauern oder Laufzeiten auf echten Mengen geht, ist ein anonymisierter Dump das richtige Mittel, als eigener Lauf außerhalb des Gates.
Verwandte Artikel
Vorgelagert:
- GitHub Actions für Postgres-Deploys — mit Wegwerf-Datenbank als Qualitäts-Gate (das Gate, in dem die Sonden laufen, und die Grenze „leere Datenbank“, an der dieser Artikel ansetzt)
- Datenbank-CI/CD mit PostgreSQL — der komplette Lebenszyklus vom Objekt-File bis zum automatischen Deploy (der Hub dieses Clusters)
Geschwister im Cluster:
- Schema-Änderungen tracken ohne Framework — Run-once-Skripte, Checksummen und Immutabilität (die Skripte, deren Schleife die Backfill-Fixture erzwingt)
- NOT-NULL-Spalte nachträglich hinzufügen — das Expand/Contract-Muster für befüllte Tabellen (Struktur-Änderungen auf Bestandsdaten)
- SQL-Schema deployen ohne Migrations-Tool — Verzeichnis-Konvention statt Flyway oder Liquibase (das Deploy-Modell, das der Smoke zweimal einspielt)
Cross-Cluster:
- Warum sqlfluff unsere SQL-Konventionen nicht prüfen kann — ein eigener Guard in 240 Zeilen (das erste der drei Netze: Form statt Verhalten)
- Der grüne Test, der nichts beweist — warum eine Test-Suite den Fehler bestätigte, den sie finden sollte (die Gegenprobe, das Gegenstück zur Negativ-Probe)
- Die erste Ausführung testet den Testfall — was ein Testfall wert ist, den noch nie jemand ausgeführt hat (dieselbe Figur für manuelle Testfälle: nie ausgeführt heißt nie geprüft)
- Migration verifizieren — Datenqualität und Zeilen-Abgleich nach dem Umzug (der Zähler-Vergleich als Vollständigkeits-Beweis)