A broken document template rarely throws an error. The API returns 200, a PDF is generated, and everything looks fine from the application's point of view. But the document is different from last week's: the reverse-charge note is missing, the footer contains outdated bank details, or the total uses the wrong tax rate. The people who notice are the ones receiving the document.
That's why testing document templates means checking what they produce, not just whether they render successfully.
This post shows how we use snapshot tests to catch unintended changes in generated documents, how to choose the right test data, and what we discovered about our own testing tool along the way.
What to compare
Not the PDF itself. Its bytes can change with every render — at the very least because of the creation and modification dates in its metadata. A byte comparison therefore fails for reasons unrelated to the document's content. And even when two PDFs differ, the comparison doesn't tell you what changed.
What is worth comparing is the document before it gets printed: the HTML assembled by the template engine from a template and a set of data.
HTML is deterministic, readable, and easy to diff. A difference in the HTML is a difference you can inspect.
That's what formfeed test does. It renders every data set in templates/<slug>/data/*.json offline, using the same engine as the render servers, and compares the output with the approved snapshot in templates/<slug>/tests/__snapshots__/<data set>.html.
To approve intentional changes, run formfeed test -u.
There is one important detail: dates taken from the clock break determinism. A date('now') expression, or a date filter applied to a field left empty in the data set, produces different output over time. Give every data set explicit dates instead.
Which data sets deserve a snapshot?
A snapshot can only check what its input data actually exercises.
Consider this condition in our sample invoice template:
{% if invoice.reverse_charge %}<p class="note">Steuerschuldnerschaft des Leistungsempfängers.</p>{% endif %}
If the project only has an ordinary invoice as test data, this line is never rendered. The snapshot cannot tell you whether it works.
That's why the sample project has two data sets: default and reverse-charge.
When the wording of the note changes, formfeed test reports the difference:
--- 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 reverse-charge invoice fails because its snapshot has changed.
The practical rule: create a data set for every meaningful branch of your template.
For an invoice, that might mean:
- An invoice with one line and one with many lines.
- A customer with and without a VAT ID.
- An ordinary invoice and a credit note.
- Each language you support.
- Special tax cases, such as reverse charge.
The goal isn't to create as many data sets as possible. It's to cover the different documents your template can actually produce.
In our case, the editor uses the same data sets for its preview, so maintaining them rarely means doing the same work twice.
When the finance team changes the footer
Now consider a change that happens sooner or later in almost every company: finance updates the bank details in the footer and adds the BIC.
There was a problem, though. Chromium renders PDF headers and footers from separate templates in the page margins, outside the document body.
When we ran the tests while preparing this post, they passed. formfeed test compared only the document body, so a change to footer.html went unnoticed.
In other words, we had a test that passed even though an important part of the document was no longer covered.
We fixed that. Since formfeed 0.3.7, snapshots include the header and footer as well, each separated by a marker line.
The same change now produces a diff 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>
The footer appears in every invoice snapshot, so the change is visible in each relevant data set.
One migration detail: if your templates use headers or footers and you're upgrading from formfeed 0.3.6 or earlier, the first formfeed test will fail once because the snapshots don't contain those sections yet. Run formfeed test -u and commit the updated snapshots.
In the pull request
When someone changes a template, they run formfeed test -u and commit the resulting snapshots alongside the template changes.
The pull request then shows both sides of the change: the edited footer.html and every snapshot affected by it.
For example:
templates/invoice/footer.html | 2 +-
templates/invoice/tests/__snapshots__/default.html | 2 +-
templates/invoice/tests/__snapshots__/reverse-charge.html | 2 +-
A reviewer can see not only what changed, but which documents are affected.
And sometimes the most useful finding is a snapshot that changes even though nobody intended it to.
From HTML diffs to actual PDFs
Reading HTML is not the same as looking at the finished invoice. A snapshot tells you that the markup changed, but not whether the resulting document still looks right.
That's why our pipeline also renders the PDFs.
formfeed ci preview renders templates changed since the base branch through the API, using a test key. It saves the resulting PDFs and generates a summary that can be posted to the pull request.
Here's the output 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. If configured with a test key as its api-key, it also runs formfeed ci preview and posts the summary as a comment that gets updated with each run.
Test keys are not metered, so these previews don't add to your API usage.
What a snapshot does not see
A snapshot checks markup, not layout.
If a row moves to the second page because a line above it becomes longer, the diff shows the longer line and nothing more: the row's own markup stays exactly the same, and nothing in the snapshot says that it now starts page two.
That's why snapshot tests are only one part of document testing. For layout issues, there's the rendered preview in the pull request, the paged preview in the editor, and True render.
For the rest of the testing setup, see the testing guide.
And if you're wondering why document templates belong in a repository in the first place, we covered that in an earlier post.
The important distinction is simple: a successful render doesn't mean a correct document. Snapshot tests catch changes in what your templates produce; rendered previews help catch changes in how those documents look. Together, they give you a much better chance of catching a broken invoice before someone sends it.
Run on 21 September 2026 with the formfeed CLI on a sample project; the outputs above are copied from that run.
Top comments (0)