💾 Archived View for capsule.adrianhesketh.com › 2021 › 10 › 17 › testing-templ-html-rendering-with-g… captured on 2024-02-05 at 09:28:17. Gemini links have been rewritten to link to archived content
⬅️ Previous capture (2022-01-08)
-=-=-=-=-=-=-
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.
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.
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 }) }
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>© {%= 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") } }
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 %}
With the layout in place, we go another step higher and use the layout to create individual static pages on the Website.
{% 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") } }
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())
Here, we can divide the testing into two areas:
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) } }) }
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) } }
`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.
https://github.com/PuerkitoBio/goquery
https://github.com/a-h/templ/tree/main/example
Using Storybook with Go frontends
Trigger Fargate tasks on S3 upload with Go CDK