Skip to content
Trilha
Chapters

Learn

Testing

A test client in the framework itself: one request, a whole session, CSRF that just works.

An app made with Trilha is an http.Handler, so it can always be tested with httptest and nothing else. The problem is what comes before the first assertion: a client, a cookie jar, and the CSRF token copied from the cookie into the form. That is fifty lines every project writes again — and gets wrong the first time, because the double-submit only passes when the cookie comes back in the request.

The framework already issues that cookie and already checks that token, so it ships the client. No external test framework, no assertion library: package trilha never imports testing.

One request#

func TestListaPosts(t *testing.T) {
	res := trilha.TestRequest(t, newApp(), "GET", "/api/posts")
	res.WantStatus(200).WantContains(`"slug"`)
}

newApp() is the function the generator writes in trilha_gen.go: the same app that serves in production. The request goes through the real path — mux, middlewares, layouts, CSRF, error negotiation — and what comes back is the recorded response.

Assertions chain and never return an error. In a test the value of an error is stopping with the right message, so a failure prints the status, the target and the body:

GET /api/posts: status = 500, want 200
{"status":500,"title":"Internal Server Error","request_id":"…"}

A whole session#

When the test is a flow — open the form, submit it, follow the redirect — the client keeps the cookies the app sets:

func TestPublicar(t *testing.T) {
	c := trilha.NewTestClient(t, newApp())
	c.Get("/blog/novo").WantStatus(200)
	res := c.PostForm("/blog/novo", url.Values{"titulo": {"Hello"}})
	res.WantStatus(303).WantHeader("Location", "/blog/hello")
	c.Get("/blog/hello").WantContains("Hello")
}

Get, PostForm and PostJSON are shortcuts for Request, which takes any method. A redirect is not followed on its own: the test that wants the destination asks for the destination, because where a 303 lands is an assertion, not a detail.

CSRF passes by default#

Every request the helpers send carries the CSRF cookie, and every method with a body carries the same value in the X-CSRF-Token header.

A test that wants to prove the rejection asks for it:

c.PostForm("/blog/novo", form, trilha.WithoutCSRF()).WantStatus(403)

A signed session without logging in#

WithSigned writes a cookie signed with the app's own signer — the same one c.SetSigned uses in a handler. The admin page stops requiring a POST /login before every case:

res := trilha.TestRequest(t, newApp(), "GET", "/admin", trilha.WithSigned("sessao", "ana"))
res.WantStatus(200)

The signature is real: a session forged by hand still fails, which is what trilha.WithCookie("sessao", "ana|9999999999|fake") is for when you want to test the rejection.

One route.go, one page#

TestRoute mounts a throwaway app in Dev around a single route, so a handler can be tested where it lives, before it is registered anywhere:

res := trilha.TestRoute(t, trilha.Route{
	Pattern: "/api/itens/{id}",
	Methods: map[string]trilha.HandlerFunc{"GET": GET},
}, "GET", "/api/itens/7")
res.WantStatus(200).WantContains(`"id":7`)

The pattern is what resolves {id}, so c.Param("id") answers 7 — the router is doing the work, not a mock.

TestPage does the same for a page and also hands back the rendered node, layouts already applied:

res := trilha.TestPage(t, trilha.Route{Page: Page, Layouts: []trilha.LayoutFunc{Layout}}, "/sobre")
res.WantStatus(200)
if h.Render(res.Node) == "" {
	t.Fatal("empty page")
}

res.Body holds the whole document, with the layout around it; res.Node is just what the page returned. Asserting on the node survives a change of layout, which is usually what you want.

Both build the app for you; trilha.WithApp(a) uses yours instead, when the route depends on something Setup provided with trilha.Providetrilha.Use[T](a) reads it back in the test itself.

The options#

OptionWhat it does
WithApp(a)uses your app in TestRoute/TestPage instead of a throwaway one
WithHeader(name, value)one header (Accept, Trilha-Fragment, Authorization)
WithCookie(name, value)one raw cookie
WithSigned(name, value)one cookie signed by the app, valid for an hour
WithForm(values)body as application/x-www-form-urlencoded
WithJSON(v)body as application/json
WithBody(contentType, body)body exactly as written (multipart, CSV, a broken JSON)
WithoutCSRF()sends nothing about CSRF, to test the refusal

The response#

TestResponse embeds *httptest.ResponseRecorder, so Code, Body and Header() are still there for whatever the ready-made assertions do not cover.

MethodWhat it does
WantStatus(code)fails with the body when the status differs
WantContains(text)fails with the body when the text is missing
WantHeader(name, value)fails when the header differs
JSON(&v)decodes the body into v, failing with the body on invalid JSON
Cookie(name)the cookie this response set, or nil
Nodethe page's node, filled in by TestPage

Cookie is how you assert on a logout: what proves the session is gone is the app deleting the cookie, not the redirect that follows.

if res.Cookie("sessao") == nil {
	t.Fatal("logging out should clear the session")
}

Race and fuzzing#

Two bugs never show up in a deterministic suite. One is the data race: two requests touching the same field of the app at the same time — the asset cache, the metric counters, the rate-limit buckets. The other is the input nobody wrote: a path with %2e%2e, a cookie with the signature of another key, a form body that is a single ;.

The framework's own suite covers both, and the two commands are one line each:

make race    # go test -race ./...
make fuzz    # 20s on each fuzz target, same as CI

make race is only worth what the suite gives it to look at, so there is a test (TestConcorrencia) that hits the same app from 32 goroutines: it logs in, reads a signed page, calls an API route, asks for a static file and reads /metrics. Without it the detector would run over an app answering one request at a time and find nothing.

The fuzz targets state an invariant rather than an expected output:

TargetWhat it holds
FuzzRouteMatchno target crashes the app or serves a file from outside public/
FuzzBindForm / FuzzBindJSONif Bind returns no error, every validate rule holds
FuzzSignedVerifya cookie is only accepted if some key would have produced it, and it has not expired
FuzzParseTraceparentthe trace id is either empty or hex that came from the header
FuzzRenderEscapeswhatever goes into h.Text or an attribute comes back escaped

Fuzzing in your own app is the same shape. Write the target next to the code it tests, seed it with the cases you already know, and assert the property — not the output:

func FuzzSlug(f *testing.F) {
	for _, s := range []string{"", "Olá mundo", "a//b", "---"} {
		f.Add(s)
	}
	f.Fuzz(func(t *testing.T, s string) {
		got := Slug(s)
		if strings.ContainsAny(got, " /?#") {
			t.Fatalf("%q gerou %q", s, got)
		}
	})
}

Challenge#

Write a test proving that the blog's form rejects a title longer than the limit and shows the message on the page, without going through the API.

Show solution
func TestTituloLongo(t *testing.T) {
	c := trilha.NewTestClient(t, newApp())
	res := c.PostForm("/blog/novo", url.Values{"titulo": {strings.Repeat("a", 200)}})
	res.WantStatus(422).WantContains("no máximo")
}

The form answers 422 with the page re-rendered — the same body a browser would show — so one request covers the validation and the message. The CSRF token went along on its own.