Testando seu aplicativo Go: comece do jeito certo
Publicados: 2022-03-11Ao aprender algo novo, é importante ter um novo estado de espírito.
Se você é relativamente novo em Go e vem de linguagens como JavaScript ou Ruby, provavelmente está acostumado a usar frameworks existentes que o ajudam a simular, afirmar e fazer outros testes mágicos.
Agora erradique a ideia de dependência de dependências externas ou estruturas! O teste foi o primeiro impedimento que encontrei ao aprender essa linguagem de programação notável há alguns anos, uma época em que havia muito menos recursos disponíveis.
Agora eu sei que testar o sucesso em Go significa viajar leve nas dependências (como em todas as coisas em Go), depender minimamente de bibliotecas externas e escrever um bom código reutilizável. Esta apresentação das experiências de Blake Mizerany se aventurando com bibliotecas de teste de terceiros é um ótimo começo para ajustar sua mentalidade. Você verá alguns bons argumentos sobre o uso de bibliotecas e estruturas externas versus fazê-lo “do jeito Go”.
Pode parecer contra-intuitivo construir seu próprio framework de testes e simular conceitos, mas é mais fácil do que se imagina e um bom ponto de partida para aprender a linguagem. Além disso, ao contrário de quando eu estava aprendendo, você tem este artigo para guiá-lo através de cenários de teste comuns, bem como apresentar técnicas que considero práticas recomendadas para testar com eficiência e manter o código limpo.
Teste de tabela em Go
A unidade básica de teste - conhecida como 'teste unitário' - pode ser qualquer componente de um programa em sua forma mais simples que recebe uma entrada e retorna uma saída. Vamos dar uma olhada em uma função simples para a qual gostaríamos de escrever testes. Não é nem de longe perfeito ou completo, mas é bom o suficiente para fins de demonstração:
avg.go
func Avg(nos ...int) int { sum := 0 for _, n := range nos { sum += n } if sum == 0 { return 0 } return sum / len(nos) }
A função acima, func Avg(nos ...int)
, retorna zero ou a média inteira de uma série de números que são fornecidos a ela. Agora vamos escrever um teste para ele.
Em Go, é considerado uma prática recomendada nomear um arquivo de teste com o mesmo nome do arquivo que contém o código que está sendo testado, com o sufixo _test
adicionado. Por exemplo, o código acima está em um arquivo chamado avg.go
, então nosso arquivo de teste será nomeado avg_test.go
.
Observe que esses exemplos são apenas trechos de arquivos reais, pois a definição do pacote e as importações foram omitidas para simplificar.
Aqui está um teste para a função Avg
:
avg__test.go
func TestAvg(t *testing.T) { for _, tt := range []struct { Nos []int Result int }{ {Nos: []int{2, 4}, Result: 3}, {Nos: []int{1, 2, 5}, Result: 2}, {Nos: []int{1}, Result: 1}, {Nos: []int{}, Result: 0}, {Nos: []int{2, -2}, Result: 0}, } { if avg := Average(tt.Nos...); avg != tt.Result { t.Fatalf("expected average of %v to be %d, got %d\n", tt.Nos, tt.Result, avg) } } }
Há várias coisas a serem observadas sobre a definição da função:
- Primeiro, o prefixo 'Test' no nome da função de teste. Isso é necessário para que a ferramenta o pegue como um teste válido.
- A última parte do nome da função geralmente é o nome da função ou método que está sendo testado, neste caso
Avg
. - Também precisamos passar na estrutura de teste chamada
testing.T
, que permite o controle do fluxo do teste. Para obter mais detalhes sobre esta API, visite a página de documentação.
Agora vamos falar sobre a forma em que o exemplo está escrito. Um conjunto de testes (uma série de testes) está sendo executado por meio da função Avg()
e cada teste contém uma entrada específica e a saída esperada. No nosso caso, cada teste envia uma fatia de inteiros ( Nos
) e espera um valor de retorno específico ( Result
).
Simulação da interface Golang
Um dos maiores e mais poderosos recursos que a linguagem Go tem a oferecer é chamado de interface. Além do poder e da flexibilidade que obtemos da interface ao arquitetar nossos programas, a interface também nos oferece oportunidades incríveis de desacoplar nossos componentes e testá-los completamente em seu ponto de encontro.
Vamos pegar um cenário imaginário onde precisamos ler os primeiros N bytes de um io.Reader e devolvê-los como uma string. Ficaria algo assim:
readn.go
// readN reads at most n bytes from r and returns them as a string. func readN(r io.Reader, n int) (string, error) { buf := make([]byte, n) m, err := r.Read(buf) if err != nil { return "", err } return string(buf[:m]), nil }
Obviamente, o principal a testar é que a função readN
, quando recebe várias entradas, retorna a saída correta. Isso pode ser feito com o teste de tabela. Mas há dois outros aspectos não triviais que devemos abordar, que são verificar se:
-
r.Read
é chamado com um buffer de tamanho n. -
r.Read
retorna um erro se um for lançado.
Para saber o tamanho do buffer que é passado para r.Read
, bem como controlar o erro que ele retorna, precisamos simular o r
que está sendo passado para readN
. Se observarmos a documentação do Go no tipo Reader, veremos como é o io.Reader
:
type Reader interface { Read(p []byte) (n int, err error) }
Isso parece bastante fácil. Tudo o que temos que fazer para satisfazer o io.Reader
é ter nosso próprio mock de um método Read
. Então nosso ReaderMock
pode ser o seguinte:
type ReaderMock struct { ReadMock func([]byte) (int, error) } func (m ReaderMock) Read(p []byte) (int, error) { return m.ReadMock(p) }
Vamos analisar um pouco o código acima. Qualquer instância do ReaderMock
satisfaz claramente a interface io.Reader
porque implementa o método Read
necessário. Nosso mock também contém o campo ReadMock
, permitindo definir o comportamento exato do método mocked, o que torna super fácil instanciar dinamicamente o que precisarmos.
Um ótimo truque sem memória para garantir que a interface seja satisfeita em tempo de execução é inserir o seguinte em nosso código:
var _ io.Reader = (*MockReader)(nil)
Isso verifica a asserção, mas não aloca nada, o que nos permite ter certeza de que a interface está implementada corretamente em tempo de compilação, antes que o programa seja executado em qualquer funcionalidade que a utilize. Um truque opcional, mas útil.
Continuando, vamos escrever nosso primeiro teste, no qual r.Read
é chamado com um buffer de tamanho n
. Para fazer isso, usamos nosso ReaderMock
da seguinte forma:
func TestReadN_bufSize(t *testing.T) { total := 0 mr := &MockReader{func(b []byte) (int, error) { total = len(b) return 0, nil }} readN(mr, 5) if total != 5 { t.Fatalf("expected 5, got %d", total) } }
Como você pode ver acima, definimos o comportamento da função Read
do nosso “fake” io.Reader
com uma variável de escopo, que pode ser usada posteriormente para confirmar a validade do nosso teste. Bastante fácil.
Vejamos o segundo cenário que precisamos testar, que exige que zombemos de Read
para retornar um erro:
func TestReadN_error(t *testing.T) { expect := errors.New("some non-nil error") mr := &MockReader{func(b []byte) (int, error) { return 0, expect }} _, err := readN(mr, 5) if err != expect { t.Fatal("expected error") } }
Nos testes acima, qualquer chamada para mr.Read
(nosso mocked Reader) retornará o erro definido, portanto, é seguro assumir que o funcionamento correto de readN
fará o mesmo.
Função Mocking com Go
Não é sempre que precisamos zombar de uma função, porque tendemos a usar estruturas e interfaces. Estes são mais fáceis de controlar, mas ocasionalmente podemos esbarrar nessa necessidade, e frequentemente vejo confusão em torno do assunto. Algumas pessoas até perguntaram como zombar de coisas como log.Println
. Embora raramente precisemos testar a entrada fornecida para log.Println
, usaremos esta oportunidade para demonstrar.

Considere esta simples instrução if
abaixo que registra a saída dependendo do valor de n
:
func printSize(n int) { if n < 10 { log.Println("SMALL") } else { log.Println("LARGE") } }
No exemplo acima, assumimos o cenário ridículo em que testamos especificamente que log.Println
é chamado com os valores corretos. Para que possamos zombar dessa função, temos que envolvê-la dentro da nossa primeiro:
var show = func(v ...interface{}) { log.Println(v...) }
Declarar a função dessa maneira - como uma variável - nos permite sobrescrevê-la em nossos testes e atribuir qualquer comportamento que desejarmos a ela. Implicitamente, as linhas referentes a log.Println
são substituídas por show
, então nosso programa se torna:
func printSize(n int) { if n < 10 { show("SMALL") } else { show("LARGE") } }
Agora podemos testar:
func TestPrintSize(t *testing.T) { var got string oldShow := show show = func(v ...interface{}) { if len(v) != 1 { t.Fatalf("expected show to be called with 1 param, got %d", len(v)) } var ok bool got, ok = v[0].(string) if !ok { t.Fatal("expected show to be called with a string") } } for _, tt := range []struct{ N int Out string }{ {2, "SMALL"}, {3, "SMALL"}, {9, "SMALL"}, {10, "LARGE"}, {11, "LARGE"}, {100, "LARGE"}, } { got = "" printSize(tt.N) if got != tt.Out { t.Fatalf("on %d, expected '%s', got '%s'\n", tt.N, tt.Out, got) } } // careful though, we must not forget to restore it to its original value // before finishing the test, or it might interfere with other tests in our // suite, giving us unexpected and hard to trace behavior. show = oldShow }
Nosso takeaway não deve ser 'mock log.Println
', mas naqueles cenários muito ocasionais quando precisamos zombar de uma função de nível de pacote por motivos legítimos, a única maneira de fazer isso (até onde eu saiba) é declarando-a como uma variável de nível de pacote para que possamos assumir o controle de seu valor.
No entanto, se precisarmos zombar de coisas como log.Println
, uma solução muito mais elegante pode ser escrita se usarmos um registrador personalizado.
Testes de renderização de modelos Go
Outro cenário bastante comum é testar se a saída de um modelo renderizado está de acordo com as expectativas. Vamos considerar uma solicitação GET para http://localhost:3999/welcome?name=Frank
, que retorna o seguinte corpo:
<html> <head><title>Welcome page</title></head> <body> <h1 class="header-name"> Welcome <span class="name">Frank</span>! </h1> </body> </html>
Caso ainda não tenha ficado óbvio o suficiente, não é coincidência que o name
do parâmetro de consulta corresponda ao conteúdo do span
classificado como “nome”. Nesse caso, o teste óbvio seria verificar se isso acontece corretamente todas as vezes em várias saídas. Achei a biblioteca GoQuery imensamente útil aqui.
Agora podemos escrever nosso teste desta maneira:
welcome__test.go
func TestWelcome_name(t *testing.T) { resp, err := http.Get("http://localhost:3999/welcome?name=Frank") if err != nil { t.Fatal(err) } if resp.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", resp.StatusCode) } doc, err := goquery.NewDocumentFromResponse(resp) if err != nil { t.Fatal(err) } if v := doc.Find("h1.header-name span.name").Text(); v != "Frank" { t.Fatalf("expected markup to contain 'Frank', got '%s'", v) } }
Primeiro, verificamos se o código de resposta era 200/OK antes de prosseguir.
Acredito que não seja exagero supor que o restante do trecho de código acima é autoexplicativo: recuperamos a URL usando o pacote http
e criamos um novo documento compatível com goquery a partir da resposta, que usamos para consultar o DOM que foi retornado. Verificamos se span.name
dentro h1.header-name
encapsula o texto 'Frank'.
Testando APIs JSON
Go é frequentemente usado para escrever APIs de algum tipo, então, por último, mas não menos importante, vamos examinar algumas maneiras de alto nível de testar APIs JSON.
Considere se o endpoint retornou anteriormente JSON em vez de HTML, portanto, de http://localhost:3999/welcome.json?name=Frank
, esperaríamos que o corpo da resposta fosse algo como:
{"Salutation": "Hello Frank!"}
Afirmar respostas JSON, como já se deve ter adivinhado, não é muito diferente de afirmar respostas de modelo, com a exceção de que não precisamos de bibliotecas ou dependências externas. As bibliotecas padrão do Go são suficientes. Aqui está nosso teste confirmando que o JSON correto é retornado para os parâmetros fornecidos:
welcome__test.go
func TestWelcome_name_JSON(t *testing.T) { resp, err := http.Get("http://localhost:3999/welcome.json?name=Frank") if err != nil { t.Fatal(err) } if resp.StatusCode != 200 { t.Fatalf("expected 200, got %d", resp.StatusCode) } var dst struct{ Salutation string } if err := json.NewDecoder(resp.Body).Decode(&dst); err != nil { t.Fatal(err) } if dst.Salutation != "Hello Frank!" { t.Fatalf("expected 'Hello Frank!', got '%s'", dst.Salutation) } }
Se algo diferente da estrutura que decodificamos for retornado, json.NewDecoder
retornará um erro e o teste falhará. Considerando que a resposta decodifica com sucesso na estrutura, verificamos se o conteúdo do campo está conforme o esperado - no nosso caso “Hello Frank!”.
Configuração e desmontagem
Testar com Go é fácil, mas há um problema com o teste JSON acima e o teste de renderização de modelo antes disso. Ambos assumem que o servidor está em execução e isso cria uma dependência não confiável. Além disso, não é uma boa ideia ir contra um servidor “ao vivo”.
Felizmente, Go oferece o pacote httptest para criar servidores de teste. Os testes acionam seu próprio servidor separado, independente do nosso principal, e assim os testes não interferem na produção.
Nesses casos, é ideal criar funções genéricas de setup
e teardown
para serem chamadas por todos os testes que exigem um servidor em execução. Seguindo esse novo padrão mais seguro, nossos testes ficariam mais ou menos assim:
func setup() *httptest.Server { return httptest.NewServer(app.Handler()) } func teardown(s *httptest.Server) { s.Close() } func TestWelcome_name(t *testing.T) { srv := setup() url := fmt.Sprintf("%s/welcome.json?name=Frank", srv.URL) resp, err := http.Get(url) // verify errors & run assertions as usual teardown(srv) }
Observe a referência app.Handler()
. Essa é uma função de prática recomendada que retorna o http.Handler do aplicativo, que pode instanciar seu servidor de produção ou um servidor de teste.
Conclusão
Testar em Go é uma ótima oportunidade para assumir a perspectiva externa do seu programa e assumir o papel de seus visitantes ou, na maioria dos casos, dos usuários de sua API. Ele oferece a grande oportunidade de garantir que você esteja entregando um bom código e uma experiência de qualidade.
Sempre que você não tiver certeza das funcionalidades mais complexas em seu código, o teste é útil como uma garantia e também garante que as peças continuarão a funcionar bem juntas ao modificar partes de sistemas maiores.
Espero que este artigo tenha sido útil para você, e você pode comentar se souber de outros truques de teste.