Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions docs/docs/04-core-concepts/03-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,113 @@ func TestPostsHandler(t *testing.T) {
}
```

### Testing template arguments from a handler

When an HTTP handler queries a database and renders a templ component with the result, it is useful to verify that the handler is passing the right data to the template — without having to parse the rendered HTML.

templ's generated code supports an opt-in mechanism: if the request context contains a `map[string]any` value under the key `"_templ_args_map"`, the generated code populates it with the arguments passed to the component. This lets you inspect them directly in your test.

The complete runnable example is at https://github.com/a-h/templ/blob/main/examples/testing-args/.

Consider this handler that loads a user and renders a page:

```go
// template.templ
templ userPage(u User) {
<h1>{ u.Name }</h1>
}
```

```go
// handler.go
type User struct {
ID int
Name string
}

var getUser = func(id int) (User, error) {
return User{}, errors.New("user not found")
}

func UserPage(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
user, err := getUser(id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
userPage(user).Render(r.Context(), w)
}

func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", UserPage)
http.ListenAndServe(":8080", mux)
}
```

In the test, replace the `getUser` dependency, inject the args capture map into the context, call the mux, and read the captured arguments:

```go
// handler_test.go
func TestUserHandlerPassesCorrectUser(t *testing.T) {
getUser = func(id int) (User, error) {
return User{ID: id, Name: "Alice"}, nil
}

argsMap := make(map[string]any)
ctx := context.WithValue(context.Background(), "_templ_args_map", argsMap)

mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", UserPage)

w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/users/42", nil).WithContext(ctx)

mux.ServeHTTP(w, r)

u, ok := argsMap["u"].(User)
if !ok {
t.Fatal("template was not called or argument u was not captured")
}
if u.Name != "Alice" {
t.Errorf("expected user name Alice, got %q", u.Name)
}
}
```

The map key matches the **parameter name** in the templ function signature (`u` in `templ userPage(u User)`). For method receivers — `templ (this MyModel) Render(id int)` — the receiver variable is captured too, under its own name (`"this"`).

#### Nested components and duplicate parameter names

When components are nested, each one writes its arguments into the same map. If an outer and an inner component both have a parameter with the same name, **the outer component's value is kept** and the inner one is ignored.

```go
templ layout(title string) {
<html>
<head><title>{ title }</title></head>
<body>@page(title)</body> // page also has a "title" parameter
</html>
}

templ page(title string) {
<h1>{ title }</h1>
}
```

```go
argsMap := make(map[string]any)
ctx := context.WithValue(context.Background(), "_templ_args_map", argsMap)

layout("Outer title").Render(ctx, io.Discard)

// argsMap["title"] == "Outer title" ← layout wins; page's value is discarded
```

This is intentional: in a handler test you typically call the top-level component directly, so the outermost arguments are the ones you want to inspect.

This is not a test of the template output. It is a test of the **handler's logic**: confirming that it queries the right data and forwards it to the template unchanged.

### Summary

- goquery can be used effectively with templ for writing component level tests.
Expand Down
7 changes: 7 additions & 0 deletions examples/testing-args/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module github.com/a-h/templ/examples/testing-args

go 1.25.0

replace github.com/a-h/templ => ../../

require github.com/a-h/templ v0.0.0-00010101000000-000000000000
2 changes: 2 additions & 0 deletions examples/testing-args/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
32 changes: 32 additions & 0 deletions examples/testing-args/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package main

import (
"errors"
"net/http"
"strconv"
)

type User struct {
ID int
Name string
}

var getUser = func(id int) (User, error) {
return User{}, errors.New("user not found")
}

func UserPage(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
user, err := getUser(id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
userPage(user).Render(r.Context(), w)
}

func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", UserPage)
http.ListenAndServe(":8080", mux)
}
33 changes: 33 additions & 0 deletions examples/testing-args/handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package main

import (
"context"
"net/http"
"net/http/httptest"
"testing"
)

func TestUserHandlerPassesCorrectUser(t *testing.T) {
getUser = func(id int) (User, error) {
return User{ID: id, Name: "Alice"}, nil
}

argsMap := make(map[string]any)
ctx := context.WithValue(context.Background(), "_templ_args_map", argsMap)

mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", UserPage)

w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/users/42", nil).WithContext(ctx)

mux.ServeHTTP(w, r)

u, ok := argsMap["u"].(User)
if !ok {
t.Fatal("template was not called or argument u was not captured")
}
if u.Name != "Alice" {
t.Errorf("expected user name Alice, got %q", u.Name)
}
}
5 changes: 5 additions & 0 deletions examples/testing-args/template.templ
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package main

templ userPage(u User) {
<h1>{ u.Name }</h1>
}
57 changes: 57 additions & 0 deletions examples/testing-args/template_templ.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file added examples/testing-args/testing-args
Binary file not shown.
105 changes: 105 additions & 0 deletions generator/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,29 @@ func (g *generator) writeTemplate(nodeIdx int, t *parser.HTMLTemplate) error {
if _, err = g.w.WriteIndent(indentLevel, "templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context\n"); err != nil {
return err
}
var allNames []string
if recv := extractReceiverName(t.Expression.Value); recv != "" {
allNames = append(allNames, recv)
}
allNames = append(allNames, extractParamNames(extractParamList(t.Expression.Value))...)
if len(allNames) > 0 {
if _, err = g.w.WriteIndent(indentLevel, "templ_7745c5c3_ArgsMap, templ_7745c5c3_ArgsOk := ctx.Value(\"_templ_args_map\").(map[string]any)\n"); err != nil {
return err
}
if _, err = g.w.WriteIndent(indentLevel, "if templ_7745c5c3_ArgsMap != nil && templ_7745c5c3_ArgsOk {\n"); err != nil {
return err
}
indentLevel++
for _, name := range allNames {
if _, err = g.w.WriteIndent(indentLevel, fmt.Sprintf("templruntime.SetTemplArg(templ_7745c5c3_ArgsMap, %q, %s)\n", name, name)); err != nil {
return err
}
}
indentLevel--
if _, err = g.w.WriteIndent(indentLevel, "}\n"); err != nil {
return err
}
}
if _, err = g.w.WriteIndent(indentLevel, "if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {\n"); err != nil {
return err
}
Expand Down Expand Up @@ -1839,6 +1862,88 @@ func functionName(name string, body string) string {
return "__templ_" + name + "_" + hp
}

func extractReceiverName(expression string) string {
trimmed := strings.TrimSpace(expression)
if !strings.HasPrefix(trimmed, "(") {
return ""
}
depth := 0
for i, ch := range trimmed {
switch ch {
case '(':
depth++
case ')':
depth--
if depth == 0 {
parts := strings.Fields(strings.TrimSpace(trimmed[1:i]))
if len(parts) >= 1 {
return parts[0]
}
return ""
}
}
}
return ""
}

func extractParamList(expression string) string {
lastClose := strings.LastIndex(expression, ")")
if lastClose == -1 {
return ""
}
depth := 0
for i := lastClose; i >= 0; i-- {
if expression[i] == ')' {
depth++
} else if expression[i] == '(' {
depth--
if depth == 0 {
return expression[i+1 : lastClose]
}
}
}
return ""
}

func extractParamNames(paramList string) []string {
if strings.TrimSpace(paramList) == "" {
return nil
}
var params []string
var current strings.Builder
depth := 0
for _, ch := range paramList {
switch ch {
case '(', '[', '{':
depth++
current.WriteRune(ch)
case ')', ']', '}':
depth--
current.WriteRune(ch)
case ',':
if depth == 0 {
params = append(params, strings.TrimSpace(current.String()))
current.Reset()
} else {
current.WriteRune(ch)
}
default:
current.WriteRune(ch)
}
}
if s := strings.TrimSpace(current.String()); s != "" {
params = append(params, s)
}
var names []string
for _, param := range params {
parts := strings.Fields(param)
if len(parts) >= 1 {
names = append(names, strings.TrimPrefix(parts[0], "..."))
}
}
return names
}

func stripTypes(parameters string) string {
variableNames := []string{}
params := strings.Split(parameters, ",")
Expand Down
1 change: 1 addition & 0 deletions generator/test-a-href/template_templ.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading