Ir para o conteúdo
Trilha
Capítulos

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çãoO 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étodoO 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
Nodeo 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")
}

O que o navegador vê#

Uma resposta prova o que o servidor mandou. Entre isso e a tela há duas coisas, e cada uma tem a sua ferramenta.

O HTML servido, sem navegador. O Snapshot() de uma resposta — ou o trilha.CapturePage — tira o que muda a cada pedido (nonce, token CSRF, request id, cookies, carimbos de data) e confere o que a página promete: HasCSRFToken, HasCSPNonce, HasSafeCookies, HasNoSecret, HasAria, FocusedOnError, e MatchGolden para segurar a marcação num arquivo. Cada um devolve um error que diz o que mudar. Roda em todo go test; veja a referência.

snap := c.Get("/billing/planos").WantStatus(200).Snapshot()
for _, err := range []error{snap.HasCSRFToken(), snap.HasCSPNonce(), snap.HasSafeCookies()} {
	if err != nil {
		t.Error(err)
	}
}

A página rodando, num navegador de verdade. O foco indo para o campo que falhou, um fragmento trocado no lugar, a barra do envio, uma ilha montando, uma dica aberta pelo teclado — só um navegador mostra. O módulo github.com/emersonjoe/trilha/uitest tem go.mod próprio, então a dependência dele (Playwright) nunca chega ao framework nem ao seu app:

func TestFormularioDePerfil(t *testing.T) {
	uitest.RunWith(t, "..", uitest.Config{Env: []string{"TRILHA_SECRET=…"}}, func(s *uitest.Session) {
		s.Navigate("/perfil")
		s.Fill("#email", "ana@example.com")
		s.Click("#profile button[type=submit]")
		s.WantAttr("#name", "aria-invalid", "true")
		s.WantFocus("#name")
	})
}

O Run compila o app uma vez, sobe o binário numa porta livre e roda o cenário em cada navegador de UITEST_BROWSERS — chromium por padrão, uma lista como firefox,webkit, ou all para os três motores com que as pessoas navegam —, cada vez num contexto de navegador headless novo; o s.Browser diz qual. Não há screenshot nem sleep: todo passo espera a sua condição, até 30 segundos. Um cenário que falha roda mais uma vez do zero; falhando duas, grava report/<cenário>-<navegador>.txt — passo, seletor, esperado, obtido, o conserto, o console do navegador e o log do servidor —, que é o que um agente lê para corrigir a página. Os navegadores são instalados uma vez por máquina (go run github.com/mxschmitt/playwright-go/cmd/playwright install chromium firefox webkit); sem eles os cenários pulam, e UITEST_REQUIRED=1 transforma isso em falha. O CSPViolations() lista o que a política recusou, que a própria página nunca mostra.

Tenha poucos destes, um por jornada que dói se quebrar — entrar, salvar um formulário, navegar numa lista, enviar um arquivo —, e escreva cada um como caso: pré-condições, dados, passos, resultado esperado. Caso extremo fica na unidade e na integração, que rodam em milissegundos.

Neste repositório, make test-ui roda os cenários do navegador sobre um projeto feito por trilha new e trilha add (make test-ui-install baixa os navegadores, e a CI roda um job por motor), o uitest/JORNADAS.md documenta cada cenário como caso de teste, e make test-ui-a só as conferências do HTML servido.

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 CI

O 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:

AlvoO que ele sustenta
FuzzRouteMatchnenhum alvo derruba o app nem serve arquivo de fora do public/
FuzzBindForm / FuzzBindJSONse o Bind não devolve erro, toda regra do validate vale
FuzzSignedVerifyum cookie só é aceito se alguma chave o teria produzido, e enquanto não vence
FuzzParseTraceparento id do trace ou é vazio ou é hexadecimal que veio do cabeçalho
FuzzRenderEscapeso 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.