O que é Unidade em um teste unitário?
Publicado em
Às vezes sinto falta da adrenalina de descompilar um jar (porque o fonte foi perdido anos atrás), adicionar um if e colocá-lo de volta em produção. Mas acredite, aqueles tempos eram uma droga.
Nos velhos tempos, as pessoas entregavam em produção sem qualquer tipo de teste automatizado. Nós apenas contruíamos as funcionalidades necessárias, testávamos localmente e copiávamos os arquivos para um servidor, geralmente depois da meia-noite, acompanhados de pizzas e de medo. À medida que nossos sistemas ficavam cada vez maiores, era mais difícil manter todas as coisas funcionando conforme planejado. Sem qualquer tipo de regressão garantindo que o que estava funcionando antes de uma atualização ainda funcionava depois dela, havia uma sensação real de risco em qualquer pequena mudança.
Ainda podemos ver esses sistemas funcionando quase que por mágica nos legados de grandes empresas, sendo trabalhados apenas por quem já mexeu neles, mas a abordagem nas boas grandes empresas mudou. Mesmo nessas monstruosidades, as pessoas costumam construir algum tipo de teste automatizado.
Eu credito muitas dessas melhorias na qualidade a algumas das comunidades de software que cresceram nos últimos dez anos. A comunidade ruby on rails merece elogios especiais: no início, os projetos geralmente eram classificados como ruins se houvesse menos de 100% de cobertura de teste em um software rails. Hoje as coisas são mais flexíveis (como precisam ser), mas isso não significa menos cuidado. A ideia de que deve haver testes automatizados reais e úteis em seu sistema é unânime em qualquer discussão madura sobre desenvolvimento de software.
No entanto, os métodos diferem na definição e implementação, principalmente no conceito de teste unitário.
Tudo muito bom, tudo muito legal, devemos testar nossas unidades individualmente. Mas surge a pergunta:
O que é uma Unidade?
Os puristas defendem o teste de todos os métodos, porque nesta mentalidade o método é visto como uma unidade. Em projetos Java, isso significa algo assim:
class CoolEndpoint {
String getMeAString();
}
class CoolEndpointTest{
void getMeAStringTest(); // or should_ReturnMeAString_when_ICallDamnIt
}
/////
class CoolService {
String getMeBusinessString();
}
class CoolServiceTest{
void getMeBusinessStringTest();
}
/////
class CoolRepository {
String findMeAString();
}
class CoolRepositoryTest{
void findMeAStringTest();
}
Se você tiver mais camadas (como um ResourceAssembler ou um Adapter), essas camadas também devem ter testes específicos. Ainda mais específico: qualquer método (a “unidade”) de qualquer camada deve ter no mínimo um teste, e quanto mais, melhor como regra geral.
No entanto, há perigo aqui. Em linguagens interpretadas, como Ruby, onde você não tem seu aliado mais confiável (o compilador), faz algum sentido: é melhor pegar qualquer tipo de erro ao executar seus testes do que ter que executar o programa e verificar manualmente. Mas vejo que, nesses cenários, estamos usando os testes automatizados para duas coisas diferentes:
- Garantir que o código está estruturalmente correto. O foco aqui é verificar se tudo está funcionando tecnicamente. Útil para atualizações de linguagem ou grandes refatorações.
- Garantir que suas regras de negócios estejam corretas, o que significa que suas funcionalidades ainda estão funcionando conforme planejado após alguma mudança de código (teste de regressão)
Sei que essas duas coisas são importantes e merecem atenção, mas acho que o que realmente consiste em uma “unidade” é apenas o segundo tipo. O primeiro tipo é mais técnico do que orientado para negócios e soa mais como um tipo de Teste Estrutural.
É possível cimentar esta posição comparando o que cada teste cobre indo de uma linguagem interpretada para uma compilada, porque a maioria dos problemas que o primeiro tipo verifica, o compilador irá notar sem a necessidade de testes especializados.
Podemos ir ainda mais fundo: com a ajuda de um compilador, o teste estrutural se torna mais um incômodo do que realmente uma ajuda. Em um cenário de refatoração, eles quebrarão, mesmo que as regras de negócios estejam realmente funcionando e o código esteja tecnicamente correto, apenas dobrando o trabalho sem trazer qualquer tipo de segurança real.
É por isso que rotulo uma “unidade” como uma regra de negócios específica testo de acordo.
Como isso funciona
Depende muito do tipo de arquitetura interna em que sua aplicação é construída, mas geralmente ela contém algum tipo de camada de “serviço”, onde a lógica de negócios é armazenada. Esse é um bom mapa do que devemos testar, mas gosto de mover os testes importantes para um nível mais alto de abstração. Os pontos de entrada ou controladores geralmente são uma boa interface para testar. Estes são os pontos onde sua aplicação será estressada e é “cara” dela para o mundo.
Para evitar a dependência de teste, simule todos os componentes externos (como banco de dados, outros aplicativos, caches, filas, etc.) e faça seu teste atingir a interface mais externa. Mesmo em aplicativos monolíticos, seu sistema exporá algum tipo de serviço da web. Use-o. Se não, bem, use sua camada de “serviço”. Não é o ideal, mas é bom o suficiente.
A ideia é simples: dada uma entrada, a interface mais externa que você escolheu deve retornar alguma saída. Caixa totalmente preta. Se sua interface possui mais de uma regra de negócio, tente criar um teste específico para cada regra, mesmo que seja a mesma interface. Algumas pessoas o chamam de “Teste de componente”, mas parece que o nome em si não é amplamente conhecido ou aceito.
Sim, esses testes são mais caros do que “teste de unidade de método”, mas não chegam nem perto do custo de um teste de integração completo e oferecem todos os benefícios e quase nenhum dos problemas. Se precisar refatorar toda a lógica interna, mover toda a infraestrutura, mudar todas as dependências do projeto, se você mantiver as coisas funcionando conforme o esperado, nenhum teste será quebrado, que é a ideia da coisa toda. Você atingiu autonomia para refatorações quando tem a certeza que suas regras de negócio ainda estão sendo respeitadas.
E, acima de tudo, se uma regra de negócio for alterada, basta corrigir os testes, que agora devem falhar, funcionando como pretendido.
Em serviços da web java, você pode se concentrar em ferramentas como RestAssured, que testa e força suas interfaces a manter sua funcionalidade e seu contrato. Verifique o exemplo abaixo, onde você valida o json retornado de um serviço da web.
{
"lotto":{
"lottoId":5,
"winning-numbers":[2,45,34,23,7,5,3],
"winners":[
{
"winnerId":23,
"numbers":[2,45,34,23,3,5]
},
{
"winnerId":54,
"numbers":[52,3,12,11,18,22]
}
]
}
}
@Test public void
lotto_resource_returns_200_with_expected_id_and_winners() {
when().
get("/lotto/{id}", 5).
then().
statusCode(200).
body("lotto.lottoId", equalTo(5),
"lotto.winners.winnerId", hasItems(23, 54));
}
Em aplicativos que também servem html, algum tipo de teste funcional (como selenium) será necessário.
“Mas testes nos ajudam a construir melhores interfaces de métodos!”
Sim isso é verdade. O fato é que, se você usar seus testes para ajudá-lo a desenhar suas interfaces de método, você ainda pode, mas é importante perguntar: Esses testes serão úteis para o projeto ou só para ajudar na sua primeira versão de um método?
Além disso, depois de algum tempo, as regras de melhores interfaces são interiorizadas, e você começa a fazer isso quase que por instinto. E, como eu disse acima, esses testes de caixa preta de alto nível dão a liberdade de refatorar, caso alguma interface interna não seja a ideal.
No final, esses são os tipos de testes que oferecem a flexibilidade para refatorar, mas com a rede de segurança das regras de negócios sendo respeitada.
Referências
Component-based usability testing
Unit Testing vs Component Testing
Comentários
Sinto que os comentários em blogs têm diminuído com o passar do tempo. Se você tiver alguma dúvida ou quiser falar sobre o post, entre em contato comigo pelos links abaixo.