DEV Community

gpiress
gpiress

Posted on • Updated on

Como seguir a pirâmide de testes

Testes automatizados estão presentes em (quase) todos os projetos atuais.
Seu uso aumenta a qualidade do produto gerado e torna os times envolvidos mais confiantes e produtivos.

Como tentativa de garantir a qualidade de um projeto, não é difícil encontrar requisitos de porcentagem de código coberto por testes e outras métricas semelhantes. Essas métricas podem acabar sendo maléficas ao código, criando a existência de testes que efetivamente não testam nada, mas que mantém a métrica acima do limite desejado.

Mas então o que devemos testar? E como?

Pirâmide de testes

Pirâmide de testes

A pirâmide de testes é como você deve pensar sua estratégia de testes: a base da pirâmide é composta por testes unitários, o meio por testes de integração e o topo por testes End-to-end.

A analogia da pirâmide é útil para trazer uma imagem da quantidade de testes esperada em cada uma dessas camadas. Testes unitários (a base) devem estar presentes em maior número que testes de integração, e testes de integração devem existir em maior número que testes End-to-end.

Vamos falar sobre cada um desses tipos de teste e o como utilizá-los.

Nota: é possível encontrar muitas versões diferentes dessa pirâmide, mas eu acredito que essa é a mais útil para entender os conceitos e contém o necessário para a maioria dos projetos.

Testes unitários

Testes unitários são provavelmente os mais conhecidos e mais difundidos dentre os testes automatizados. Eles testam uma unidade e são normalmente bem rápidos e auto-contidos.

O que é uma unidade?

Eu gosto de definir uma unidade a ser testada como o menor grupo lógico de código possível em sua arquitetura. Isso quer dizer que em Java isso seria uma classe, em ReactJS seria um componente, em alguns projetos NodeJS e Python seria um módulo e assim por diante. O mais importante é ser consistente com a sua definição de unidade para o projeto.

O que testar?

Por via de regra, é necessário escrever testes para toda lógica e regra de negócio contida em sua unidade. Basicamente sua unidade só existe por conta das regras de negócio que ela satisfaz, então elas devem estar bem testadas.

Sendo mais específico, eu costumo testar o contrato de uma unidade, isto é, todas as interfaces públicas que podem ser usadas por outras partes do código.

Segue um exemplo de uma classe CaseConverter que converte uma string para outro tipo de case (snake_case, camelCase):

public class CaseConverter {

    public enum Case {
        CAMEL_CASE,
        SNAKE_CASE
    }

    public String convert(final String input, final Case convertTo) {
        switch (convertTo) {
            case CAMEL_CASE:
                return convertToCamel(input);
            case SNAKE_CASE:
                return convertToSnake(input);
            default:
                return "";
        }
    }

    private String convertToCamel(final String input) {
        StringBuilder camelCaseBuilder = new StringBuilder();

        final String[] words = input.toLowerCase().split(" ");
        for (String word : words) {
            if (camelCaseBuilder.length() == 0) {
                camelCaseBuilder.append(word);
            } else {
                camelCaseBuilder.append(word.substring(0, 1).toUpperCase());
                camelCaseBuilder.append(word.substring(1));
            }
        }

        return camelCaseBuilder.toString();
    }

    private String convertToSnake(final String input) {
        StringBuilder snakeCaseBuilder = new StringBuilder();

        final String[] words = input.toLowerCase().split(" ");
        for (String word : words) {
            if (snakeCaseBuilder.length() == 0) {
                snakeCaseBuilder.append(word);
            } else {
                snakeCaseBuilder.append("_");
                snakeCaseBuilder.append(word);
            }
        }

        return snakeCaseBuilder.toString();
    }
}

O contrato dessa classe é convert(String, Case) -> String ou seja, ao chamar a única função pública convert passando como parâmetros uma String e o tipo de Case desejados, essa função retornará a String convertida.

Devemos porém criar testes que testem toda funcionalidade que oferecemos, converter para camelCase e para snake-case:

import org.junit.Test;

import static org.junit.Assert.*;

public class CaseConverterTest {

    @Test
    public void convert_should_convert_to_camel_case() {
        final String input = "SOmE teXt hERE";
        final String expected = "someTextHere";

        final CaseConverter caseConverter = new CaseConverter();
        final String actual = caseConverter.convert(input, CaseConverter.Case.CAMEL_CASE);

        assertEquals(expected, actual);
    }

    @Test
    public void convert_should_convert_to_snake_case() {
        final String input = "SOmE teXt hERE";
        final String expected = "some_text_here";

        final CaseConverter caseConverter = new CaseConverter();
        final String actual = caseConverter.convert(input, CaseConverter.Case.SNAKE_CASE);

        assertEquals(expected, actual);
    }
}

Desse modo, verificamos que nossa classe cumpre os requisitos necessários, mesmo sem invocar explicitamente cada uma de suas funções. Perceba que as funções ainda são invocadas indiretamente, mas isso não é o mais importante, seu teste agora não assume nada sobre o funcionamento interno. Apenas testa que o contrato é cumprido.

Observação 1: Algumas linguagens não possuem o conceito de funções públicas e privadas, mas ainda assim elas seguem um padrão de contrato de uso. Tente pensar qual o contrato da sua unidade antes de escrever qualquer código!

Observação 2: Java possui a anotação @VisibleForTesting disponível através de diferentes bibliotecas. Eu acredito que o uso dessa anotação é um [code smell(https://en.wikipedia.org/wiki/Code_smell) e deve ser evitado quando possível.

O que não testar: Mocks (Spies)

Frequentemente nossas classes possuem dependências, tanto outras classes definidas no mesmo repositório ou dependências externas. Exemplos comuns são classes de acesso a Banco de Dados e clientes para requisições HTTPS.

Nesses casos, a recomendação é utilizar Injeção de Dependências no construtor da classe e não testar o funcionamento dessas dependências, pois:

  1. Se forem outras classes do seu repositório, elas serão testadas unitariamente em seus próprios testes.
  2. Se forem dependências externas, elas devem ser testadas unitariamente pelos donos da dependência.

Em ambos os casos, as dependências devem ser testadas em testes de integração.

Para não testar e falhar com sua dependência, utiliza-se mocks, que são objetos que fingem implementar os contratos das dependências, e que a pessoa escrevendo testes tem total controle sobre o comportamento.

Lembre-se que o objetivo dos testes unitários é garantir o contrato e testar a lógica interna da unidade, nada além disso. Por isso é necessário seguir a pirâmide de testes!

Uma última dica é que seus testes unitários jamais devem fazer requisições para nada fora da sua aplicação, como por exemplo serviços web externos. Isso se deve porque não há como garantir que esses serviços externos se mantenham online o tempo todo e com a mesma interface (api). Seu teste unitário não pode falhar por conta de um serviço externo.

Testes de Integração

Testes de integração são os mais comuns de serem esquecidos na hora de escrever testes para um projeto. O objetivo dos testes de integração não é testar a lógica das unidades, mas testar como diferentes unidades interagem entre si.

Não é incomum que repositórios tenham várias unidades que juntas são responsáveis por uma finalidade. É possível que todas essas unidades passem testes unitários e mesmo assim não funcionem em conjunto. Os testes de integração garantem que as unidades são utilizáveis em conjunto.

Exemplos de testes de integração:

Em uma página com uma lista de tarefas

  1. Página com componentes de lista de tarefas e adicionar tarefa.
  2. TESTE: Todos os componentes são exibidos corretamente
  3. Adicionar uma tarefa nova
  4. TESTE: A lista de tarefas é atualizada para incluir a tarefa adicionada

Teste "vertical" de uma funcionalidade

Para uma funcionalidade de exibir um mapa com restaurantes bem avaliados na proximidade.

  • Front end: Página ou componente de App que mostre o mapa
  • Back end: Serviço que retorna restaurantes bem avaliados dado uma localização
  • Banco de Dados: Contém as informações de restaurantes: localização e avaliação
  1. Iniciando o ambiente de testes
    1. Inicializar o componente frontend
    2. Inicializar o serviço backend
    3. Inicializar o Banco de Dados com dados de teste
  2. No front end, navegar para uma posição de teste e pedir recomendações
  3. No front end, verificar que os restaurantes esperados foram recomendados

Nesse caso você garante que a funcionalidade funciona em um ambiente ainda controlado, que não é o que o usuário final vai seguir. Ainda que utilize um Banco de Dados de teste, existe uma garantia de qualidade.

Testes de segurança

Por exemplo para garantir que a comunicação entre um componente frontend e um componente backend seja encriptada e que nenhum dado sensível seja transmitido pela rede.

Esses testes são mais lentos que os testes unitários, pois envolvem mais partes e inicializações. E por se tratarem de integrações entre unidades, seu número deve ser bem menor que o de testes unitários.

Testes end-to-end

Testes end-to-end (de fim-a-fim ?) são os testes que simulam a jornada de um usuário no seu aplicativo. Eles devem simular exatamente o que um usuário faria para utilizar seu produto a fim de capturar erros em alguma dessas etapas.

Não é incomum encontrar repositórios com muitos testes desse tipo. Esse é o tipo de teste mais simples de justificar a existência do ponto de vista do produto, pois emula a experiência do usuário. São portanto muito importantes, pois são os testes mais próximos do que o usuário realmente vai encontrar ao utilizar o aplicativo.

Mas testes end-to-end tem alguns problemas:

  • Podem ser difíceis de definir -- nem sempre é simples escrever um script para seguir o fluxo exato no aplicativo e testar exatamente cada passo
  • São muito lentos -- por simular a experiência de usuário, normalmente precisam de todos os componentes inicializados, carregar imagens, executar requisições, etc
  • Baixa granularidade de erros -- mesmo quando capturam erros, muitas vezes é difícil saber a origem exata do problema já que esses testes são definidos em nível muito alto, cada passo pode envolver muitas unidades.
  • Podem ter custo elevado -- testes que comparam vídeos ou imagens de uso rapidamente precisam aumentar o armazenamento de dados para comparação e, se muito utilizados, podem representar um custo não negligível para o projeto.
  • Testes "flaky", que hora passam, hora falham -- quanto mais pra cima da pirâmide o seu teste, mais ele é afetado por questões fora do seu controle, rede interna, conexões externas, performance da máquina rodando o teste, etc. Ter muitos testes flaky pode diminuir a confiança dos times nos testes e diminuir a qualidade do código.

Por conta dos problemas associados, esses testes devem ser usados em quantidades moderadas, para os fluxos críticos do aplicativo em questão.

Exemplo de teste end-to-end

  1. Usuário faz login no aplicativo com user "foca" e senha "123"
  2. Usuário vê uma tela com todas suas faturas futuras: 1, 2 e 3
  3. Usuário clica na fatura 2
  4. Tela com mais informações sobre a fatura 2 é exibida
  5. Usuário clica para alterar data de pagamento da fatura 2
  6. Usuário altera data para 31/12/2021
  7. Usuário vê alerta de data inválida

Pelo exemplo dá pra perceber que testar todos os possíveis cenários seja uma tarefa hercúlea. E é mesmo, por isso é importante garantir que os testes unitários e de integração estejam bem escritos e realmente testando a lógica do projeto.

Conclusão

Esse artigo cobriu a pirâmide de testes, descreveu a importância de cada um dos tipos de teste que a compõe, testes unitários, de integração e end-to-end. Os testes unitários devem ser os mais presentes em qualquer repositório, mas devem testar apenas a lógica de uma unidade e seu contrato. Testes de integração verificam o funcionamento de várias unidades em conjunto e os testes end-to-end testam a jornada crítica de seus usuários.

Uma proporção base pra utilizar como orientação é: a cada 100 testes unitários, 10 testes de integração e 1 teste end-to-end

Gostou? Achou útil? Discorda? Me conta :).

Top comments (0)