Eine kaputte Dokumentvorlage scheitert selten. Nichts wirft einen Fehler, die API antwortet mit
200, und ein PDF kommt heraus. Es ist nur ein anderes PDF als letzte Woche: Der Hinweis zur
Steuerschuldnerschaft fehlt, in der Fußzeile steht noch die alte Bankverbindung, die Summe rechnet
mit dem falschen Satz. Bemerkt wird das von denen, die es bekommen.
Tests für Dokumente müssen die Änderung finden, nicht den Absturz. Dieser Beitrag zeigt, wie wir das mit Snapshot-Tests machen und was wir beim Schreiben gelernt haben.
Was man vergleicht
Nicht das PDF. Seine Bytes ändern sich mit jedem Rendern — mindestens Erstellungs- und Änderungsdatum in den Metadaten —, also scheitert ein Byte-Vergleich ohne Grund, und ein gescheiterter Vergleich zweier PDFs sagt niemandem, was sich geändert hat.
Vergleichen lohnt sich das Dokument, bevor es gedruckt wird: das HTML, das die Engine aus der Vorlage
und einem Datensatz zusammensetzt. Es ist deterministisch, es ist Text, und ein Unterschied darin ist
ein Unterschied, den ein Mensch lesen kann. Genau das macht formfeed test: Es rendert jeden
Datensatz in templates/<slug>/data/*.json offline, mit derselben Engine wie die Render-Server, und
vergleicht das Ergebnis mit dem genehmigten Snapshot in
templates/<slug>/tests/__snapshots__/<Datensatz>.html. formfeed test -u genehmigt den aktuellen
Stand. Eines hebt den Determinismus auf: Ein Datum von der Uhr — date('now') oder date auf einem
Feld, das der Datensatz leer lässt — ändert sich jeden Tag; geben Sie jedem Datensatz deshalb seine
Datumswerte mit.
Welche Datensätze einen verdienen
Ein Snapshot sieht nur, was seine Daten auslösen. Die Rechnung in unserem Beispielprojekt enthält eine Bedingung:
{% if invoice.reverse_charge %}<p class="note">Steuerschuldnerschaft des Leistungsempfängers.</p>{% endif %}
Gibt es nur die gewöhnliche Rechnung als Datensatz, wird diese Zeile nie gerendert, und nichts prüft
sie. Das Projekt hat deshalb zwei Datensätze, default und reverse-charge. Ändert sich der Wortlaut
des Hinweises, meldet formfeed test:
--- invoice/reverse-charge (approved)
+++ invoice/reverse-charge (current)
@@
</table>
<p class="total">Gesamt 960,00 €</p>
- <p class="note">Steuerschuldnerschaft des Leistungsempfängers.</p>
+ <p class="note">Steuerschuldnerschaft des Leistungsempfängers (§ 13b UStG).</p>
</body>
1 passed, 1 failed, 0 without snapshot, 0 written
Die gewöhnliche Rechnung besteht; der eine Datensatz, der den Hinweis zeigt, scheitert. Daraus folgt eine einfache Regel: ein Datensatz je Verzweigung der Vorlage. Die Rechnung mit einer Position und die mit vierzig, der Kunde ohne USt-IdNr., die Gutschrift, die Sprache, in die Sie übersetzen. Der Editor nutzt dieselben Datensätze für seine Vorschau, sie sind also selten Zusatzarbeit.
Wenn die Buchhaltung die Fußzeile ändert
Jetzt eine Änderung, die jedes Unternehmen früher oder später macht: die Bankverbindung in der Fußzeile. Die Buchhaltung ergänzt die BIC.
Die Fußzeile eines PDFs ist ein Sonderfall. Chromium druckt sie aus einem eigenen Template, im
Seitenrand, außerhalb des Dokuments — und als wir das für diesen Beitrag ausprobiert haben, bestand
der Test. formfeed test verglich nur den Dokumentinhalt, und eine geänderte footer.html ging
unbemerkt durch. Also haben wir es behoben: Seit formfeed 0.3.7 enthält der Snapshot auch Kopf- und
Fußzeile, jeweils unter einer Markierungszeile, und dieselbe Änderung liest sich so:
--- invoice/default (approved)
+++ invoice/default (current)
@@
<!-- formfeed:footer -->
<div style="width:100%;padding:0 18mm;font-size:8px;color:#555;display:flex;justify-content:space-between">
- <span>Fennlor Studio GmbH · IBAN DE36 0000 0000 0000 0000 00</span>
+ <span>Fennlor Studio GmbH · IBAN DE36 0000 0000 0000 0000 00 · BIC XXXXDEXXXXX</span>
<span>Seite <span class="pageNumber"></span> von <span class="totalPages"></span></span>
</div>
— einmal für jeden Datensatz, denn jede Rechnung trägt die Fußzeile. Haben Ihre Vorlagen eine Kopf-
oder Fußzeile und kommen Sie von formfeed 0.3.6 oder älter, scheitert das erste formfeed test nach
dem Update genau aus diesem Grund einmal;
führen Sie formfeed test -u aus und committen Sie die Snapshots.
Im Pull Request
Wer die Vorlage ändert, führt formfeed test -u aus und committet die neuen Snapshots mit. Der Pull
Request trägt die Änderung dann zweimal: in footer.html und in jedem Snapshot, den sie berührt —
templates/invoice/footer.html | 2 +-
templates/invoice/tests/__snapshots__/default.html | 2 +-
templates/invoice/tests/__snapshots__/reverse-charge.html | 2 +-
—, sodass ein Reviewer nicht nur sieht, was bearbeitet wurde, sondern welche Dokumente es erreicht. Ein Snapshot, der sich ändert, obwohl das niemand wollte, ist der Befund.
HTML zu lesen ist allerdings nicht dasselbe, wie die Rechnung anzusehen, also rendert die Pipeline sie
auch. formfeed ci preview rendert die seit dem Basis-Branch geänderten Vorlagen mit einem
Test-Schlüssel über die API, speichert die PDFs und schreibt eine Zusammenfassung für den Pull Request. Aus dem Lauf
für diesen Beitrag, gegen unseren lokalen Stack:
## Formfeed preview
Templates changed against `main`: 1
| Template | Data set | Pages | Output |
| --- | --- | --- | --- |
| `invoice` | default | 1 | [invoice.pdf](…signierter Link…) |
Rendered with a Formfeed test key; links expire.
Die GitHub Action in der CI-Dokumentation führt bei jedem
Pull Request formfeed validate und formfeed test aus, mit einem Test-Schlüssel als api-key auch
formfeed ci preview, und postet die Zusammenfassung als einen Kommentar, den jeder Lauf
aktualisiert. Test-Schlüssel werden nicht gezählt, die Vorschau kostet also nichts.
Was ein Snapshot nicht sieht
Ein Snapshot vergleicht Markup, kein Layout. Eine Zeile, die auf Seite zwei rutscht, weil eine Zeile darüber länger geworden ist, ändert das HTML überhaupt nicht. Dafür gibt es die gerenderte Vorschau im Pull Request, die paginierte Vorschau im Editor und Echtes Rendering — und für den Rest der Testeinrichtung den Leitfaden zum Testen. Warum Vorlagen überhaupt ins Repository gehören, steht in einem früheren Beitrag.
Ausgeführt am 21. September 2026 mit der formfeed-CLI an einem Beispielprojekt; die Ausgaben oben
sind aus diesem Lauf kopiert.