Testes automatizados em Go
Primeiro, alguns disclaimers:
- Este texto é baseado no conteúdo de um workshop de testes que foi apresentado para alguns times do PicPay.
- O texto é um pouco longo, então recomendo ser acompanhado de uma boa xícara de café (ou chá) :)
- Eu tenho usado este conteúdo como referência e exemplos no meu dia a dia, então recomendo adicionar no seu bookmark e voltar de tempos em tempos para relembrar os tópicos ;)
- Apesar dos exemplos (e o título), serem na linguagem Go, acredito que os conceitos apresentados aqui podem ser aproveitados em qualquer linguagem de programação moderna.
E agora vamos ao conteúdo…
Acho que é impossível começar um texto sobre testes sem pensar em dois tópicos: a importância dos testes e a Pirâmide de Testes, certo?
A importância dos testes
Quanto ao primeiro item, vou tentar reforçar o assunto com três "insights". O primeiro vem de um amigo e colega que eu admiro muito, o Luís Cobucci que nos presenteou com a seguinte pérola em uma palestra:
Quando você vai no médico ele pede “posso lavar as mãos antes de operar você?”? Da mesma forma, é esperado que devs entreguem software de qualidade e testes tem grande importância nisso.
O segundo insight é de minha humilde autoria e resume minha visão sobre os testes automatizados:
Testes não são só para melhorar a qualidade do código, são para melhorar a qualidade de vida das pessoas desenvolvedoras. Nada como a tranquilidade de saber se seu código quebrou algo em poucos segundos e não na mão do seu cliente.
E nada melhor que finalizar este ponto com uma frase de um autor famoso, o Steve McConnell:
Só testar não aumenta a qualidade do software. Tentar aumentar a qualidade do software apenas aumentando o número de testes é a mesma coisa que tentar perder peso se pesando com mais frequência.
A Pirâmide de testes
Estas três frases nortearam a escrita deste texto pois falam sobre as nossas responsabilidades como pessoas desenvolvedoras de software mas também nos lembram das vantagens de um teste bem escrito. E também nos fazem pensar onde e como testar, o que nos trás ao segundo tópico obrigatório: a Pirâmide de Testes.
Estrutura dos testes
Mas antes de começarmos a ver exemplos de cada tipo de teste, vamos olhar para a estrutura de um bom teste:
- Configure os dados de teste, prepare o teste
- Invoque o método/função sendo testada, execute o teste
- Confirme que os resultados esperados são retornados, verifique as asserções
Este padrão também é conhecido como Arrange (Prepare o teste), Act (Execute o teste) e Assert (Verifique as asserções). Vamos observar esta estrutura em todos os testes.
Os exemplos de teste apresentados neste documento podem ser encontrados neste repositório.
Testes unitários
Testes de unidade garantem que uma determinada unidade (o sujeito em teste) da base de código funcione conforme o esperado. Os testes de unidade têm o escopo mais restrito de todos os testes do conjunto de testes. O número de testes de unidade do conjunto de testes superará em grande parte qualquer outro tipo de teste.
O que testar?
Os testes unitários devem pelo menos testar a interface pública do pacote. Em Go é possível testar tanto as funções públicas (as que começam com a primeira letra maiúscula) quanto as funções privadas do pacote, mas é recomendado testarmos prioritariamente as públicas.
Há uma linha tênue quando se trata de escrever testes de unidade: eles devem garantir que todos os seus caminhos de código não triviais sejam testados (incluindo caminho feliz e casos de borda). Ao mesmo tempo, eles não devem estar muito vinculados à sua implementação.
Por que isso?
Testes muito próximos do código de produção rapidamente se tornam irritantes. Assim que você refatorar seu código de produção (recapitulação rápida: refatorar significa alterar a estrutura interna do seu código sem alterar o comportamento visível externamente), seus testes de unidade irão quebrar. Resumindo, não reflita sua estrutura de código interna em seus testes de unidade. Teste para comportamento observável em vez disso. Para ilustrar esse conceito, no código a seguir:
O recomendado é criarmos testes para a interface pública do pacote, as funções NewService
, FindAll
e FindyByRole
. Desta forma, se for necessário uma refatoração nas funções internas, como a getDefaultPrivileges
e walkPrivilegeChildren
não é necessário refatorar também os testes unitários. Para fazer isso em Go basta criar um pacote especial no momento da escrita do teste:
Arquivo service_test.go
Desta forma, nosso teste se comporta como um pacote diferente, apesar do arquivo estar no mesmo diretório que o service.go
. Essa é uma facilidade da linguagem para facilitar a criação de testes.
Exemplos de teste unitário
Este arquivo contém os testes do serviço que implementam a interface UseCase.
Como o serviço tem por dependência uma implementação da interface Repository (que por sua vez precisa de uma conexão com o banco de dados), vamos usar o conceito de mocks para mantermos o foco do teste apenas na regra de negócio do serviço. Para gerarmos facilmente os mocks
estamos usando a ferramenta mockery, que lê as interfaces e gera código para usarmos nos testes. A geração dos mocks
é executada pelo comando make generate-mocks
e pode ser executada manualmente ou automaticamente quando executamos o comando make unit-test
Este arquivo contém os testes do serviço que implementam a interface UseCase.
Este é um serviço que faz uso de uma API externa. Para não acessar a API real a cada teste criamos um mock
para simular o seu comportamento. Vale destacar uma boa prática neste pacote. Ao invés de colocarmos como dependência do UseCase
um http.Client
padrão da linguagem foi criada uma interface para ser usada como dependência. No construtor do serviço criamos uma instância de http.Client
e damos a opção do usuário substituir esse cliente padrão por outra implementação. Fazemos uso desta opção no momento do teste ao passar um mock
do client. Esta implementação pode ser resumida pela frase “Don’t Mock What You Don’t Own”
e mais detalhes podem ser vistos neste post.
internal/http/echo/handler_test.go
Neste arquivo implementamos os testes unitários da camada de API.
Eles usam os mocks
da camada de UseCase
.
Executando os testes unitários
Execute
make unit-test
Testes de integração
Todos os aplicativos não triviais serão integrados com algumas outras partes (bancos de dados, sistemas de arquivos, chamadas de rede para outros aplicativos). Ao escrever testes de unidade, essas são geralmente as partes que você deixa de fora para obter um melhor isolamento e testes mais rápidos. Ainda assim, seu aplicativo irá interagir com outras partes e isso precisa ser testado.
Testes de integração estão disponíveis para ajudar. Eles testam a integração do seu aplicativo com todas as partes que vivem fora do seu aplicativo.
Para seus testes automatizados, isso significa que você não precisa apenas executar seu próprio aplicativo, mas também o componente com o qual está integrando. Se você estiver testando a integração com um banco de dados, precisará executar um banco de dados ao executar seus testes. Para testar se você pode ler arquivos de um disco, você precisa salvar um arquivo em seu disco e carregá-lo em seu teste de integração.
Um teste de integração de banco de dados ficaria assim:
- Iniciar um banco de dados;
- Conectar seu aplicativo ao banco de dados;
- Acionar uma função dentro do seu código que grava dados no banco de dados;
- Verificar se os dados esperados foram gravados no banco de dados lendo os dados do banco de dados.
Outro exemplo, testar se seu serviço se integra a um serviço separado por meio de uma API REST, pode ser assim:
- Inicie seu aplicativo;
- Inicie uma instância do serviço separado (ou um teste duplo com a mesma interface);
- Acione uma função em seu código que lê a API do serviço separado;
- Verifique se seu aplicativo pode analisar a resposta corretamente.
Escreva testes de integração, para todos os trechos de código em que você serializa ou "desserializa" dados. Exemplos:
- Chamadas para a API REST dos seus serviços;
- Leitura e gravação em bancos de dados;
- Chamada de APIs de outros aplicativos;
- Leitura e gravação em filas;
- Escrevendo no sistema de arquivos.
Ao escrever testes de integração, você deve tentar executar suas dependências externas localmente: execute um banco de dados MySQL local, teste em um sistema de arquivos local, etc. Se você estiver integrando com um serviço separado, execute uma instância desse serviço localmente ou crie e execute uma versão falsa que imita o comportamento do serviço real.
Exemplo de teste de integração
Este teste faz a validação da camada de integração com o banco de dados. Ele cria um container Docker, conecta no banco de dados, cria as tabelas, executa os testes, e no final faz o truncate das tabelas e destrói o container
Executando os testes de integração
Execute
make integration
Suite test
Para reutilizar código e organizar a inicialização dos testes, pode ser utilizado o Suite da própria Testify. Com o suite, podemos utilizar os métodos SetupTest e TearDownTest, garantindo um teste limpo e assertivo.
Exemplo de teste de integração usando o Suite
Neste PR é possível ver o uso do Suite em uma versão do exemplo anterior.
Teste end to end
Testes de ponta a ponta dão a você a maior confiança quando você precisa decidir se seu software está funcionando ou não. Mas devido ao alto custo de manutenção, você deve reduzir ao mínimo o número de testes completos. Pense nas interações de alto valor que os usuários terão com seu aplicativo. Tente criar jornadas do usuário que definam o valor central do seu produto e traduza as etapas mais importantes dessas jornadas do usuário em testes automatizados de ponta a ponta.
Exemplos de teste end to end
internal/http/echo/handler_e2e_test.go
Este teste implementa o fluxo de cadastro e leitura de um usuário.
Executando os testes de integração
Execute
make e2e
Testes na correção de bugs
Testes, especialmente os unitários, são ótimas ferramentas para usarmos no momento da correção de um bug. Idealmente, quando um erro é reportado um bom fluxo para se seguir é o seguinte:
- Escreva um cenário de testes que produza o erro;
- Resolva o problema no código fonte;
- Execute os testes para garantir que nenhum efeito colateral foi adicionado;
- Refatore o código fonte caso necessário;
- Execute os testes novamente e faça o deploy da nova versão.
Evite a duplicação de testes
Agora que você sabe que deve escrever diferentes tipos de testes, há mais uma armadilha a ser evitada: duplicar testes em todas as diferentes camadas da pirâmide. Embora seu pressentimento possa dizer que não existem “muitos testes”, isso não é uma verdade. Cada teste em seu conjunto de testes é bagagem adicional e não vem de graça. Escrever e manter testes leva tempo. Ler e entender o teste de outras pessoas leva tempo. E, claro, executar testes leva tempo.
Assim como no código de produção, você deve buscar a simplicidade e evitar a duplicação. No contexto da implementação de sua pirâmide de teste, você deve manter duas regras em mente:
- Se um teste de nível superior detectar um erro e não houver falha no teste de nível inferior, você precisará escrever um teste de nível inferior;
- Empurre seus testes o mais baixo possível na pirâmide de testes.
A primeira regra é importante porque os testes de nível inferior permitem restringir melhor os erros e replicá-los de maneira isolada. Eles serão executados mais rapidamente e ficarão menos inchados quando você estiver depurando o problema em questão.
A segunda regra é importante para manter seu conjunto de testes rápido. Se você testou todas as condições com confiança em um teste de nível inferior, não há necessidade de manter um teste de nível superior em seu conjunto de testes. Ter testes redundantes se tornará irritante em seu trabalho diário pois o conjunto de testes será mais lento e você precisará alterar mais lugares quando alterar o comportamento do seu código.
Escrevendo código de teste limpo
Assim como na escrita de código em geral, criar um código de teste bom e limpo exige muito cuidado. Aqui estão mais algumas dicas para criar um código de teste sustentável:
- O código de teste é tão importante quanto o código de produção. Dê-lhe o mesmo nível de cuidado e atenção. “Este é apenas um código de teste”, não é uma desculpa válida para justificar um código desleixado;
- Teste uma condição por teste. Isso ajuda você a manter seus testes curtos e fáceis de raciocinar. Em Go podemos usar a construção
t.Run
, como neste exemplo; - Usar uma estrutura bem definida facilita a construção de testes limpos. Veja o tópico "Estrutura dos testes" no começo deste texto;
- A legibilidade importa. Não tente ser excessivamente DRY. A duplicação é aceitável, se melhorar a legibilidade. Tente encontrar um equilíbrio entre o código DRY e DAMP.