Skip to content
gohacks.dev
Web

Templ Testing Components With Golden HTML Files in Go

Test templ components with golden HTML files and an update flag, add structural checks that parse the output with x/net/html, and read a failure message.

9 min read
Summarize in AI
Flat illustration of a test report with one failing test, with the HTML5 logo.

A templ component is a function from arguments to markup, and a function from arguments to a string is about the easiest thing there is to test. The question is what to assert. Substring checks are quick to write and miss most regressions. A full comparison of the output catches everything, including changes nobody meant to make, which is both its strength and its cost. This article sets up the two techniques that work together: golden files for the whole output, and parsed checks for the facts that matter.

Tested with Go 1.25.6, templ v0.3.1001 and golang.org/x/net v0.48.0, macOS on Apple Silicon (arm64)

It follows the tutorial, applies the general technique in golden files to components, and belongs to the web cluster that ends in a production HTTP server. Handlers are tested separately, as in testing handlers. Everything ran on Go 1.25.6 with templ v0.3.1001 and golang.org/x/net v0.48.0.

The components under test

card.templText
package main

templ Card(title string, tags []string, featured bool) {
	<article class={ "card", templ.KV("featured", featured) }>
		<h2>{ title }</h2>
		if len(tags) > 0 {
			<ul class="tags">
				for _, t := range tags {
					<li><a href={ templ.URL("/tags/" + t) }>{ t }</a></li>
				}
			</ul>
		}
	</article>
}

templ Table(rows [][2]string) {
	<table>
		<tbody>
			for _, r := range rows {
				<tr><th scope="row">{ r[0] }</th><td>{ r[1] }</td></tr>
			}
		</tbody>
	</table>
}

A card with an optional tag list and a table of label and value rows. Both have a branch worth testing: the card renders no <ul> without tags, and the table escapes cell text.

The helpers

helpers_test.goGo
package main

import (
	"context"
	"flag"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/a-h/templ"
	"golang.org/x/net/html"
)

var update = flag.Bool("update", false, "rewrite the golden files")

// render turns a component into one tag per line, so a diff points at the line that changed.
func render(t *testing.T, c templ.Component) string {
	t.Helper()
	var sb strings.Builder
	if err := c.Render(context.Background(), &sb); err != nil {
		t.Fatal(err)
	}
	return strings.ReplaceAll(sb.String(), "><", ">\n<") + "\n"
}

// golden compares got with testdata/<name>.golden.html, or rewrites it when -update is set.
func golden(t *testing.T, name, got string) {
	t.Helper()
	path := filepath.Join("testdata", name+".golden.html")
	if *update {
		os.MkdirAll("testdata", 0o755)
		if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
			t.Fatal(err)
		}
		return
	}
	want, err := os.ReadFile(path)
	if err != nil {
		t.Fatalf("no golden file %s (run with -update to create it): %v", path, err)
	}
	if string(want) == got {
		return
	}
	wl, gl := strings.Split(string(want), "\n"), strings.Split(got, "\n")
	for i := 0; i < max(len(wl), len(gl)); i++ {
		var w, g string
		if i < len(wl) {
			w = wl[i]
		}
		if i < len(gl) {
			g = gl[i]
		}
		if w != g {
			t.Fatalf("%s differs at line %d:\n  golden: %q\n  actual: %q", path, i+1, w, g)
		}
	}
}

// tags returns the attribute values (or text) of every element with the given name, parsed as HTML.
func elements(t *testing.T, src, tag string) []html.Token {
	t.Helper()
	var out []html.Token
	z := html.NewTokenizer(strings.NewReader(src))
	for {
		switch z.Next() {
		case html.ErrorToken:
			return out
		case html.StartTagToken, html.SelfClosingTagToken:
			if tok := z.Token(); tok.Data == tag {
				out = append(out, tok)
			}
		}
	}
}

func attr(tok html.Token, name string) string {
	for _, a := range tok.Attr {
		if a.Key == name {
			return a.Val
		}
	}
	return ""
}

Three helpers carry the technique.

render renders the component and inserts a newline between every pair of tags (>< becomes > newline <). templ's output is compact, one long line, and a diff of one long line says nothing. After the split, each tag is a line, and a failure names the line.

golden compares the rendered string with a file under testdata. When the test binary is run with -update, it writes the file instead of comparing. The first failing line is reported with both versions, which is usually all you need to see what changed. The testdata directory name is a Go convention: the tool ignores it when building packages.

elements and attr parse the output with the tokenizer from golang.org/x/net/html and return the tags of one name, with their attributes. That is a small, dependency-light way to ask structural questions. (Libraries such as goquery give you CSS selectors on top of the same parser, if you want them.)

The tests

card_test.goGo
package main

import (
	"strings"
	"testing"
)

func TestGoldenCard(t *testing.T) {
	golden(t, "card", render(t, Card("Go testing", []string{"go", "testing"}, true)))
}

func TestGoldenCardWithoutTags(t *testing.T) {
	golden(t, "card-no-tags", render(t, Card("Quiet card", nil, false)))
}

func TestGoldenTable(t *testing.T) {
	golden(t, "table", render(t, Table([][2]string{{"Name", "Mara"}, {"Role", "<admin>"}})))
}

// Semantic checks survive harmless markup changes that would break a golden file.
func TestCardSemantics(t *testing.T) {
	html := render(t, Card("Go testing", []string{"go", "a&b"}, true))
	links := elements(t, html, "a")
	if len(links) != 2 || attr(links[0], "href") != "/tags/go" || attr(links[1], "href") != "/tags/a&b" {
		t.Errorf("links = %+v", links)
	}
	if card := elements(t, html, "article"); len(card) != 1 || !strings.Contains(attr(card[0], "class"), "featured") {
		t.Errorf("the featured class is missing: %+v", card)
	}
	if len(elements(t, render(t, Card("x", nil, false)), "ul")) != 0 {
		t.Error("a card without tags must not render an empty list")
	}
}

func TestStaleGolden(t *testing.T) {
	golden(t, "stale", render(t, Card("Go testing", []string{"go"}, false)))
}

The golden tests describe whole components: the featured card, the card without tags, and the table with a cell containing <admin>. The semantic test asks narrower questions: there are two links, they point at /tags/go and /tags/a&b (the second shows that attribute values are handled), the article carries the featured class, and a card without tags has no <ul> at all. The last test compares against a golden file that was written for an older version of the markup on purpose, to show how a failure reads.

Running them

Shell
go get github.com/a-h/[email protected] >/dev/null 2>&1
go get -tool github.com/a-h/templ/cmd/[email protected] >/dev/null 2>&1
go tool templ generate >/dev/null 2>&1
rm -f testdata/card.golden.html testdata/card-no-tags.golden.html testdata/table.golden.html
echo "--- first run, creating the golden files"
go test -run 'Golden(Card|Table|CardWithoutTags)$' -update -count=1 . | tail -1
echo "--- testdata/card.golden.html"; cat testdata/card.golden.html
echo "--- testdata/table.golden.html"; cat testdata/table.golden.html
echo "--- normal run"
go test -run 'Golden(Card|Table|CardWithoutTags)$|Semantics' -race -count=1 -v . 2>&1 | grep -v "^=== RUN"
echo "--- a stale golden file"
go test -run Stale -count=1 . 2>&1 | grep -v "^FAIL\|^---" | head -6 || true
Text
--- first run, creating the golden files
ok  	example.test/lab	0.232s
--- testdata/card.golden.html
<article class="card featured">
<h2>Go testing</h2>
<ul class="tags">
<li>
<a href="/tags/go">go</a>
</li>
<li>
<a href="/tags/testing">testing</a>
</li>
</ul>
</article>
--- testdata/table.golden.html
<table>
<tbody>
<tr>
<th scope="row">Name</th>
<td>Mara</td>
</tr>
<tr>
<th scope="row">Role</th>
<td>&lt;admin&gt;</td>
</tr>
</tbody>
</table>
--- normal run
--- PASS: TestGoldenCard (0.00s)
--- PASS: TestGoldenCardWithoutTags (0.00s)
--- PASS: TestGoldenTable (0.00s)
--- PASS: TestCardSemantics (0.00s)
PASS
ok  	example.test/lab	1.268s
--- a stale golden file
    card_test.go:36: no golden file testdata/stale.golden.html (run with -update to create it): open testdata/stale.golden.html: no such file or directory

The first run creates the golden files; open them and review them as you would any code, because they are now the specification. The card file shows the structure with featured in the class list, an unordered list with a link per tag. The table file shows the <admin> cell as &lt;admin&gt;, so the golden file also documents that escaping happens. The normal run passes all four tests under the race detector. The stale-golden run is the failure message: testdata/stale.golden.html differs at line 3, with the old line (</article>) and the new one (<ul class="tags">), which tells you in one glance that a list now appears where the golden file expected the card to end.

Making golden files work in a team

Review the diff, not the test. When someone changes markup on purpose, they rerun with -update, and the golden files appear in the pull request diff. That diff is the proof of exactly what changed in the output. A reviewer reading it catches the accidental parts: a stray class, a missing attribute, a changed element. Make a rule that nobody runs -update to make a failing test pass without reading what changed.

Keep goldens small. Test components, not pages. A golden file for a whole page changes every time any component changes, and the diffs become noise that people approve without reading. A golden per component isolates changes to the place they happen.

Remove nondeterminism at the source. Components that print the current time, a random ID, a CSRF token or a map in iteration order produce different output every run. Inject those values as parameters or through the context, and use fixed values in the test. Go's randomized map iteration is the usual hidden offender: iterate a sorted slice in the component.

Use both techniques on purpose. Golden files are right for stable presentational components (cards, tables, buttons) where any change should be noticed. Semantic checks are right where the structure matters more than the exact bytes: that a form has a label for each input, that every link is under /tags/, that no empty list is rendered, that an error message is tied to its field. They keep passing through harmless refactors, such as reindenting or reordering attributes.

Test the escaping. Keep at least one case per component with hostile data: markup in a title, a quote in an attribute, a javascript: URL in a link. It costs one line and it is the regression that would hurt most. The tests for escaping and raw HTML go further.

Beyond a single component

Layouts and pages are tested the same way with a stub child, as in layouts, which asserts the structure and the slots. Handlers that return components are tested with httptest, checking status, headers and, through elements, the shape of the body. And the HTMX fragments from the HTMX article deserve the strongest test: render the same data as a full page and as a fragment, and assert the fragment appears inside the page unchanged, so the two paths cannot drift.

Finally, run the tests in CI after go tool templ generate, with -race, as everywhere else in this series. A component test that passes against stale generated code is worse than no test. The test patterns for html/template apply almost unchanged, and the Go documentation on testing and on golang.org/x/net/html covers the packages used here.

Also inTesting
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.