Goldens and testing
lttr's own test suite (SPEC §18) is the model for testing templates you write against the library: golden files reviewed in the diff, a structural lint run on every golden, fuzzing on the parser and compiler, and a coverage floor enforced per package.
Regenerate and review goldens
make golden
# = go test ./examples/quiet -run TestGolden -update
TestGolden renders each of the nine examples and compares .HTML/.Text against testdata/golden/{NN}-{name}.html and .txt. With -update it rewrites those files from the current render instead of comparing; without it, a mismatch fails with the first differing line shown both ways:
../../testdata/golden/01-order.out_for_delivery.html differs from the render at line 89:
golden: "...EC$185..." (abbreviated: the real line is the whole row's HTML)
render: "...EC$999..." (review, then -update)
Goldens are reviewed in the PR diff, not rubber-stamped. After make golden, run git diff testdata/golden/ and read every changed line: a golden diff is the actual, final proof that a template or renderer change did what you meant and nothing else — it is the one place a stray attribute, a rounding change in Money, or a copy edit that slipped into the wrong module shows up as a literal, reviewable line of HTML or text. TestGolden also fails if the render produced any Message.Warnings — a clean render has none.
Two more checks run over the files on disk (TestGoldenFiles): every text golden's lines are at most 70 runes unless they contain a URL (SPEC §9.3's wrapping rule), and every HTML golden passes the structural lint below. TestGoldenText01MatchesSpec additionally pins template 01's text body byte for byte against the literal block in SPEC §9.3 — the one example the spec spells out in full.
Run the structural lint
internal/htmlcheck.Lint(doc string) []string is the SPEC §18 structural lint: it returns one message per violation, and an empty result means clean. It runs on every HTML golden (TestGoldenFiles) and is available to your own tests too. It checks:
- no
flexorgridin CSS (matched case-insensitively, but only insidestyle="…"attributes and<style>bodies — the word "grid" in visible copy is not an error); - no
var(,@import,<script(case-insensitively) or unexpanded{{anywhere in the document; - every
<table>hasrole="presentation",cellpadding="0",cellspacing="0"andborder="0"; - every
<img>hasalt,width, andborder:0(or0px/none) in itsstyle; - no
<div style="...">containingdisplay:flex; - the document has
<meta name="color-scheme" content="light dark">; - the document is under 100 KB;
- Outlook conditional comments (
<!--[if …]>/<![endif]-->) are balanced, and none closes before it opens.
It also looks inside <!--[if mso]>…<![endif]--> blocks: the tables, images, <div>s and <style> bodies an Outlook-only branch contains obey the same rules, at their real byte offsets in the document; a VML element like v:roundrect is neither a table nor an image, so only the CSS checks apply to it. It is a small tokenizer, not a full HTML parser — enough to find tags, attributes and comments in the markup lttr itself generates, with comments skipped and <style>/<script> bodies treated as raw text.
Run make check
make check
# = fmt vet staticcheck test cover fuzz
is everything CI runs, in order:
| Step | What it does |
|---|---|
fmt | gofmt -l . must report nothing |
vet | go vet ./... |
staticcheck | An installed staticcheck, falling back to go run honnef.co/go/tools/cmd/staticcheck@latest if the installed binary can't load current stdlib files (D25) — a real finding fails both |
test | go test -race ./... |
cover | The coverage floor (below) |
fuzz | Every Fuzz* target for $FUZZTIME each (default 20s) |
Run the fuzz targets
scripts/fuzz.sh lists every Fuzz* function in every package and runs each for $FUZZTIME (default 20s, overridable: FUZZTIME=2m make check), with -fuzzminimizetime=1s — Go's 60s default minimisation window made every target sit idle for most of a 20s smoke run (D33). The three targets:
| Target | Package | Guards |
|---|---|---|
FuzzParse | comark | The Comark block parser never panics |
FuzzParseText | comark | The inline/merge-tag text parser never panics |
FuzzCompile | ir | Compiling arbitrary Comark to IR never panics |
None of the three is allowed to panic on any input; a crash found by fuzzing gets minimised and saved to that package's testdata/fuzz/ corpus.
Enforce the coverage floor
scripts/cover.sh 85 . ./comark ./data ./ir ./layout ./render/html ./render/text
runs go test -cover on each listed directory and fails if any package's statement coverage is below the floor (COVER_MIN, default 85 in the Makefile). A package that exists but has no Go files is skipped; one whose only code is, say, a lone const declaration reports [no statements] and is treated as a pass rather than a missing figure (D23) — a package with zero coverable statements vacuously clears any floor.
Test your own templates
The same three-step shape lttr's own examples use — compile against your data type, render with sample data, compare to a golden — works for templates you write:
package myapp
import (
_ "embed"
"flag"
"os"
"strings"
"testing"
"github.com/sulv-io/lttr"
)
var update = flag.Bool("update", false, "rewrite golden files")
// lintBasics is a small stand-in for the repo's own internal/htmlcheck.Lint
// (unexported, so consumers can't import it — see the note below): it
// checks a couple of the same categories from "Run the structural lint"
// above, cheaply enough to run in every test.
func lintBasics(html string) []string {
var msgs []string
if !strings.Contains(html, `<meta name="color-scheme" content="light dark">`) {
msgs = append(msgs, `missing <meta name="color-scheme" content="light dark">`)
}
if strings.Contains(strings.ToLower(html), "<script") {
msgs = append(msgs, "contains <script>")
}
if strings.Contains(html, "{{") {
msgs = append(msgs, "contains unexpanded {{")
}
return msgs
}
//go:embed templates/order-shipped.md
var orderShippedSrc []byte
type OrderShippedData struct {
OrderID, TrackURL string
}
func TestOrderShippedCompiles(t *testing.T) {
// Compile checks the template against OrderShippedData and your family
// registry once, the same way your production init would.
if _, err := lttr.Compile[OrderShippedData](myFamilies, "order.shipped", orderShippedSrc); err != nil {
t.Fatal(err)
}
}
func TestOrderShippedRenders(t *testing.T) {
tmpl, err := lttr.Compile[OrderShippedData](myFamilies, "order.shipped", orderShippedSrc)
if err != nil {
t.Fatal(err)
}
r, err := lttr.NewRenderer(myConfig)
if err != nil {
t.Fatal(err)
}
to := lttr.Recipient{Address: "keisha@example.com", FirstName: "Keisha"}
data := OrderShippedData{OrderID: "CH-20417", TrackURL: "https://example.com/t/CH-20417"}
msg, err := tmpl.Render(r, to, data)
if err != nil {
t.Fatal(err)
}
if len(msg.Warnings) != 0 {
t.Errorf("warnings: %q", msg.Warnings)
}
for _, m := range lintBasics(msg.HTML) {
t.Errorf("lint: %s", m)
}
for _, part := range []struct{ path, got string }{
{"testdata/order-shipped.html", msg.HTML},
{"testdata/order-shipped.txt", msg.Text},
} {
if *update {
os.WriteFile(part.path, []byte(part.got), 0o644)
continue
}
want, err := os.ReadFile(part.path)
if err != nil {
t.Fatal(err)
}
if part.got != string(want) {
t.Errorf("%s differs from the render", part.path)
}
}
}
internal/htmlcheck (the package behind Run the structural lint above) is unexported: only code inside github.com/sulv-io/lttr/... can import it, so lintBasics above can't just call htmlcheck.Lint. Copy the rules you care about into your own test package instead — the point is to gate on the same categories of mistake, not necessarily lttr's own function.Wire an -update flag exactly like examples/quiet/golden_test.go does (flag.Bool("update", false, "rewrite golden files")) so go test ./... -run TestOrderShipped -update regenerates your goldens the same way make golden does lttr's own, and review the diff the same way before committing it.
Next steps
- Screenshots for the visual check goldens don't cover (layout overflow).
- Message and MIME for what a render's
Messageguarantees before it ever reaches a golden.