Pensando em testes - Parte 2 - Domando o caos

Algumas ideias para melhorar a manutenibilidade do seu conjunto de testes a partir de uma visão arquitetural.

Pensando em testes - Parte 2 - Domando o caos
Foto de Sander: https://www.pexels.com/pt-br/foto/foto-de-um-espaco-de-trabalho-abandonado-3359003/

Comecei o post anterior dizendo que pior que não ter testes automatizados é ter o caos disfarçado de testes automatizados. Agora chegou o momento vou falar sobre código, mais especificamente, propor ideias para que você possa ter uma melhor arquitetura de testes automatizados.

Tenho uma visão muito particular sobre arquitetura: é um sinônimo para estratégia, isto é: qual plano iremos seguir para atingir nossos objetivos.

💡
Me lembrem de no futuro escrever sobre esta nossa visão sobre arquitetura, ok?

Nosso objetivo aqui é a manutenibilidade dos testes no médio e longo prazo. Nossos meios serão uma sugestão sobre como o código deve ser organizado e também como guiar nossa equipe para chegar a este fim.

💡
ATENÇÃO: a estratégia aqui exposta é uma possibilidade. Talvez não seja adequada ao seu contexto. O objetivo é apenas apresentar uma opção que pode lhe dar ideias para tratar este problema.
Pensando em testes - Parte 1
Uma nova série de posts - Pensando em testes: como evitar que seus testes automatizados se tornem um problema.

O problema

Jackson Pollock trouxe harmonia ao caos usando o caos

É comum que todas as boas práticas aplicadas ao código que é testado não se aplicarem aos testes que o validarão. É como se cada membro da equipe escrevesse seus testes de acordo com suas próprias ideias (todas bem internas).

Normalmente gestores acham lindo (alguns sentem até orgulho, mostram as páginas de status do CI para o cliente) ter uma infinidade de testes executando com sucesso. É uma visão bastante ingênua: se os testes estão passando, é sinal de que temos qualidade, certo? Não se a qualidade dos nossos testes é questionável.

Não me entenda mal: acredito e defendo a individualidade, mas é importante que haja um acordo para que não terminemos em uma torre de Babel, e é aqui que a nossa estratégia entra em ação.

Torre de Babel - Peter Bruegel

Neste cenário em que temos cada membro da equipe atuando de acordo com os próprios princípios é comum terminarmos em situações tais como:

  • MUITO código duplicado, normalmente funcionalidades que poderiam ser usadas como suporte para outros testes.
    • Um exemplo: diferentes implementações de mocks para um MESMO serviço para simular os MESMOS comportamentos.
  • O tempo de execução de toda a suite de testes vai aumentando significativamente com o tempo, e você sente uma dificuldade TREMENDA ao tentar reduzir este tempo.
  • A qualidade dos testes em si é ruim: funções enormes com diversas responsabilidades, loops imensos, várias condicionais...
  • As pessoas começam a gastar tempo demais na escrita dos testes.
    • É importante ficar atento a isto. Se você gasta muito tempo escrevendo os testes pode ser que haja algo errado aí.
  • Quando um teste falha há resistência do time em verificar se o teste está correto.
    • E quando o teste não está implementado corretamente o que temos? Bugs criados pelo... teste (é "lindo" quando ocorre).

Resumindo: uma massa de código difícil de manter. Bora pensar arquiteturalmente na coisa agora?

Defina diretrizes ("pronde eu vô?")

Foto de Carolyn: https://www.pexels.com/pt-br/foto/sinalizacao-multicolorida-3345082/

Primeiro precisamos definir um acordo, caso contrário todos que chegarem ao projeto não saberão se há padrões e o caos começa a se instalar. É o que gosto de chamar de momento "pronde eu vô?"

Usarei como exemplo um módulo de backend que expõe uma série de endpoints disponibilizados por HTTP (uma API "REST"). É interessante começar por uma checklist. Podemos começar com algumas diretrizes para guiar nosso time. Talvez nesta forma:

Para cada endpoint é necessário validar:

  • Que usuários sem permissão ao endpoint não tenham acesso ao mesmo. A API deve retornar o código HTTP 403.
  • Que se não for enviado um atributo de preenchimento obrigatório, deve ser retornado o código HTTP 400.
  • Que se dois usuários acessarem o mesmo endpoint ao mesmo tempo não teremos problemas transacionais.

De cara definimos aqui um conjunto mínimo de testes que irão tratar problemas que não estão diretamente relacionados à funcionalidade a ser validada, mas que podem gerar problemas em nosso sistema. Olha que legal: começamos a dar um "norte" para aqueles que chegam ao time.

Também é interessante pensar em diretrizes globais à plataforma. Voltando ao mesmo exemplo, é possível imaginar mais um conjunto de diretrizes:

  • Você deve usar o framework X para escrever os testes, não Y.
    • Soa estranho, né? Mas é comum frameworks trocarem o framework de testes entre versões, definindo como obsoleta uma anterior.
  • O banco de dados canônico a ser usado nos testes é este (no caso de testes integrados).
    • Outra excelente ideia: evita que cada membro do time crie seus próprios registros, que talvez nem sejam criados corretamente. Uma base de dados canônica ajuda a padronizar a configuração mínima de dados da plataforma para a realização dos tests.
    • Dica: use e abuse de Docker aqui para garantir que esta base de dados seja o mais efêmera possível.
  • O padrão de nomenclatura dos testes é X
    • Fundamental para que ao lermos o relatório de execução dos testes seja fácil para quem está chegando identificar o que pode ter dado errado.

Estes são apenas alguns exemplos. Agora, vamos ao código?

Arrumando a casa

Foto de Element5 Digital: https://www.pexels.com/pt-br/foto/gabinete-de-arquivo-de-aco-cinza-1370294/

Cada ambiente de desenvolvimento nos oferece padrões para seguirmos na organização dos nossos testes. E sabe o que é interessante? Na maior parte das vezes o que observo é um monte de arquivos de teste que no máximo são organizados por pacote (ou diretórios). Dá pra ser mais feliz.

Organizando os testes - suítes de testes

A situação é comum: você está implementando uma mudança e quer saber se as mudanças que aplicou no código não quebraram o sistema. Aí executa todos os testes, o que pode levar um tempo enorme (e aí bate aquela preguiça...).

E se você pudesse organizar seus testes em grupos? Exemplo: um grupo para lidar com acesso básico à funcionalidade, outro relativo àquela funcionalidade em si. A maior parte das pessoas acaba fazendo isto usando pacotes, mas dá pra ser melhor.

O problema de você usar apenas o diretório ainda pode dar trabalho. A maior parte dos frameworks/bibliotecas de testes oferecem o recurso de "test suits", observa só este exemplo usando JUnit.

@Suite
@IncludeTags("slow")
public class TestesLentosSuite {
}

É possível aplicar tags no código fonte dos testes identificando, por exemplo, quais são aqueles que são de execução lenta. Olha que ferramenta LINDA pra otimizar a execução do seu sistema. Mas dá pra ir além de tags, você pode usar nomes de classes, métodos, pacotes, etc. Este texto do Baeldung sobre suítes de testes com JUnit é bom pra começar.

💡
Mesmo que seu framework de testes não tenha suporte a test suites, você pode implementar sua própria suite. Basta eleger um teste que seja o responsável por executar um conjunto de testes.

Go não oferece test suites por padrão, mas o pacote Testify tem suporte. Para saber mais a respeito leia este link.
💡
Test suites podem ser um primeiro passo importante na refatoração de testes. Pense com carinho nisto.

Dont repeat yourself - sério! - crie um pacote "Utils"

Foto de energepic.com: https://www.pexels.com/pt-br/foto/conjunto-de-ferramentas-na-prancha-175039/

O erro mais comum que encontro é código duplicado absolutamente desnecessário em uma base de testes. Seguem alguns exemplos:

  • Criação de usuários/perfis de acesso para a execução de funcionalidades.
  • Mocks que simulam o mesmo comportamento para a mesma classe/serviço.
  • Geração de valores aleatórios, tal como UUIDs

A solução para isto é fácil, e diversos frameworks de testes já oferecem algo assim para nós. Alguns chamam de "mixins", por exemplo. Eu vou chamar de "utilitários". É bem simples: crie um pacote na sua base de testes chamado "utils" que contenha todo aquele código utilitário que é usado em mais de um teste.

Um exemplo legal: código responsável pela autenticação de usuários em uma API REST. Implemente neste pacote uma função que receba as credenciais e, na sequência, retorne um objeto contendo os dados de autorização do usuário (preferencialmente os mesmos que seriam usados pelos usuários finais da aplicação).

Outro exemplo: sabe aquele mock que você vê implementado diversas vezes pela plataforma, tal como uma API externa ou mesmo o mecanismo de autenticação? Inclua no utils!

💡
Não generalize em excesso!

Só inclua neste pacote de utilitários funcionalidades que REALMENTE são usadas por mais de um teste e, obrigatoriamente, em MAIS de um arquivo de teste e MAIS de um pacote.

Separando código utilitário de testes na nossa experiência tem reduzido HORRORES a repetição de código.

Diretrizes para não cair numa generalização excessiva

A ideia do utils é boa, né? Mas pode te criar um inferno também. Segue então algumas diretrizes que podem te ajudar.

  • Só inclua no pacote "utils" o que realmente for compartilhado por mais de uma classe ou módulo de testes.
    • Regra básica de reuso: um componente só é "útil" mesmo quando pode ser aplicado em no mínimo dois casos (penso no mínimo 3).
  • Se o código utilitário é útil apenas para uma funcionalidade da aplicação, crie um "sub útil" apenas para esta sua funcionalidade.

O código utilitário não precisa estar num pacote. Pode ser também escrito dentro da sua classe/módulo de testes relacionados a uma funcionalidade. Lembre-se de limitar o acesso a estas funcionalidades APENAS aos testes relativos à funcionalidade avaliada.

Defina cenários para testes mais rápidos e melhor organizados - crie seu teatro!

Foto de Victor Moragriega: https://www.pexels.com/pt-br/foto/figuras-vibrantes-do-festival-japones-ao-ar-livre-31148849/

Este é um ponto de muita confusão. Já passou por aquela base de testes integrados que ao final de sua execução gera um número ENORME de registros que poderiam ser reaproveitados? Exemplo clássico: perfis de acesso.

Vou chamar de cenários estes registros que são padrão para o teste de uma funcionalidade e que não precisam ser criados o tempo inteiro: de brinde você ainda ganha testes mais rápidos.

Um exemplo: você precisa de um perfil de acesso para validar se um usuário que o possua (ou não) consegue acessar um endpoint. Se estou usando Java uso e abuso dos enums. Posso criar um que represente os perfis que preciso. Veja o exemplo abaixo:

public enum PerfilTeste {

    Admin("Administrador", {"ROLE_ADMIN"}),
    Leitura("Leitura", {"ROLE_USER", "ROLE_LEITOR"}),
    Autor("Autor", {"ROLE_USER", "ROLE_LEITOR", "ROLE_ESCRITA"});

    private String[] permissoes;
    public String getPermissoes() {return permissoes;}

    private String nome;
    public String getNome() {return this.nome;}

    PerfilTeste(String nome, String permissoes) {
       this.nome = nome;
       this.permissoes = permissoes;
    }
}

Observe que legal: eu tenho ali os três perfis que preciso. Agora, como os obtenho? Crio uma função que retorne o perfil pra mim com base no enum:

public class CenarioAcesso {
    public Perfil getPerfil(PerfilTeste perfil) {
         /*
            Busco no banco de dados o registro
            do perfil.

            Se não existir, crio o registro e
            o retorno
         */
    }
}

E com base nisto, você não precisa mais ficar instruindo sua equipe a respeito de quais os perfis de acesso. Estão todos padronizados no enum. Fácil, né? E você pode melhorar este cenário: crie enums para representar atores do seu sistema, temas de conteúdo, etc.

Sua equipe não precisa mais gastar tempo criando registros básicos para os testes: você os tem padronizados. Podemos focar nossas validações menos em detalhes e mais na funcionalidade que precisa estar implementada de forma correta.

Gosto muito de criar "atores" nestes cenários. Usuários que representam pessoas reais que operam na plataforma. Isto além de mostrar à equipe técnica que estamos lidando com pessoas "REAIS" ainda torna mais divertida a escrita. Sério: experimente criar atores com nomes, tais como Léo, Nanna, Kico, João, Talles. (não gosta de alguém? se vingue fazendo esta personagem passar por todas as situações de erros nos testes! kkkkkkkkkkk).

💡
O uso de cenários trás duas vantagens:

Torna claro para toda a equipe quais as personagens reais do sistema, assim como as configurações que sempre devem existir.

No caso de testes integrados pode reduzir HORRORES o tempo de execução.

Resumindo

O objetivo desta segunda parte foi apresentar algumas ideias que podem ajudar a tornar a base de testes mais fácil de ser mantida e entendida.

Se for pra resumir os princípios, estes seriam:

  • Comece definindo com o time o mínimo de organização e padrões que devem ser seguidos nos testes.
    • Evite impor: as pessoas não gostam de quem impor. Foque no diálogo, todo mundo aprende e você ainda evita que seus testes sejam sabotados acidentalmente pelo time.
  • Agregue seus testes em suítes: isto permite focar nos testes por funcionalidade, lentidão, etc.
  • Evite código duplicado implementando código utilitário.
  • Humanize seus testes criando cenários (de preferência com personagens pra tornar a coisa mais divertida).

O próximo post será sobre anti-patterns. Vou mostrar exemplos de testes ruins que podemos melhorar.

Mantido por itexto Consultoria