-- Leo's gemini proxy

-- Connecting to capsule.adrianhesketh.com:1965...

-- Connected

-- Sending request

-- Meta line: 20 text/gemini; charset=utf-8

capsule.adrianhesketh.com


home


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.


[0]


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.


[1]


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)
	}
}

Footer


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.


[2]


Nav


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


https://github.com/PuerkitoBio/goquery

https://github.com/a-h/templ

https://github.com/a-h/templ/tree/main/example


More


Next


Using Storybook with Go frontends


Previous


Trigger Fargate tasks on S3 upload with Go CDK


Home


home

-- Response ended

-- Page fetched on Sat Apr 27 19:26:12 2024