Prós e Contras de Aumentar o Tamanho da Unidade Testada
Ao final desta aula, seremos capazes de:
- Identificar os prós e os contras de aumentar o tamanho da unidade testada (tested unit).
- Compreender exatamente quando devemos aumentar o tamanho da unidade sob teste em aplicações modernas com Spring Boot 4.1.2, Kotlin 2.4.21, Gradle 9.8.2 (Kotlin DSL), JUnit Jupiter 6.0.3, AssertJ 3.27.7 e MockK 1.14.
Esta aula assume que:
Os Prós de Aumentar o Tamanho da Unidade Testada
Os principais benefícios de aumentar o tamanho da unidade testada — testando conjuntamente o controller REST (TaskItemCrudController), o serviço de aplicação (TaskItemCrudService) e os validadores/mapeadores reais com MockMvcTester.of(...) standalone, substituindo apenas o repositório (TaskItemRepository) por um dublê de teste do MockK 1.14 — são:
Primeiro, podemos escrever testes significativos para códigos que não possuem muita lógica de negócios.
Por exemplo, se estivermos escrevendo testes para uma API REST que fornece operações CRUD para diferentes entidades e utilizarmos a abordagem tradicional (uma classe de teste isolada por classe de produção), os testes da nossa camada web serão bastante superficiais (shallow), pois não conseguem testar a lógica que se encontra na camada de serviço.
Por outro lado, se aumentarmos o tamanho da unidade testada, podemos escrever testes extremamente rápidos (executados em milissegundos no JUnit Jupiter 6.0.3, sem subir o contexto completo do Spring Boot) que garantem que:
- Quando a informação solicitada é encontrada no banco de dados, nossa API REST retorna um documento JSON que contém a informação correta.
- Quando informações válidas são fornecidas à nossa API REST, nossa API REST salva as informações corretas no banco de dados.
- Quando informações inválidas são fornecidas à nossa API REST, nossa API REST retorna um documento JSON que contém todos os erros de validação.
- Quando informações inválidas são fornecidas à nossa API REST, nossa API REST não realiza nenhuma alteração no banco de dados.
Esses testes são muito mais significativos e valiosos do que os testes tradicionais de classe isolada.
Segundo, podemos refatorar a implementação interna do sistema sob teste (system under test — SUT), desde que não façamos alterações em sua API pública (o contrato HTTP/JSON exposto pelo controller) ou na API dos componentes que são substituídos por dublês de teste (como a interface ou classe TaskItemRepository).
Em outras palavras, se aumentarmos o tamanho da unidade testada, podemos escrever testes que verificam o comportamento do sistema sob teste em vez de sua implementação interna. Isso torna nossa aplicação mais fácil (e mais rápida) de manter, pois podemos realizar mudanças na estrutura interna do código (por exemplo, extrair classes auxiliares, mover validações entre métodos do serviço ou reorganizar conversores em Kotlin) sem precisar fazer nenhuma alteração em nossos testes unitários.
Terceiro, conseguimos cobrir mais código com testes rápidos que se comportam de maneira semelhante a testes de integração. Isso pode ser extremamente útil quando estamos trabalhando com código legado (legacy code) que é difícil de testar.
Com bastante frequência, o problema em sistemas legados é que não conseguimos escrever os testes tradicionais por classe porque é difícil ou impossível substituir as dependências diretas da classe testada por dublês de teste. Se aumentarmos o tamanho da unidade testada, precisaremos substituir apenas as dependências que interagem com APIs externas, sistemas de arquivos ou bancos de dados. Isso facilita muito a nossa vida, pois, tipicamente, as dependências da camada mais baixa da arquitetura são mais fáceis de substituir do que as dependências encontradas nas camadas superiores.
A seguir, examinaremos os contras dessa técnica.
Os Contras de Aumentar o Tamanho da Unidade Testada
As desvantagens de aumentar o tamanho da unidade testada são:
Primeiro, o código que configura nossos dublês de teste (test doubles) pode ser difícil de ler, pois pode não ficar imediatamente claro por que aquela configuração é necessária. O problema é que, quando nossos dublês de teste não são dependências diretas da classe que fornece a API testada (por exemplo, o TaskItemRepository é dependência de TaskItemCrudService, mas o teste invoca TaskItemCrudController), a configuração desses dublês não é óbvia se não estivermos familiarizados com a implementação interna da unidade testada.
Se nos depararmos com esse problema, podemos melhorar nosso código de teste aplicando as seguintes técnicas:
- Configurar os dublês de teste usando funções utilitárias privadas (private utility functions) e dar nomes adequados e expressivos a essas funções. Um bom nome descreve por que o nosso caso de teste requer a configuração fornecida pela função utilitária.
- Adicionar comentários às funções que configuram nossos dublês de teste. Às vezes, é difícil encontrar um bom nome (ou mesmo um nome razoável) para uma função utilitária que configura um dublê de teste. Se isso acontecer, podemos melhorar um pouco a situação escrevendo um comentário que explique o propósito da nossa função utilitária.
Veja um exemplo prático em Kotlin 2.4.21 com MockK 1.14 demonstrando o uso de funções utilitárias privadas bem nomeadas e documentadas:
|
|
Segundo, o código que configura nossos dublês de teste pode ser difícil e trabalhoso de escrever se o sistema sob teste possuir muita lógica de negócios. Se o sistema sob teste tiver regras de negócios complexas com muitas ramificações (branches), escrever testes que percorram cada um dos caminhos possíveis pode significar que nossos dublês de teste terão que retornar muitos objetos diferentes quando o sistema sob teste consumir uma API externa, ler dados de um sistema de arquivos ou buscar informações no banco de dados. Isso significa que teremos que escrever uma grande quantidade de código de stubbing (every { ... } returns ...).
Podemos tentar resolver esse problema aplicando as seguintes técnicas:
- Escrever testes aninhados (
@Nestedno JUnit Jupiter 6.0.3), que nos ajudam a eliminar código de configuração duplicado (via@BeforeEachem cada contextoinner class) e nos permitem estruturar nossa classe de teste de uma forma menos trabalhosa de escrever e manter. - Substituir stubs por fakes (implementações em memória ou com dados pré-definidos). Essa técnica nos permite remover completamente o código repetitivo que configura os stubs a cada teste.
Quando Devemos Aumentar o Tamanho da Unidade Testada?
Devemos aumentar o tamanho da unidade testada se qualquer uma das seguintes condições for verdadeira:
Primeiro, estamos escrevendo testes para funções (ou métodos) de busca (finder functions/methods). Como essas funções tipicamente não possuem lógica de negócios — apenas repassam parâmetros e convertem ou retornam projeções/DTOs —, não faz muito sentido testá-las escrevendo testes de classe isolados. Se aumentarmos o tamanho da unidade testada, poderemos escrever testes significativos e rápidos para nossas funções de busca.
Segundo, estamos escrevendo testes para uma API CRUD que não possui muita lógica de negócios. Se aumentarmos o tamanho da unidade testada, poderemos escrever testes rápidos que especificam o comportamento da nossa API de ponta a ponta entre a camada web e a camada de serviço. Em outras palavras, como nossos testes não dependem dos detalhes de implementação interna da nossa API, podemos refatorar livremente sua implementação desde que não alteremos a API pública nem a API dos componentes substituídos por dublês de teste.
Terceiro, estamos escrevendo testes para um código cujas dependências diretas não utilizam APIs externas, sistemas de arquivos ou bancos de dados. Se estivermos nessa situação, não faz muito sentido escrever testes de classe isolados, a menos que as classes testadas possuam muita lógica de negócios própria. Se as classes testadas não tiverem muita lógica de negócios, devemos aumentar o tamanho da unidade testada, pois isso nos permite especificar o comportamento do sistema sob teste de uma maneira que não limita nossa capacidade de refatorar sua implementação.
Quarto, estamos trabalhando com código legado (legacy code) que é difícil de testar e precisamos de testes rápidos. Quando trabalhamos com código legado, escrever testes de integração completos costuma ser a escolha “mais fácil e barata” no curto prazo, porque frequentemente é difícil substituir as dependências diretas de uma única classe por dublês de teste. Contudo, os testes de integração são significativamente mais lentos do que os testes unitários. Se precisarmos de testes rápidos, devemos aumentar o tamanho da unidade testada. Isso facilita o trabalho porque, tipicamente, as dependências da camada mais baixa (como clientes HTTP, gateways ou repositórios de banco de dados) são muito mais fáceis de substituir por dublês de teste do que as dependências entrelaçadas nas camadas superiores.
Agora sabemos identificar os prós e os contras de aumentar o tamanho da unidade testada e sabemos exatamente quando devemos utilizar essa técnica. Vamos resumir o que aprendemos nesta aula.
Resumo
Esta aula nos ensinou três pontos fundamentais:
- Se aumentarmos o tamanho da unidade testada, poderemos escrever testes rápidos que especificam o comportamento do sistema sob teste e nos permitem refatorar sua implementação interna com segurança.
- A desvantagem de aumentar o tamanho da unidade testada é que o código que configura nossos dublês de teste pode se tornar difícil de ler e de escrever, especialmente quando há muita lógica de negócios envolvida.
- Devemos aumentar o tamanho da unidade testada quando quisermos escrever testes rápidos que cubram uma fatia maior de código e quando o sistema sob teste não possuir lógica de negócios excessivamente ramificada.