Testando seu aplicativo Go: comece do jeito certo

Publicados: 2022-03-11

Ao 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”.

Quer aprender Go? Confira nosso tutorial introdutório de Golang.

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.

Faça as coisas “the Go Way”, erradique as dependências de estruturas externas.
Tweet

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 ).

O teste de tabela recebe o nome de sua estrutura, facilmente representada por uma tabela com duas colunas: a variável de entrada e a variável de saída esperada.

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.

Uma interface é uma coleção nomeada de métodos, mas também um tipo de variável.

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.

O GoQuery usa uma API do tipo jQuery para consultar uma estrutura HTML, indispensável para testar a validade da saída de marcação de seus programas.

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”.

Nunca é uma boa ideia testar dados “ao vivo” em um servidor de produção “ao vivo”; crie cópias locais ou de desenvolvimento para que não haja danos se as coisas derem terrivelmente erradas.

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.