Skip to content

Snapshot tests for documents

What to assert about a document template, which data sets deserve a snapshot, what the diff looks like when the finance team changes a footer — and the gap in our own tool this post found.

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.

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.

Philipp Staudt

Start rendering today

The free plan includes 100 units a month and free test renders. Change plans when you need more.