Aprender
Testes
Um cliente de teste no próprio framework: um pedido, uma sessão inteira, CSRF que já passa.
Um app feito com Trilha é um http.Handler, então sempre deu para testá-lo com httptest e mais nada. O problema é o que vem antes da primeira asserção: um cliente, um pote de cookies e o token do CSRF copiado do cookie para o campo do formulário. São cinquenta linhas que todo projeto escreve de novo — e erra na primeira tentativa, porque o duplo envio só passa quando o cookie volta no pedido.
O framework já emite esse cookie e já confere esse token, então ele traz o cliente junto. Sem framework de teste externo, sem biblioteca de asserção: o package trilha nunca importa testing.
Um pedido#
func TestListaPosts(t *testing.T) {
res := trilha.TestRequest(t, newApp(), "GET", "/api/posts")
res.WantStatus(200).WantContains(`"slug"`)
}newApp() é a função que o gerador escreve no trilha_gen.go: o mesmo app que serve em produção. O pedido passa pelo caminho de verdade — mux, middlewares, layouts, CSRF, negociação de erro — e o que volta é a resposta gravada.
As asserções encadeiam e nenhuma devolve error. Em teste, o valor de um erro é parar com a mensagem certa, então a falha imprime o status, o alvo e o corpo:
GET /api/posts: status = 500, want 200
{"status":500,"title":"Internal Server Error","request_id":"…"}Uma sessão inteira#
Quando o teste é um fluxo — abrir o formulário, enviar, seguir o redirecionamento — o cliente guarda os cookies que o app põe:
func TestPublicar(t *testing.T) {
c := trilha.NewTestClient(t, newApp())
c.Get("/blog/novo").WantStatus(200)
res := c.PostForm("/blog/novo", url.Values{"titulo": {"Olá"}})
res.WantStatus(303).WantHeader("Location", "/blog/ola")
c.Get("/blog/ola").WantContains("Olá")
}Get, PostForm e PostJSON são atalhos do Request, que aceita qualquer método. O redirecionamento não é seguido sozinho: o teste que quer o destino pede o destino, porque onde um 303 para é uma asserção, não um detalhe.
O CSRF passa por padrão#
Todo pedido enviado pelos auxiliares leva o cookie do CSRF, e todo método com corpo leva o mesmo valor no cabeçalho X-CSRF-Token.
Um teste que queira provar a recusa pede a recusa:
c.PostForm("/blog/novo", form, trilha.WithoutCSRF()).WantStatus(403)Sessão assinada sem passar pelo login#
WithSigned grava um cookie assinado com o signer do próprio app — o mesmo que o c.SetSigned do handler usa. A página do administrador deixa de exigir um POST /login antes de cada caso:
res := trilha.TestRequest(t, newApp(), "GET", "/admin", trilha.WithSigned("sessao", "ana"))
res.WantStatus(200)A assinatura é de verdade: uma sessão forjada à mão continua falhando, que é para isso que serve o trilha.WithCookie("sessao", "ana|9999999999|assinatura-falsa") quando o que você quer testar é a recusa.
Um route.go, uma página#
O TestRoute monta um app descartável em Dev em volta de uma rota só, então dá para testar o handler onde ele mora, antes de estar registrado em qualquer lugar:
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`)É o padrão que resolve o {id}, então o c.Param("id") responde 7 — quem faz o trabalho é o roteador, não um dublê.
O TestPage faz o mesmo por uma página e ainda devolve o nó renderizado, com os layouts já aplicados:
res := trilha.TestPage(t, trilha.Route{Page: Page, Layouts: []trilha.LayoutFunc{Layout}}, "/sobre")
res.WantStatus(200)
if h.Render(res.Node) == "" {
t.Fatal("página vazia")
}O res.Body tem o documento inteiro, com o layout em volta; o res.Node é só o que a página devolveu. Asserção no nó sobrevive a uma troca de layout, que costuma ser o que você quer.
Os dois montam o app para você; trilha.WithApp(a) usa o seu, quando a rota depende de algo que o Setup proveu com trilha.Provide — trilha.Use[T](a) lê de volta no próprio teste.
As opções#
| Opção | O que faz |
|---|---|
WithApp(a) | usa o seu app no TestRoute/TestPage em vez de um descartável |
WithHeader(nome, valor) | um cabeçalho (Accept, Trilha-Fragment, Authorization) |
WithCookie(nome, valor) | um cookie cru |
WithSigned(nome, valor) | um cookie assinado pelo app, válido por uma hora |
WithForm(values) | corpo em application/x-www-form-urlencoded |
WithJSON(v) | corpo em application/json |
WithBody(contentType, corpo) | corpo exatamente como escrito (multipart, CSV, um JSON quebrado) |
WithoutCSRF() | não manda nada de CSRF, para testar a recusa |
A resposta#
O TestResponse embute o *httptest.ResponseRecorder, então Code, Body e Header() continuam à mão para o que as asserções prontas não cobrirem.
| Método | O que faz |
|---|---|
WantStatus(código) | falha com o corpo quando o status é outro |
WantContains(texto) | falha com o corpo quando o texto não está lá |
WantHeader(nome, valor) | falha quando o cabeçalho é outro |
JSON(&v) | decodifica o corpo em v, falhando com o corpo se o JSON for inválido |
Cookie(nome) | o cookie que esta resposta pôs, ou nil |
Node | o nó da página, preenchido pelo TestPage |
O Cookie é como se faz a asserção de uma saída: o que prova que a sessão acabou é o app apagar o cookie, não o redirecionamento que vem depois.
if res.Cookie("sessao") == nil {
t.Fatal("sair devia limpar a sessão")
}Corrida e fuzzing#
Dois defeitos nunca aparecem numa suíte determinística. Um é a corrida de dados: dois pedidos mexendo no mesmo campo do app ao mesmo tempo — o cache de assets, os contadores da métrica, os baldes do limite de taxa. O outro é a entrada que ninguém escreveu: um caminho com %2e%2e, um cookie com a assinatura de outra chave, um corpo de formulário que é só um ;.
A suíte do framework cobre os dois, e cada comando é uma linha:
make race # go test -race ./...
make fuzz # 20s em cada alvo de fuzzing, o mesmo do CIO make race só vale o que a suíte der para ele olhar, então existe um teste (TestConcorrencia) que bate no mesmo app com 32 goroutines: entra, lê uma página assinada, chama uma rota de API, pede um arquivo estático e lê o /metrics. Sem ele o detector passaria por um app respondendo um pedido por vez e não acharia nada.
Os alvos de fuzzing afirmam uma invariante, não uma saída esperada:
| Alvo | O que ele sustenta |
|---|---|
FuzzRouteMatch | nenhum alvo derruba o app nem serve arquivo de fora do public/ |
FuzzBindForm / FuzzBindJSON | se o Bind não devolve erro, toda regra do validate vale |
FuzzSignedVerify | um cookie só é aceito se alguma chave o teria produzido, e enquanto não vence |
FuzzParseTraceparent | o id do trace ou é vazio ou é hexadecimal que veio do cabeçalho |
FuzzRenderEscapes | o que entra num h.Text ou num atributo volta escapado |
Fazer fuzzing no seu app tem a mesma forma. Escreva o alvo ao lado do código que ele testa, semeie com os casos que você já conhece e afirme a propriedade — não a saída:
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)
}
})
}Desafio#
Escreva um teste provando que o formulário do blog recusa um título maior que o limite e mostra a mensagem na página, sem passar pela API.
Mostrar solução
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")
}O formulário responde 422 com a página redesenhada — o mesmo corpo que o navegador mostraria —, então um pedido cobre a validação e a mensagem. O token do CSRF foi junto sozinho.