Tooling

Goldens and testing

make golden and reviewing its diffs, the structural lint, make check, fuzzing, the coverage floor, and testing your own templates.

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

Terminal
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:

go test output
../../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 flex or grid in CSS (matched case-insensitively, but only inside style="…" 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> has role="presentation", cellpadding="0", cellspacing="0" and border="0";
  • every <img> has alt, width, and border:0 (or 0px/none) in its style;
  • no <div style="..."> containing display: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

Terminal
make check
# = fmt vet staticcheck test cover fuzz

is everything CI runs, in order:

StepWhat it does
fmtgofmt -l . must report nothing
vetgo vet ./...
staticcheckAn 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
testgo test -race ./...
coverThe coverage floor (below)
fuzzEvery 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:

TargetPackageGuards
FuzzParsecomarkThe Comark block parser never panics
FuzzParseTextcomarkThe inline/merge-tag text parser never panics
FuzzCompileirCompiling 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

Terminal
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:

templates_test.go
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 Message guarantees before it ever reaches a golden.
Copyright © 2026