Ao escrever testes de unidade, não use simulados

Publicados: 2018-05-02

Nota: Este é o nosso último post de engenharia técnica escrito pelo engenheiro principal, Seth Ammons. Agradecimentos especiais a Sam Nguyen, Kane Kim, Elmer Thomas e Kevin Gillette por revisarem este post . E para mais posts como este, confira nosso blog técnico.

Eu realmente gosto de escrever testes para o meu código, especialmente testes de unidade. A sensação de confiança que me dá é grande. Pegar algo em que não trabalho há muito tempo e ser capaz de executar os testes de unidade e integração me dá o conhecimento de que posso refatorar impiedosamente se necessário e, desde que meus testes tenham uma cobertura boa e significativa e continuem passando , ainda terei software funcional depois.

Os testes de unidade orientam o design do código e nos permitem verificar rapidamente se os modos de falha e os fluxos lógicos funcionam conforme o esperado. Com isso, quero escrever sobre algo talvez um pouco mais controverso: ao escrever testes unitários, não use mocks.

Vamos colocar algumas definições na mesa

Qual é a diferença entre testes unitários e de integração? O que quero dizer com mocks e o que você deve usar em vez disso? Este post é focado em trabalhar em Go, então minha inclinação sobre essas palavras está no contexto de Go.

Quando digo testes de unidade , estou me referindo àqueles testes que garantem o tratamento adequado de erros e orientam o design do sistema testando pequenas unidades de código. Por unidade, poderíamos estar nos referindo a um pacote inteiro, uma interface ou um método individual.

O teste de integração é onde você realmente interage com sistemas e/ou bibliotecas dependentes. Quando digo “mocks”, estou me referindo especificamente ao termo “Mock Object”, que é onde “substituímos o código de domínio por implementações fictícias que emulam a funcionalidade real e impõem asserções sobre o comportamento do nosso código [1]” (ênfase minha).

Dito um pouco mais curto: mocks afirmam comportamento, como:

MyMock.Method("foo").Called(1).WithArgs("bar").Returns("raz")

Eu defendo “Fakes em vez de Mocks”.

Uma falsificação é uma espécie de dublê de teste que pode conter comportamento de negócios [2]. Fakes são meramente structs que se encaixam em uma interface e são uma forma de injeção de dependência onde controlamos o comportamento. O principal benefício das falsificações é que elas diminuem o acoplamento no código, onde os mocks aumentam o acoplamento e o acoplamento torna a refatoração mais difícil [3].

Neste post, pretendo demonstrar que as falsificações fornecem flexibilidade e permitem testes e refatorações fáceis. Eles reduzem as dependências em comparação com os mocks e são fáceis de manter.

Vamos mergulhar em um exemplo que é um pouco mais avançado do que “testar uma função de soma”, como você pode ver em um post típico dessa natureza. No entanto, preciso fornecer algum contexto para que você possa entender mais facilmente o código que segue neste post.

No SendGrid, um de nossos sistemas tradicionalmente tinha arquivos no sistema de arquivos local, mas devido à necessidade de maior disponibilidade e melhor taxa de transferência, estamos movendo esses arquivos para o S3.

Temos um aplicativo que precisa ser capaz de ler esses arquivos e optamos por um aplicativo que pode ser executado em dois modos “local” ou “remoto”, dependendo da configuração. Uma advertência que é omitida em muitas das amostras de código é que, no caso de uma falha remota, voltamos a ler o arquivo localmente.

Com isso fora do caminho, este aplicativo tem um pacote getter. Precisamos garantir que o pacote getter possa obter arquivos do sistema de arquivos remoto ou do sistema de arquivos local.

Abordagem ingênua: basta chamar a biblioteca e as chamadas no nível do sistema

A abordagem ingênua é que nosso pacote de implementação chamará getter.New(...) e passará as informações necessárias para configurar a obtenção de arquivos remotos ou locais e retornará um Getter . O valor retornado poderá então chamar MyGetter.GetFile(...) com os parâmetros necessários para localizar o arquivo remoto ou local.

Isso nos dá nossa estrutura básica. Quando criamos o novo Getter , inicializamos os parâmetros necessários para qualquer possível busca remota de arquivos (uma chave de acesso e um segredo) e também passamos alguns valores que se originam na configuração do nosso aplicativo, como useRemoteFS que informará o código para tentar o sistema de arquivos remoto.

Precisamos fornecer algumas funcionalidades básicas. Confira o código ingênuo aqui [4]; abaixo é uma versão reduzida. Observe que este é um exemplo inacabado e vamos refatorar as coisas.

A ideia básica aqui é que, se estivermos configurados para ler do sistema de arquivos remoto e obtivermos detalhes do sistema de arquivos remoto (host, bucket e chave), devemos tentar ler do sistema de arquivos remoto. Depois que tivermos confiança na leitura remota do sistema, transferiremos todas as leituras de arquivos para o sistema de arquivos remoto e removeremos as referências à leitura do sistema de arquivos local.

Este código não é muito amigável para testes de unidade; observe que para verificar como funciona, precisamos atingir não apenas o sistema de arquivos local, mas também o sistema de arquivos remoto. Agora, poderíamos apenas fazer um teste de integração e configurar alguma mágica do Docker para ter uma instância s3, permitindo-nos verificar o caminho feliz no código.

Ter apenas testes de integração é menos do que ideal, pois os testes de unidade nos ajudam a projetar softwares mais robustos, testando facilmente códigos alternativos e caminhos de falha. Devemos salvar os testes de integração para tipos maiores de testes do tipo “isso realmente funciona”. Por enquanto, vamos nos concentrar nos testes de unidade.

Como podemos tornar esse código mais testável por unidade? Existem duas escolas de pensamento. Uma é usar um gerador de mocks (como https://github.com/vektra/mockery ou https://github.com/golang/mock) que cria código clichê para uso ao testar mocks.

Você pode seguir esse caminho e gerar as chamadas do sistema de arquivos e as chamadas do cliente Minio. Ou talvez você queira evitar uma dependência, então você gera seus mocks manualmente. Acontece que zombar do cliente Minio não é simples porque você tem um cliente tipado concretamente que retorna um objeto tipado concretamente.

Eu digo que há uma maneira melhor do que zombar. Se reestruturarmos nosso código para ser mais testável, não precisaremos de importações adicionais para mocks e tralhas relacionadas e não haverá necessidade de conhecer DSLs de teste adicionais para testar as interfaces com confiança. Podemos configurar nosso código para não ser excessivamente acoplado e o código de teste será apenas um código Go normal usando as interfaces do Go. Vamos fazer isso!

Abordagem de interface: maior abstração, teste mais fácil

O que é que precisamos testar? É aqui que alguns Esquilos novos erram. Já vi pessoas entenderem o valor de alavancar interfaces, mas sentem que precisam de interfaces que correspondam à implementação concreta do pacote que estão usando.

Eles podem ver que temos um cliente Minio, então eles podem começar fazendo interfaces que correspondam a TODOS os métodos e usos do cliente Minio (ou qualquer outro cliente s3). Eles esquecem o Go Proverb [5][6] de “Quanto maior a interface, mais fraca a abstração”.

Não precisamos testar contra o cliente Minio. Precisamos testar se podemos obter arquivos remotamente ou localmente (e verificar alguns caminhos de falha, como falhas remotas). Vamos refatorar essa abordagem inicial e colocar o cliente Minio em um getter remoto. Enquanto estamos fazendo isso, vamos fazer o mesmo com nosso código para leitura de arquivos locais e criar um getter local. Aqui estão as interfaces básicas, e teremos o tipo para implementar cada uma:

Com essas abstrações em vigor, podemos refatorar nossa implementação inicial. Vamos colocar o localFetcher e remoteFetcher na estrutura Getter e refatorar GetFile para usá-los. Confira a versão completa do código refatorado aqui [7]. Abaixo está um snippet ligeiramente simplificado usando a nova versão da interface:

Esse novo código refatorado é muito mais testável por unidade porque tomamos interfaces como parâmetros na estrutura Getter e podemos alterar os tipos concretos para falsificações. Em vez de simular chamadas de SO ou precisar de uma simulação completa do cliente Minio ou de interfaces grandes, precisamos apenas de duas falsificações simples: fakeLocalFetcher e fakeRemoteFetcher .

Essas falsificações têm algumas propriedades que nos permitem especificar o que elas retornam. Poderemos retornar os dados do arquivo ou qualquer erro que desejarmos e podemos verificar se o método de chamada GetFile trata os dados e os erros conforme pretendido.

Com isso em mente, o coração dos testes se torna:

Com essa estrutura básica, podemos resumir tudo em testes conduzidos por tabelas [8]. Cada caso na tabela de testes estará testando para acesso a arquivos local ou remoto. Seremos capazes de injetar um erro no acesso remoto ou local ao arquivo. Podemos verificar os erros propagados, se o conteúdo do arquivo foi passado e se as entradas de log esperadas estão presentes.

Eu fui em frente e incluí todos os casos de teste e permutações em potencial no teste orientado a uma tabela disponível aqui [9] (você pode notar que algumas assinaturas de métodos são um pouco diferentes—isso nos permite fazer coisas como injetar um registrador e afirmar em declarações de log ).

Bonito, hein? Temos controle total de como queremos que GetFile se comporte e podemos afirmar em relação aos resultados. Projetamos nosso código para ser amigável ao teste de unidade e agora podemos verificar os caminhos de sucesso e erro implementados no método GetFile .

O código é fracamente acoplado e a refatoração no futuro deve ser fácil. Fizemos isso escrevendo um código simples em Go que qualquer desenvolvedor familiarizado com Go deve ser capaz de entender e estender quando necessário.

Mocks: e os detalhes de implementação minuciosos e corajosos?

O que os mocks nos comprariam que não conseguimos na solução proposta? Uma ótima pergunta que mostra um benefício para um mock tradicional pode ser: “como você sabe que chamou o cliente s3 com os parâmetros corretos? Com mocks, posso garantir que passei o valor da chave para o parâmetro key, e não para o parâmetro bucket.”

Esta é uma preocupação válida e deve ser coberta por um teste em algum lugar . A abordagem de teste que defendo aqui não verifica se você chamou o cliente Minio com o bucket e os parâmetros-chave na ordem correta.

Uma ótima citação que li recentemente disse: “Mocking introduz suposições, que introduz risco [10]”. Você está supondo que a biblioteca cliente está implementada corretamente, está supondo que todos os limites são sólidos, está supondo que sabe como a biblioteca realmente se comporta.

Zombar da biblioteca apenas zomba de suposições e torna seus testes mais frágeis e sujeitos a alterações quando você atualiza o código (que é o que Martin Fowler concluiu em Mocks Aren't Stubs [3]). Quando a borracha encontrar o caminho, teremos que verificar se estamos realmente usando o cliente Minio corretamente e isso significa testes de integração (estes podem estar em uma configuração do Docker ou em um ambiente de teste). Como teremos testes de unidade e de integração, não há necessidade de um teste de unidade para cobrir a implementação exata, pois o teste de integração cobrirá isso.

Em nosso exemplo, os testes de unidade orientam nosso design de código e nos permitem testar rapidamente se os erros e os fluxos lógicos funcionam conforme projetado, fazendo exatamente o que eles precisam fazer.
Para alguns, eles acham que isso não é uma cobertura de teste de unidade suficiente. Eles estão preocupados com os pontos acima. Alguns vão insistir em interfaces de estilo boneca russa, onde uma interface retorna outra interface que retorna outra interface, talvez como o seguinte:

E então eles podem extrair cada parte do cliente Minio em cada wrapper e usar um gerador de simulação (adicionando dependências a compilações e testes, aumentando suposições e tornando as coisas mais frágeis). No final, o mockista poderá dizer algo como:

myClientMock.ExpectsCall("GetObject").Returns(mockObject).NumberOfCalls(1).WithArgs(key, bucket) – e isso é se você puder recuperar o encantamento correto para essa DSL específica.

Isso seria muita abstração extra ligada diretamente à escolha de implementação de usar o cliente Minio. Isso causará testes frágeis para quando descobrirmos que precisamos mudar nossas suposições sobre o cliente ou precisamos de um cliente totalmente diferente.

Isso aumenta o tempo de desenvolvimento de código de ponta a ponta agora e no futuro, aumenta a complexidade do código e reduz a legibilidade, aumenta potencialmente as dependências de geradores de simulação e nos dá o valor adicional duvidoso de saber se misturamos o bucket e os parâmetros-chave dos quais teríamos descoberto em testes de integração de qualquer maneira.

À medida que mais e mais objetos são introduzidos, o acoplamento fica cada vez mais apertado. Podemos ter feito uma simulação de logger e depois começamos a ter uma simulação de métricas. Antes que você perceba, você está adicionando uma entrada de log ou uma nova métrica e acabou de quebrar muitos testes que não esperavam que uma métrica adicional chegasse.

A última vez que fui mordido por isso em Go, a estrutura de zombaria nem me disse qual teste ou arquivo estava falhando, pois entrou em pânico e teve uma morte horrível porque se deparou com uma nova métrica (isso exigia uma pesquisa binária nos testes comentando-os para poder encontrar onde precisávamos alterar o comportamento simulado). Os mocks podem agregar valor? Certo. Vale a pena o custo? Na maioria dos casos, não estou convencido.

Interfaces: simplicidade e testes unitários para vencer

Mostramos que podemos orientar o design e garantir que o código adequado e os caminhos de erro sejam seguidos com o uso simples de interfaces em Go. Ao escrever falsificações simples que aderem às interfaces, podemos ver que não precisamos de mocks, estruturas de mocking ou geradores de mock para criar código projetado para teste. Também observamos que o teste de unidade não é tudo, e você deve escrever testes de integração para garantir que os sistemas estejam devidamente integrados entre si.

Espero conseguir um post sobre algumas maneiras legais de executar testes de integração no futuro; Fique ligado!

Referências

1: Endo-Testing: Unit Testing with Mock Objects (2000): Veja a introdução para a definição de mocked object
2: The Little Mocker: Veja a parte sobre fakes, especificamente, “um Fake tem comportamento empresarial. Você pode levar uma falsificação a se comportar de maneiras diferentes, fornecendo dados diferentes.”
3: Mocks não são Stubs: Veja a seção, “Então eu deveria ser um classicista ou um mockista?” Martin Fowler afirma: “Não vejo nenhum benefício convincente para o TDD mockista e estou preocupado com as consequências do acoplamento de testes à implementação”.
4: Abordagem Naive: uma versão simplificada do código. Consulte [7].
5: https://go-proverbs.github.io/: A lista de Go Proverbs com links para palestras.
6: https://www.youtube.com/watch?v=PAAkCSZUG1c&t=5m17s: Link direto para falar de Rob Pike sobre tamanho e abstração da interface.
7: Versão completa do código de demonstração: você pode clonar o repositório e executar `go test`.
8: Testes orientados por tabela: Uma estratégia de teste para organizar o código de teste para reduzir a duplicação.
9: Testes para a versão completa do código de demonstração. Você pode executá-los com `go test`.
10: Perguntas a se fazer ao escrever testes por Michal Charemza: A zombaria introduz suposições, e suposições introduzem riscos.