adrianhesketh.com

Testing templ HTML rendering with goquery

I’ve been building the templ templating language [0] as a way to define and share reusable HTML UI components and layouts for the Go programming language.

Developers often use unit testing to describe the expected behaviour of components and layouts that they’re developing, to test that the expectations are met, and to ensure that future changes to the components don’t break that expected behaviour by re-running the tests after making changes.

goquery is a Go package that’s well suited to this task.

Defining a layout

While there’s some value in this post if you’re using an alternative templating engine, it’s based on templ, so I’ll explain a bit about how that works.

templ files contain layouts and components. The templ language is a mixture of HTML-like elements, text and Go expressions. Running templ generate reads the template files and outputs Go code, so posts.templ starts with a package definition, and imports that get output into the Go file.

{% package main %}

{% import "fmt" %}
{% import "time" %}

With that in place, it’s possible to start defining parts of the web page. A typical web page might include a header, a footer, and a navigation bar.

Creating a header component

The header is the first part of the website and includes the page name. To make it easy to find the header template within the HTML output during both unit and end-to-end testing, it’s common practice to add a data-testid attribute.

{% templ headerTemplate(name string) %}
	<header data-testid="headerTemplate">
		<h1>{%= name %}</h1>
	</header>
{% endtempl %}

To compile the template into Go code, run the templ generate command, and you’ll get a posts_templ.go file in the same directory containing the template function.

func headerTemplate(name string) templ.Component {
	return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
		ctx, _ = templ.RenderedCSSClassesFromContext(ctx)
		ctx, _ = templ.RenderedScriptsFromContext(ctx)
		err = templ.RenderScripts(ctx, w, )
		if err != nil {
			return err
		}
		_, err = io.WriteString(w, "<header")
		if err != nil {
			return err
		}
		_, err = io.WriteString(w, " data-testid=\"headerTemplate\"")
		if err != nil {
			return err
		}
		_, err = io.WriteString(w, ">")
		if err != nil {
			return err
		}
		_, err = io.WriteString(w, "<h1>")
		if err != nil {
			return err
		}
		_, err = io.WriteString(w, templ.EscapeString(name))
		if err != nil {
			return err
		}
		_, err = io.WriteString(w, "</h1>")
		if err != nil {
			return err
		}
		_, err = io.WriteString(w, "</header>")
		if err != nil {
			return err
		}
		return err
	})
}

Testing the header component

Since the template is just a normal Go function, we can test its behaviour using standard Go unit testing tools.

Lets create a posts_test.go file and start writing a unit test.

We’ll use the goquery library, so you’ll need to run go get github.com/PuerkitoBio/goquery to add it to your go.mod file.

The test file starts like any other, with the required imports.

package main

import (
	"context"
	"io"
	"testing"

	"github.com/PuerkitoBio/goquery"
)

Next, we can start to test the header component.

To use goquery to inspect the output, we’ll need to connect the header component’s Render method to the goquery.NewDocumentFromReader function with an io.Pipe.

func TestHeader(t *testing.T) {
	// Pipe the rendered template into goquery.
	r, w := io.Pipe()
	go func() {
		headerTemplate("Posts").Render(context.Background(), w)
		w.Close()
	}()
	doc, err := goquery.NewDocumentFromReader(r)
	if err != nil {
		t.Fatalf("failed to read template: %v", err)
	}

Then we can then check that the expected behaviour is observed.

The first behaviour to check is that the data-testid attribute has been added to the output, and that it has expected value of headerTemplate

goquery supports a variety of selectors, so it’s pretty straightforward to check.

	// Expect the component to include a testid.
	if doc.Find(`[data-testid="headerTemplate"]`).Length() == 0 {
		t.Error("expected data-testid attribute to be rendered, but it wasn't")
	}

The next behaviour to check is that the page name is visible in the output.

	// Expect the page name to be set correctly.
	expectedPageName := "Posts"
	if actualPageName := doc.Find("h1").Text(); actualPageName != expectedPageName {
		t.Errorf("expected page name %q, got %q", expectedPageName, actualPageName)
	}
}

The footer is at the bottom of the page and just has a copyright notice in it and the current date.

{% templ footerTemplate() %}
	<footer data-testid="footerTemplate">
		<div>&copy; {%= fmt.Sprintf("%d", time.Now().Year()) %}</div>
	</footer>
{% endtempl %}
func TestFooter(t *testing.T) {
	// Pipe the rendered template into goquery.
	r, w := io.Pipe()
	go func() {
		footerTemplate().Render(context.Background(), w)
		w.Close()
	}()
	doc, err := goquery.NewDocumentFromReader(r)
	if err != nil {
		t.Fatalf("failed to read template: %v", err)
	}
	// Expect the component to include a testid.
	if doc.Find(`[data-testid="footerTemplate"]`).Length() == 0 {
		t.Error("expected data-testid attribute to be rendered, but it wasn't")
	}
	// Expect the copyright notice to include the current year.
	expectedCopyrightNotice := fmt.Sprintf("© %d", time.Now().Year())
	if actualCopyrightNotice := doc.Find("div").Text(); actualCopyrightNotice != expectedCopyrightNotice {
		t.Errorf("expected copyright notice %q, got %q", expectedCopyrightNotice, actualCopyrightNotice)
	}
}

I don’t know if you spotted it, but there is a problem with the design of this code - the output of this template is not idempotent - if you call the component with the same parameters in a different year, the output will be different.

There’s a low risk of this being a problem during a test run, these sort of timing problems can introduce bugs into test suites [2], so you might prefer to structure your template so that it receives a date parameter instead.

A pattern is emerging now.

{% templ navTemplate() %}
	<nav data-testid="navTemplate">
		<ul>
			<li><a href="/">Home</a></li>
			<li><a href="/posts">Posts</a></li>
		</ul>
	</nav>
{% endtempl %}

The test just checks that the test id is present, but you might want to add some checks for specific link formatting, or any other requirements.

func TestNav(t *testing.T) {
	r, w := io.Pipe()
	go func() {
		navTemplate().Render(context.Background(), w)
		w.Close()
	}()
	doc, err := goquery.NewDocumentFromReader(r)
	if err != nil {
		t.Fatalf("failed to read template: %v", err)
	}
	// Expect the component to include a testid.
	if doc.Find(`[data-testid="navTemplate"]`).Length() == 0 {
		t.Error("expected data-testid attribute to be rendered, but it wasn't")
	}
}

Layout

With the header, footer and nav all tested, we can compose them into a layout that uses all of them, and also takes in an arbitrary content component.

The content component is rendered within the <main> element of the layout.

{% templ layout(name string, content templ.Component) %}
	<html>
		<head><title>{%= title %}</title></head>
		<body>
			{%! headerTemplate(name) %}
			{%! navTemplate() %}
			<main>
				{%! content %}
			</main>
		</body>
		{%! footerTemplate() %}
	</html>
{% endtempl %}

Pages

With the layout in place, we go another step higher and use the layout to create individual static pages on the Website.

Home page

{% templ homeTemplate() %}
	<div>Welcome to my website.</div>
{% endtempl %}

{% templ home() %}
	{%! layout("Home", homeTemplate()) %}
{% endtempl %}

templ allows the home() template to be configured as a route in any Go HTTP router:

http.Handle("/", templ.Handler(home()))

Pages are also templ components, so the tests structured in the same way.

There’s no need to test for the specifics about what gets rendered in the navTemplate or homeTemplate at the page level, because they’re already covered in other tests.

Some developers prefer to only test the external facing part of their code (e.g. at a page level), rather than testing each individual component, on the basis that it’s slower to make changes if the implementation is too tightly controlled.

For example, if a component is reused across pages, then it makes sense to test that in detail in its own test. In the pages or higher-order components that use it, there’s no point testing it again at that level, so I only check that it was rendered to the output by looking for its data-testid attribute, unless I also need to check what I’m passing to it.

func TestHome(t *testing.T) {
	r, w := io.Pipe()
	go func() {
		home().Render(context.Background(), w)
		w.Close()
	}()
	doc, err := goquery.NewDocumentFromReader(r)
	if err != nil {
		t.Fatalf("failed to read template: %v", err)
	}
	// Expect the page title to be set correctly.
	expectedTitle := "Home"
	if actualTitle := doc.Find("title").Text(); actualTitle != expectedTitle {
		t.Errorf("expected title name %q, got %q", expectedTitle, actualTitle)
	}
	// Expect the header to be rendered.
	if doc.Find(`[data-testid="headerTemplate"]`).Length() == 0 {
		t.Error("expected data-testid attribute to be rendered, but it wasn't")
	}
	// Expect the navigation to be rendered.
	if doc.Find(`[data-testid="navTemplate"]`).Length() == 0 {
		t.Error("expected nav to be rendered, but it wasn't")
	}
	// Expect the home template be rendered.
	if doc.Find(`[data-testid="homeTemplate"]`).Length() == 0 {
		t.Error("expected homeTemplate to be rendered, but it wasn't")
	}
}

Posts

In a blog, you might need to display some information that’s been retrieved from a database.

{% templ postsTemplate(posts []Post) %}
	<div data-testid="postsTemplate">
		{% for _, p := range posts %}
			<div data-testid="postsTemplatePost">
				<div data-testid="postsTemplatePostName">{%= p.Name %}</div>
				<div data-testid="postsTemplatePostAuthor">{%= p.Author %}</div>
			</div>
		{% endfor %}
	</div>
{% endtempl %}

{% templ posts(posts []Post) %}
	{%! layout("Posts", postsTemplate(posts)) %}
{% endtempl %}

Note that the template takes a slice (similar to a list type in other languages) of posts.

To use this template, we’d need a HTTP handler to collect the information from that database so a Go HTTP handler that renders this template would look something like this.

func NewPostsHandler() PostsHandler {
	// Replace this in-memory function with a call to a database.
	postsGetter := func() (posts []Post, err error) {
		return []Post{{Name: "templ", Author: "author"}}, nil
	}
	return PostsHandler{
		GetPosts: postsGetter,
	}
}

type PostsHandler struct {
	GetPosts func() ([]Post, error)
}

func (ph PostsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	ps, err := ph.GetPosts()
	if err != nil {
		log.Printf("failed to get posts: %v", err)
		http.Error(w, "failed to retrive posts", http.StatusInternalServerError)
		return
	}
	templ.Handler(posts(ps)).ServeHTTP(w, r)
}

type Post struct {
	Name   string
	Author string
}

When I’m writing HTTP handlers, I like to reduce the amount of dependency injection that the caller has to do to the minimum. In this case, the only time that I’ll be using a different implementation of the function that gets blog posts is during tests, so there’s no need to take any parameters in the NewPostsHandler() function.

http.Handle("/posts", NewPostsHandler())

Testing posts

Here, we can divide the testing into two areas:

  • Testing that the template works correctly independently of the HTTP handler.
  • Testing that the HTTP handler has correct behaviour.

Testing the posts template

The template test is similar to the previous tests, but also tests that the expected number of posts is rendered, and that the content is present.

As a template author and test writer, if you find yourself writing complex expressions to try to find things inside your output, you’re probably better off add another data-testid attribute in the appropriate place to make your life easier.

This helps keep the test suite less brittle to changes such as elements being moved around within the output HTML.

func TestPosts(t *testing.T) {
	testPosts := []Post{
		{Name: "Name1", Author: "Author1"},
		{Name: "Name2", Author: "Author2"},
	}
	r, w := io.Pipe()
	go func() {
		posts(testPosts).Render(context.Background(), w)
		w.Close()
	}()
	doc, err := goquery.NewDocumentFromReader(r)
	if err != nil {
		t.Fatalf("failed to read template: %v", err)
	}
	// Assert.
	// Expect the page title to be set correctly.
	expectedTitle := "Posts"
	if actualTitle := doc.Find("title").Text(); actualTitle != expectedTitle {
		t.Errorf("expected title name %q, got %q", expectedTitle, actualTitle)
	}
	// Expect the header to be rendered.
	if doc.Find(`[data-testid="headerTemplate"]`).Length() == 0 {
		t.Error("expected data-testid attribute to be rendered, but it wasn't")
	}
	// Expect the navigation to be rendered.
	if doc.Find(`[data-testid="navTemplate"]`).Length() == 0 {
		t.Error("expected nav to be rendered, but it wasn't")
	}
	// Expect the posts to be rendered.
	if doc.Find(`[data-testid="postsTemplate"]`).Length() == 0 {
		t.Error("expected posts to be rendered, but it wasn't")
	}
	// Expect both posts to be rendered.
	if actualPostCount := doc.Find(`[data-testid="postsTemplatePost"]`).Length(); actualPostCount != len(testPosts) {
		t.Fatalf("expected %d posts to be rendered, found %d", len(testPosts), actualPostCount)
	}
	// Expect the posts to contain the author name.
	doc.Find(`[data-testid="postsTemplatePost"]`).Each(func(index int, sel *goquery.Selection) {
		expectedName := testPosts[index].Name
		if actualName := sel.Find(`[data-testid="postsTemplatePostName"]`).Text(); actualName != expectedName {
			t.Errorf("expected name %q, got %q", actualName, expectedName)
		}
		expectedAuthor := testPosts[index].Author
		if actualAuthor := sel.Find(`[data-testid="postsTemplatePostAuthor"]`).Text(); actualAuthor != expectedAuthor {
			t.Errorf("expected author %q, got %q", actualAuthor, expectedAuthor)
		}
	})
}

Testing the posts HTTP handler

The HTTP handler test is structured slightly differently, making use of the table-driven test style commonly used in Go code.

The tests configure the GetPosts function on the PostsHandler with a mock that returns a “database error”, while the other returns a list of two posts.

In the error case, the test asserts that the error message was displayed, while in the success case, it checks that the postsTemplate is present. It doesn’t check check that the posts have actually been rendered properly or that specific fields are visible, because that’s already tested at the component level.

Testing it again here would make the code resistant to refactoring and rework, but then again, I might have missed actually passing the posts I got back from the database to the posts template, so it’s a matter of risk appetite vs refactor resistance.

func TestPostsHandler(t *testing.T) {
	tests := []struct {
		name           string
		postGetter     func() (posts []Post, err error)
		expectedStatus int
		assert         func(doc *goquery.Document)
	}{
		{
			name: "database errors result in a 500 error",
			postGetter: func() (posts []Post, err error) {
				return nil, errors.New("database error")
			},
			expectedStatus: http.StatusInternalServerError,
			assert: func(doc *goquery.Document) {
				expected := "failed to retrieve posts\n"
				if actual := doc.Text(); actual != expected {
					t.Errorf("expected error message %q, got %q", expected, actual)
				}
			},
		},
		{
			name: "database success renders the posts",
			postGetter: func() (posts []Post, err error) {
				return []Post{
					{Name: "Name1", Author: "Author1"},
					{Name: "Name2", Author: "Author2"},
				}, nil
			},
			expectedStatus: http.StatusInternalServerError,
			assert: func(doc *goquery.Document) {
				if doc.Find(`[data-testid="postsTemplate"]`).Length() == 0 {
					t.Error("expected posts to be rendered, but it wasn't")
				}
			},
		},
	}
	for _, test := range tests {
		// Arrange.
		w := httptest.NewRecorder()
		r := httptest.NewRequest(http.MethodGet, "/posts", nil)

		ph := NewPostsHandler()
		ph.Log = log.New(io.Discard, "", 0) // Suppress logging.
		ph.GetPosts = test.postGetter

		// Act.
		ph.ServeHTTP(w, r)
		doc, err := goquery.NewDocumentFromReader(w.Result().Body)
		if err != nil {
			t.Fatalf("failed to read template: %v", err)
		}

		// Assert.
		test.assert(doc)
	}
}

Summary

goquery can be used effectively with templ for writing component level tests.

Adding data-testid attributes to your code simplifies the test expressions you need to write to find elements within the output and makes your tests less brittle.

Testing can be split between the two concerns of template rendering, and HTTP handlers.

Resources