A broken document template rarely fails. Nothing throws, the API answers 200, and a PDF comes
out. It is just a different PDF from last week’s: the reverse-charge note is gone, the bank details
in the footer are the old ones, the total uses the wrong rate. The people who notice are the ones who
receive it.
Tests for documents have to catch the change, not the crash. This post shows how we do that with snapshot tests, and what we learned writing it.
What to compare
Not the PDF. Its bytes change with every render — at the very least the creation and modification dates in its metadata — so a byte comparison fails for no reason, and a failed comparison of two PDFs tells nobody what changed.
What is worth comparing is the document before it is printed: the HTML the engine assembles from the
template and one set of data. It is deterministic, it is text, and a difference in it is a difference
a person can read. That is what formfeed test does: it renders every data set in
templates/<slug>/data/*.json offline, with the same engine the render servers use, and compares the
result with the approved snapshot in templates/<slug>/tests/__snapshots__/<data set>.html.
formfeed test -u approves the current output. One thing breaks the determinism: a date taken from
the clock — date('now'), or date on a field the data set leaves empty — changes every day, so
give every data set its dates.
Which data sets deserve one
A snapshot only sees what its data exercises. The invoice in our sample project has a condition in it:
{% if invoice.reverse_charge %}<p class="note">Steuerschuldnerschaft des Leistungsempfängers.</p>{% endif %}
With only the ordinary invoice as a data set, that line is never rendered, and nothing checks it. So
the project has two data sets, default and reverse-charge. When the wording of the note changes,
this is what formfeed test reports:
--- 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
The ordinary invoice passes; the one data set that shows the note fails. The rule that follows is simple: one data set per branch of the template. The invoice with one line and the one with forty, the customer without a VAT ID, the credit note, the language you translate into. The editor keeps the same data sets for its preview, so they are rarely extra work.
When the finance team changes the footer
Now a change every company makes sooner or later: the bank details in the footer. Finance adds the BIC.
The footer of a PDF is special. Chromium prints it from a template of its own, in the page margin,
outside the document — and when we ran this for the post, the test passed. formfeed test compared
only the document body, and a changed footer.html went through unnoticed. So we fixed it: since
formfeed 0.3.7 the snapshot holds the header and the footer too, each under a marker line, and the
same change reads like this:
--- 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>
— once for each data set, because every invoice carries the footer. If your templates have a header
or a footer and you come from formfeed 0.3.6 or earlier, the first formfeed test after upgrading
fails once for exactly this reason; run
formfeed test -u and commit the snapshots.
In the pull request
Whoever changes the template runs formfeed test -u and commits the new snapshots with it. The pull
request then carries the change twice: in footer.html, and in every snapshot it affects —
templates/invoice/footer.html | 2 +-
templates/invoice/tests/__snapshots__/default.html | 2 +-
templates/invoice/tests/__snapshots__/reverse-charge.html | 2 +-
— so a reviewer sees not only what was edited but which documents it reaches. A snapshot that changes although nobody meant it to is the finding.
Reading HTML is not the same as looking at the invoice, though, so the pipeline renders it as well.
formfeed ci preview renders the templates changed since the base branch through the API with a
test key, saves the PDFs and writes a summary to post on the pull request. From the run for this
post, against our local stack:
## Formfeed preview
Templates changed against `main`: 1
| Template | Data set | Pages | Output |
| --- | --- | --- | --- |
| `invoice` | default | 1 | [invoice.pdf](…signed link…) |
Rendered with a Formfeed test key; links expire.
The GitHub Action in the CI documentation runs
formfeed validate and formfeed test on every pull request and, with a test key as its api-key,
formfeed ci preview too, and posts the summary as one comment that each run updates. Test keys are not metered, so the preview costs nothing.
What a snapshot does not see
A snapshot compares markup, not layout. A row that moves to page two because a line above it grew longer does not change the HTML at all. For that there is the rendered preview in the pull request, the paged preview in the editor, and True render — and for the rest of the testing setup, the testing guide. Why templates belong in a repository in the first place is the subject of an earlier post.
Run on 21 September 2026 with the formfeed CLI on a sample project; the outputs above are copied
from that run.