As Falhas que se Escondem de Você… Mas Não dos Testes!
Escrever testes não é apenas uma etapa técnica — é um diálogo constante entre você, o código e o negócio!
Ah, sim, mais um artigo sobre testes! Mas antes de torcer o nariz, deixe-me mostrar por que isso é importante. Os testes de unidade não são apenas uma formalidade ou uma tarefa para “marcar como feita” na sua lista de afazeres.
Vamos ver como os testes podem fazer mais do que apenas encontrar problemas — eles podem guiar a evolução da qualidade e da manutenção do seu projeto.
O Papel dos Testes de Unidade no Design
Quando pensamos em testes de unidade, muitos desenvolvedores ainda os enxergam apenas como uma forma de garantir que o código "funciona" — ou seja, que a função A, quando recebe o dado B, retorna o resultado esperado C. E é exatamente aí que mora um dos maiores erros na forma como encaramos os testes. Testes de unidade são muito mais do que uma simples verificação funcional; eles podem (e devem) atuar como uma ferramenta de design do software.
Vamos ser honestos: quantas vezes você já criou um teste de unidade apenas para cobrir uma funcionalidade sem pensar na estrutura por trás dela? Ou pior, criou o teste já sabendo que o código estava acoplado demais, mas empurrou para "resolver isso depois"? Eu já cometi esses erros! E luto para não cair nesse ciclo errado. A verdade é que, ao tratarmos o teste de unidade apenas como um "check" final no desenvolvimento, estamos ignorando o potencial que ele tem de revelar falhas profundas de design no sistema.
Falhas de Design: Mais Comuns do que Imaginamos
Vamos ser honestos: falhas de design são como aquelas goteiras no teto que você tenta ignorar, mas que continuam ali, pingando e se tornando um problema maior com o tempo. Mas o que exatamente são essas falhas? Simplificando, falhas de design acontecem quando o código não segue princípios fundamentais, como a separação de responsabilidades, baixo acoplamento e alta coesão.
Quando o código começa a se tornar difícil de manter, cheio de dependências complicadas e difíceis de testar, isso é um sintoma claro de uma falha de design.
Agora, imagine que você precisa testar uma funcionalidade que, à primeira vista, parece simples. Mas, ao começar a escrever o teste, você percebe que precisa configurar uma enxurrada de dependências, criar vários mocks e ainda fazer uma série de ajustes complexos para contornar problemas. Isso é um grande sinal de alerta! Você pode estar lidando com um problema sério de design de código.
Nesse caso, o código não está apenas complicado; ele está mal estruturado, dificultando tanto os testes quanto a manutenção. É como tentar trocar uma lâmpada e, para isso, ter que desmontar metade da casa. Claramente, algo está muito errado com a forma como a estrutura foi planejada!
E é aqui que entra a grande sacada: os testes de unidade têm o poder de expor falhas de design logo no início do desenvolvimento. Se, ao tentar escrever um teste, você percebe que ele está complicado demais — com configurações gigantes, múltiplos mocks e uma preparação inicial interminável — esse é um sinal de alerta importante! É como se o código estivesse enviando uma mensagem clara:
🛑 “Ei, este código precisa de atenção!”
Infelizmente, é comum ignorar esse alerta. Em vez de repensar a estrutura do código, muitos desenvolvedores acabam criando gambiarras temporárias nos testes para que eles funcionem, e seguem em frente, fingindo que está tudo bem. 😬
O problema, muitas vezes, não está no teste em si, mas na forma como o código foi projetado.
Mas ignorar esses sinais é como não consertar a goteira 🪣. No curto prazo, pode parecer inofensivo, mas, com o tempo, o problema cresce, tornando o código cada vez mais difícil de manter, cheio de complexidades que ninguém quer enfrentar.
Um Exemplo de Falha de Design Exposta por Testes de Unidade
Vamos examinar como os testes de unidade podem nos revelar problemas no design de uma classe. O código que deveria ser simples para testar acaba mostrando que há algo errado. No código abaixo temos um teste de unidade para uma classe que processa pedidos, e você verá rapidamente os problemas que surgem quando tentamos testá-la.
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
public class OrderProcessorTest {
@Test
public void shouldProcessOrderSuccessfully() {
// Criando um OrderProcessor usando spy para mockar os métodos privados
OrderProcessor orderProcessor = spy(new OrderProcessor());
// Criando um pedido de exemplo
Order order = new Order();
order.setTotal(150);
// Mockando métodos internos
doReturn(true).when(orderProcessor).isValidOrder(order);
doReturn(10.0).when(orderProcessor).calculateDiscount(order);
doNothing().when(orderProcessor).updateOrderInDatabase(order);
doNothing().when(orderProcessor).sendEmailConfirmation(order);
// Executando o método processOrder
orderProcessor.processOrder(order);
// Verificando as interações
verify(orderProcessor).isValidOrder(order);
verify(orderProcessor).calculateDiscount(order);
verify(orderProcessor).updateOrderInDatabase(order);
verify(orderProcessor).sendEmailConfirmation(order);
}
@Test
public void shouldThrowExceptionForInvalidOrder() {
// Criando um OrderProcessor usando spy para mockar os métodos privados
OrderProcessor orderProcessor = spy(new OrderProcessor());
// Criando um pedido inválido
Order order = new Order();
order.setTotal(0);
// Mockando a validação para retornar false
doReturn(false).when(orderProcessor).isValidOrder(order);
// Verificando se a exceção é lançada
assertThrows(IllegalArgumentException.class, () -> orderProcessor.processOrder(order));
// Verificando que nenhum outro método foi chamado após a falha de validação
verify(orderProcessor).isValidOrder(order);
verify(orderProcessor, never()).calculateDiscount(order);
verify(orderProcessor, never()).updateOrderInDatabase(order);
verify(orderProcessor, never()).sendEmailConfirmation(order);
}
}
O Que Esse Teste Está Tentando nos Dizer?
⚠️ Testes Complexos e Múltiplos Mocks
Ao tentar testar o método processOrder()
, precisamos “espiar” com spy()
e mockar vários métodos privados: isValidOrder
, calculateDiscount
, updateOrderInDatabase
e sendEmailConfirmation
. 🛑 Isso é um sinal claro de que algo está fora do lugar.
Um teste de unidade deveria ser direto, focado em uma única responsabilidade. Mas, o que temos aqui é uma classe sobrecarregada, assumindo tarefas que não deveriam ser dela.
👉 Esse cenário complica o trabalho do teste, que agora precisa lidar com múltiplas responsabilidades ao mesmo tempo. E, se o teste está difícil de escrever, o design da classe provavelmente está sobrecarregado com tarefas demais.
📏 Responsabilidades Misturadas
A classe OrderProcessor
não está apenas processando pedidos. Ela está:
• ✅ Validando pedidos;
• 💸 Calculando descontos;
• 💾 Atualizando o banco de dados;
• ✉️ Enviando confirmações por e-mail.
Cada uma dessas atividades deveria estar separada em outras classes ou serviços especializados, já que representam responsabilidades diferentes.
⚡ O problema? Ao tentar testar essa classe, somos obrigados a lidar com todas essas responsabilidades de uma vez só. Isso torna os testes difíceis de isolar, frágeis e custosos de manter. Além disso, esse design viola o Princípio da Responsabilidade Única (SRP), que afirma que cada classe deveria ter apenas um motivo para mudar.
Acoplamento Excessivo
Um dos maiores vilões aqui é o acoplamento excessivo. A classe OrderProcessor
está diretamente ligada a:
• Banco de dados.
• Sistema de e-mails.
Esse acoplamento força o teste a simular interações internas complexas (com mocks), adicionando uma camada desnecessária de dificuldade e tornando os testes menos confiáveis.
Um bom design é modular e isolado, onde cada parte tem uma única responsabilidade. Mas, aqui, o teste nos revela o contrário: para testar o processamento de um pedido, somos obrigados a lidar com detalhes que nem deveríamos conhecer.
Testes Frágeis e Difíceis de Manter
Outro problema exposto pelo teste é sua fragilidade. Como estamos mockando vários métodos privados, qualquer pequena alteração na implementação de OrderProcessor
pode quebrar vários testes ao mesmo tempo.
Em um design mais limpo, os testes deveriam ser:
• Isolados;
• Resistentes a mudanças internas;
• Focados em responsabilidades específicas.
Agora que entendemos os problemas expostos pelo teste, vamos ver como a classe foi escrita para entender onde e por que esses problemas surgem.
public class OrderProcessor {
public void processOrder(Order order) {
// Validação do pedido
if (!isValidOrder(order)) {
throw new IllegalArgumentException("Pedido inválido");
}
// Cálculo de descontos
double discount = calculateDiscount(order);
// Atualização no banco de dados
updateOrderInDatabase(order);
// Envio de confirmação por e-mail
sendEmailConfirmation(order);
}
private boolean isValidOrder(Order order) {
// Lógica de validação do pedido
return order != null && order.getTotal() > 0;
}
private double calculateDiscount(Order order) {
// Lógica de cálculo de desconto
return order.getTotal() > 100 ? 10 : 0;
}
private void updateOrderInDatabase(Order order) {
// Atualiza o pedido no banco de dados
// Simulando interação com banco de dados
}
private void sendEmailConfirmation(Order order) {
// Envia confirmação de e-mail
// Simulando envio de e-mail
}
}
Esse é o verdadeiro valor dos testes de unidade: eles nos fornecem um feedback direto e valioso. Quando você sente que precisa “driblar” o código para que o teste funcione, adicionando mais mocks, configurações complexas ou gambiarras, o teste está te enviando uma mensagem clara: “Esse código está acoplado demais e precisa ser refatorado!”
🌟 Os testes não estão apenas verificando o comportamento do código; eles estão atuando como um farol, iluminando problemas ocultos no design que precisam de atenção.
O Teste Está Complicado? O Código Também Está!
A verdade é que, quando o teste fica complicado, geralmente o problema não está no teste, mas no código que você está tentando testar.
Testar uma unidade de código deveria ser algo simples e direto: você fornece os dados necessários, verifica o comportamento esperado, e pronto. Esse é o ideal. Mas, quando o processo começa a exigir:
• Manipulação de várias dependências ,
• Criação de inúmeros mocks, ou
• Tratamento de responsabilidades misturadas,
… é um sinal claro de que algo está errado. Esses sintomas apontam para problemas como alto acoplamento e baixa coesão, que dificultam não apenas os testes, mas também a manutenção e evolução do código.
Em vez de forçar os testes a funcionarem, vale a pena ouvir o que eles estão tentando dizer. Quando o teste está complicado, o código também está complicado. Esse é o momento de parar, refletir e perguntar: “Será que o design desse código está certo? Será que essa classe deveria mesmo fazer tudo isso?”
Os testes não são apenas verificadores de comportamento; eles também são nossos aliados no desenvolvimento, ajudando a identificar onde o design pode ser melhorado. No caso do OrderProcessor
, os testes estão nos alertando de que:
A classe está assumindo responsabilidades demais.
As dependências do código estão se entrelaçando, dificultando o isolamento e a testabilidade.
O código está tão acoplado que cada pequena mudança pode quebrar várias outras partes do sistema.
Quando você se deparar com um teste difícil de escrever, faça uma pausa e observe o que ele está te mostrando. Pergunte-se: Por que está tão complicado testar isso? Muitas vezes, o teste está dando dicas de que o design não está seguindo princípios de baixo acoplamento e separação de responsabilidades.
Requisitos Implícitos e Omissos: Entendendo as Diferenças
Imagine o desenvolvimento de software como a construção de um quebra-cabeça 🧩. Cada peça representa um requisito que precisa estar presente para que a imagem final faça sentido.
• Algumas peças são fáceis de identificar — são os requisitos explícitos, aqueles que todos conhecem e sabem exatamente onde encaixar.
• Outras, no entanto, são mais sutis e podem passar despercebidas. São os requisitos implícitos e omissos, que, apesar de não serem tão óbvios, são essenciais para completar o quadro. Talvez não tenha ficado claro, vamos mergulhar mais nesse assunto? 👇🏼
🧠 O Que São Requisitos Implícitos?
Requisitos implícitos são como aquelas expectativas óbvias que temos no dia a dia, mas que ninguém fala diretamente. Imagine que você entra em um restaurante 🍽️ e pede uma sopa. Você espera, sem precisar dizer, que ela venha quente, certo? Isso não está no cardápio como “sopa quente”, mas é algo tão natural que ninguém precisa explicar.
No mundo do desenvolvimento de software, funciona do mesmo jeito. Por exemplo, ao preencher um formulário online, você espera que o sistema valide automaticamente se o e-mail inserido tem um “@” antes de prosseguir. Isso pode não estar nos requisitos, mas faz parte das “regras invisíveis” para garantir que o sistema funcione de forma esperada.
Requisitos implícitos são importantes, pois, mesmo que não estejam escritos, eles ajudam a alinhar o sistema às expectativas dos usuários e ao contexto do negócio.
O Impacto de Negligenciar Requisitos Implícitos
Imagine que você está comprando online em um e-commerce. Ao finalizar a compra, você insere seu endereço de entrega, mas o sistema não valida se o CEP é válido ou se o campo de endereço está preenchido. Isso não foi explicitamente mencionado como requisito no projeto, mas é algo que qualquer sistema de vendas deveria considerar.
Por exemplo:
• Um CEP inválido: sem validação, o pedido pode ser enviado para um local inexistente.
• Endereço incompleto: sem uma verificação, o cliente pode esquecer de preencher detalhes importantes como número ou complemento.
Esses requisitos podem não estar listados nos documentos, mas são expectativas naturais do funcionamento do sistema, porque, sem eles, o processo de entrega será inviável. Negligenciar esse tipo de requisito pode levar a pedidos não entregues, insatisfação do cliente e prejuízos para o negócio.
Requisitos implícitos são essas “regras não escritas” que garantem que o sistema opere de forma lógica e atenda ao propósito esperado. Ignorá-los pode comprometer toda a experiência.
Como os Testes de Unidade Ajudam com Requisitos Implícitos?
Os testes de unidade são como uma lupa poderosa 🔍, que te faz enxergar detalhes que poderiam passar despercebidos. Eles incentivam você a fazer perguntas importantes, como:
• “E se o usuário digitar um CEP inválido?”
• “O que acontece se o campo de endereço ficar em branco?”
Por exemplo, ao criar testes para validar o fluxo de checkout em um e-commerce, você pode simular cenários inesperados, como um CEP mal formatado ou um endereço incompleto. Mesmo que esses casos não tenham sido descritos explicitamente nos requisitos, eles são fundamentais para o funcionamento correto do sistema.
No Brasil, o CEP segue um padrão fixo de 8 dígitos no formato XXXXX-XXX. Ele é uma referência crucial para a entrega de pedidos, e cada região do país possui uma faixa específica de CEPs. Por exemplo, São Paulo usa CEPs entre 01000-000 e 19999-999, enquanto o Rio de Janeiro varia de 20000-000 a 28999-999. Um CEP válido não pode conter letras, espaços em branco ou formatos diferentes, e negligenciar essas validações pode causar problemas:
• Pedidos não entregues: O sistema pode aceitar um CEP inexistente ou mal formatado, resultando em erros no envio.
• Experiência ruim para o cliente: Campos de endereço mal validados podem frustrar o usuário ao gerar falhas ou confusões no cadastro.
• Prejuízos financeiros: Produtos podem ser enviados para o local errado ou sofrer atrasos desnecessários.
Ao escrever um teste de unidade para simular diferentes cenários você é forçado a pensar sobre requisitos implícitos que talvez nem tivessem passado pela sua cabeça.
Ao implementar testes para esse tipo de funcionalidade, você pode verificar:
1. Validação de formato: Garantir que o CEP segue o padrão correto (XXXXX-XXX).
2. Campos obrigatórios: Certificar-se de que o campo de endereço não pode estar vazio.
3. Simulação de erros comuns: Testar CEPs mal formatados (ex.: “1234”, “ABCDE-123”) ou ausentes.
Esses testes ajudam a capturar comportamentos inesperados que não estavam explicitamente documentados, mas que são cruciais para o funcionamento do sistema. Assim, os testes de unidade garantem que o sistema lide bem com requisitos implícitos, como a validação de CEP e endereço, e evitam falhas que poderiam gerar frustrações para os clientes e prejuízos para o negócio.
Além de aumentar a confiabilidade do sistema, você melhora a experiência do usuário e reduz riscos operacionais ao validar até mesmo os detalhes que parecem “óbvios”.
Os testes funcionam como lembretes para que você considere os aspectos não documentados, mas essenciais para o comportamento correto do sistema.
Exposição de Requisitos Omissos
Requisitos omissos são um pouco diferentes dos implícitos. Eles são como aquelas peças do quebra-cabeça que definitivamente deveriam estar lá, mas que, por algum motivo, ficaram de fora. Geralmente, esses requisitos foram discutidos em uma reunião, documentados ou mencionados em alguma conversa, mas acabaram não sendo implementados no código.
Vamos voltar ao exemplo do e-commerce com a classe OrderProcessor
. Imagine que existe uma regra de negócio clara:
“Todo pedido acima de R$ 5.000 deve ser revisado por um gerente antes de ser processado.”
Agora, você escreve um teste de unidade para verificar essa lógica e percebe que o código não inclui essa verificação. Nesse momento, o teste está te mostrando que um requisito omisso foi identificado. Algo importante foi deixado de fora, e a funcionalidade não está em conformidade com as regras de negócio.
Diferença Entre Requisitos Implícitos e Omissos
A diferença entre os dois é sutil, mas fundamental:
• Requisitos implícitos são aqueles que todos esperam que estejam lá, mas que não foram documentados.
• Requisitos omissos, por outro lado, são aqueles que foram discutidos e definidos, mas que, por descuido, não foram implementados.
Pense assim: um requisito implícito é uma expectativa silenciosa, enquanto o requisito omisso é uma promessa quebrada.
Por Que Isso é Importante?
Os testes de unidade ajudam a identificar ambos os tipos de requisitos:
1. Requisitos implícitos: Ao escrever testes, você é levado a pensar em cenários não documentados, mas necessários, garantindo que o código cubra todas as possibilidades esperadas.
2. Requisitos omissos: Os testes funcionam como um alarme, revelando claramente quando algo que deveria estar no código não está presente.
Escrever testes não é apenas uma etapa técnica — é um diálogo constante entre você, o código e o negócio! 💬
Na próxima vez que você for escrever um teste de unidade, encare-o como uma conversa ativa com o código. Pergunte a si mesmo:
• “E se acontecer algo que eu não previ?”
• “Onde está aquela regra que discutimos na reunião?”
• “Essas validações refletem o que realmente prometemos para o cliente?”
• “Estamos cobrindo todos os cenários para evitar prejuízos e garantir uma boa experiência?”
• “Isso está alinhado com a expectativa do cliente e com os objetivos do negócio?”
Assim, os testes de unidade se tornam mais do que simples ferramentas técnicas — eles são um meio de comunicação entre desenvolvedores e o propósito do sistema, garantindo que o quebra-cabeça esteja completo, sem peças faltando ou escondidas. E, claro, tornam o código mais robusto, confiável e alinhado com as expectativas do negócio.
Perguntas Que Surgem Durante o Teste
Veja abaixo algumas perguntas que podem surgir enquanto você tenta testar requisitos como esse:
💭 “Esse código está claro para refletir os requisitos de negócio?”
Às vezes, o código faz o que precisa, mas de uma maneira tão indireta que os testes acabam revelando essa complexidade oculta. Um código que mistura comportamentos e regras implícitas confunde tanto quem escreve quanto quem lê.
💭 “O teste está cobrindo todos os cenários possíveis?”
Os testes te ajudam a questionar se há situações que não foram previstas, como casos extremos ou entradas inválidas. Eles garantem que o sistema seja robusto mesmo em condições adversas.
💭 “Os comportamentos estão documentados no código ou nos testes?”
Um comportamento esperado que só existe “na cabeça de alguém” é uma armadilha. Os testes expõem essas lacunas e te forçam a registrar explicitamente como o sistema deve funcionar.
💭 “Estou testando a lógica real ou algo redundante?”
Se o teste não está realmente desafiando o sistema, ele pode dar uma falsa sensação de segurança. Essa pergunta te ajuda a identificar se os requisitos estão claros ou se o teste é apenas um reflexo superficial do código.
💭 “O código reflete claramente o objetivo do negócio?”
Os testes podem expor quando o código implementa algo funcional, mas desalinhado com o propósito real do sistema. Isso é um alerta para ajustar a lógica ao que o negócio espera.
Se seus testes começam a parecer complexos demais para capturar os comportamentos esperados, é um sinal de que os requisitos não estão claros no design. Além disso, essas perguntas te ajudam a refinar o código, garantir clareza e fortalecer a conexão entre tecnologia e objetivos do negócio.
O Feedback dos Testes Não É Apenas Sobre Funcionalidade
Os testes de unidade vão muito além de simplesmente confirmar que o código está funcionando — eles garantem que o código está cumprindo exatamente o que o negócio precisa. Quando os testes não conseguem capturar essas expectativas, isso é um sinal de alerta: pode haver uma falha na tradução dos requisitos do negócio para o design do sistema. É como se o sistema estivesse “fazendo algo”, mas não o algo certo, deixando lacunas que podem comprometer os objetivos do projeto.
Quantas vezes um requisito essencial, como uma “revisão do gerente”, é mencionado de maneira casual — numa reunião, em um e-mail ou até numa conversa de corredor — mas nunca é formalizado de forma clara? Os testes de unidade acabam se tornando uma ferramenta crucial para detectar essas ausências. Eles te obrigam a perguntar: “Será que todos os aspectos do que o sistema deve fazer estão realmente refletidos no código?”
Essa ausência nos testes geralmente não é um problema técnico. Ela é o reflexo de algo mais profundo: uma lacuna na comunicação entre o time técnico e o negócio. Talvez o requisito nunca tenha sido claramente documentado. Talvez tenha sido assumido que os desenvolvedores “entenderiam” a necessidade sem maiores explicações. Ou talvez tenha sido negligenciado porque parecia “óbvio” para uma das partes, mas não para a outra. Essa desconexão gera falhas sutis, mas significativas, que só se tornam visíveis quando o sistema já está em uso — ou pior, quando ele falha em atender às expectativas do cliente.
Os testes de unidade, não só validam o código, mas também funcionam como uma ponte entre os requisitos do negócio e a implementação técnica. Se um teste não cobre algo que deveria estar no sistema — como a necessidade de aprovação de um gerente —, ele não apenas aponta para uma falha no código. Ele revela que houve um problema na tradução do requisito para o sistema, um desalinhamento entre o que foi pedido e o que foi entregue.
Esse tipo de feedback é precioso porque expõe pontos cegos no processo de desenvolvimento. A falta de testes para requisitos implícitos, ou omissos destaca falhas no fluxo de comunicação entre as partes envolvidas. E isso vai além do software: impacta a confiança entre os times, a satisfação do cliente e, em última instância, a eficiência do negócio como um todo.
Portanto, os testes de unidade são mais do que uma simples ferramenta técnica. Eles são uma forma de validação bidimensional: validam o que o código faz, mas também validam como os requisitos foram capturados, interpretados e implementados.
Testes de Unidade como Documentação Viva
Os testes têm um papel que vai além da validação técnica: eles podem se tornar uma documentação viva do que o sistema deve fazer. Imagine que um novo desenvolvedor chega na equipe e começa a explorar os testes. Ele deveria ser capaz de entender o comportamento esperado do sistema apenas ao ler os casos de teste. Agora, se os requisitos de negócio não estão claros no código, os testes inevitavelmente refletirão essa falta de clareza.
Vamos falar do caso do OrderProcessor
. Sem um teste que verifique a regra de negócio de “revisar pedidos acima de R$ 5000”, essa lógica essencial fica oculta. E sabe o que acontece quando isso não está nos testes? É como se estivéssemos dizendo: “Ah, essa regra não é tão importante assim.” Mas sabemos que ela é! Aqui está a importância dos testes: eles te dão a chance de capturar essas lacunas, ajustar o design e transformar requisitos implícitos em comportamentos explícitos e facilmente testáveis.
A Busca por Interfaces Claras
Quando falamos de interfaces claras no design de código, estamos falando do contrato entre classes e componentes. Uma interface bem definida diz exatamente o que uma classe ou componente faz, sem revelar os detalhes de como faz isso. Esse nível de clareza é como um manual de instruções: ele garante que diferentes partes do sistema possam interagir de maneira previsível, mantendo a independência entre elas.
O impacto disso nos testes? Gigante! Com interfaces claras, escrever testes fica muito mais simples e direto, porque sabemos exatamente o comportamento esperado e como as partes do sistema devem interagir. Por outro lado, se a interface for confusa ou mal definida, os testes de unidade rapidamente se tornam complicados e frágeis, cheios de incertezas sobre o que exatamente deve ser testado.
Vamos voltar ao nosso exemplo do OrderProcessor
. Esta classe está sobrecarregada: validação, cálculo de descontos, atualização no banco de dados, envio de e-mails… Tudo misturado em um único método. Isso não apenas torna o código mais difícil de entender, mas também faz com que os testes sejam um verdadeiro desafio. Por quê? Porque não há interfaces claras separando as responsabilidades.
Por que isso importa?
Se queremos sistemas mais flexíveis, testáveis e fáceis de manter, precisamos dividir responsabilidades de forma clara.
Separando as Responsabilidades
Imagine que, ao testar o método processOrder()
, você só queira verificar se o pedido foi atualizado corretamente no banco de dados. Mas, com a implementação atual, antes de chegar à atualização do pedido, o teste teria que passar pela validação do pedido, pelo cálculo de descontos e pelo envio de e-mails. Todos esses passos são "barreiras" para um teste focado e direto.
Agora, pense na seguinte pergunta: será que o OrderProcessor
deveria estar diretamente responsável por todas essas etapas? Se cada responsabilidade estivesse isolada por meio de uma interface clara, o cenário seria completamente diferente. Cada função teria um papel específico, e o teste não precisaria saber detalhes de implementação que não dizem respeito à responsabilidade que está sendo testada.
Por exemplo, uma interface de banco de dados poderia encapsular a lógica de interação com o banco, e uma interface de envio de e-mail poderia isolar o comportamento relacionado à comunicação externa. Isso não só deixaria o código mais modular, como também facilitaria a criação de mocks ou stubs para essas interfaces, permitindo que os testes se concentrem apenas no que é realmente relevante.
O Encapsulamento Facilita os Testes!
Antes de mais nada, é importante esclarecer que o encapsulamento não se trata apenas de “esconder detalhes”. Ele vai muito além disso: é sobre proteger a integridade dos dados de um objeto. Pense nos dados como um tesouro guardado dentro de uma caixa forte. O encapsulamento é essa caixa, garantindo que só quem possui a chave correta — ou seja, os métodos apropriados — consiga acessar ou modificar esses dados de forma controlada e segura.
Se quiser se aprofundar nesse conceito, recomendo dar uma olhada no meu artigo:
Agora, como o encapsulamento se conecta à testabilidade? Um código bem encapsulado não só protege os dados, mas também torna os testes de unidade muito mais eficazes.
No exemplo da classe OrderProcessor
, o encapsulamento está claramente falhando. O método processOrder()
está diretamente exposto e acoplado a várias operações internas, como a interação com o banco de dados e o envio de e-mails. Esses detalhes não deveriam estar na responsabilidade da classe. Eles deveriam ser encapsulados em componentes específicos para cada responsabilidade. Assim, a classe OrderProcessor
ficaria livre para focar apenas no processamento de pedidos, permitindo que os testes fossem escritos de forma mais simples e direcionada.
Quando o encapsulamento é bem aplicado, ele facilita a criação de testes porque você pode verificar o comportamento de cada componente de maneira isolada, sem precisar lidar com todas as complexidades do sistema. Por outro lado, no caso do OrderProcessor
, a falta de encapsulamento cria um grande problema: os testes têm que lidar com tudo ao mesmo tempo — validação, interação com o banco de dados, envio de e-mails e assim por diante. Isso resulta em testes pesados, confusos e difíceis de manter.
A beleza do encapsulamento está em simplificar e organizar. Um código bem encapsulado separa responsabilidades de forma clara e evita que uma única classe carregue mais peso do que deveria. Isso não só melhora a qualidade do design, mas também torna o trabalho do desenvolvedor mais leve, já que os testes podem ser focados, ágeis e altamente eficazes.
O Teste Está Expondo o Código Mal Encapsulado?
Ao tentar testar a classe OrderProcessor
, você pode perceber que a criação do teste já é complexa demais. Isso é um sintoma claro de que o código não está bem encapsulado. O teste, nesse caso, te obriga a lidar com detalhes internos que não deveriam ser da sua responsabilidade.
Esse é o tipo de feedback valioso que os testes de unidade nos oferecem: se o teste está complicado, é porque o design está expondo mais do que deveria.
Aqui, o teste parece estar dizendo: “Eu não deveria precisar saber sobre banco de dados ou e-mails para testar o processamento de pedidos.” E é exatamente isso que buscamos com interfaces claras e bom encapsulamento: garantir que cada parte do código tenha uma responsabilidade bem definida, fácil de entender e, principalmente, fácil de testar.
Um design bem encapsulado não apenas simplifica os testes, mas também ajuda a evitar a fragilidade no código, tornando cada componente mais resiliente às mudanças. Quando o teste grita por ajuda, é um sinal de que o design precisa ser ajustado — e isso é algo que nunca devemos ignorar.
Refatorando a Classe: Da Confusão à Clareza
Vamos começar refatorando a classe OrderProcessor
com base nesses princípios de interfaces claras e encapsulamento. Como vimos, a classe original mistura várias responsabilidades, o que complica a testabilidade. Agora, ao dividir essas responsabilidades e usar interfaces, o design ficará mais modular e nossos testes, mais simples.
public class OrderProcessor {
private final OrderValidator orderValidator;
private final DiscountService discountService;
private final OrderRepository orderRepository;
private final EmailService emailService;
public OrderProcessor(OrderValidator orderValidator, DiscountService discountService,
OrderRepository orderRepository, EmailService emailService) {
this.orderValidator = orderValidator;
this.discountService = discountService;
this.orderRepository = orderRepository;
this.emailService = emailService;
}
public void processOrder(Order order) {
// Validação do pedido
if (!orderValidator.isValid(order)) {
throw new IllegalArgumentException("Pedido inválido");
}
// Cálculo de descontos
double discount = discountService.calculateDiscount(order);
// Atualização no banco de dados
orderRepository.updateOrder(order);
// Envio de confirmação por e-mail
emailService.sendEmailConfirmation(order);
}
}
Após a refatoração, a única responsabilidade do OrderProcessor
é orquestrar o processo de um pedido, ou seja, garantir que cada etapa — validação, cálculo de desconto, atualização no banco de dados e envio de e-mails — ocorra na sequência correta. Ele não realiza nenhuma dessas operações diretamente, mas delega cada uma para componentes especializados, seguindo o princípio da responsabilidade única.
Agora, o OrderProcessor
apenas coordena essas ações, deixando que cada parte do processo seja tratada por classes separadas que têm suas responsabilidades bem definidas.
Veja o antes e depois dos teste (leia os comentários):
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
public class OrderProcessorTest {
@Test
public void shouldProcessOrderSuccessfully() {
// Criando um "spy" para a classe OrderProcessor, porque temos que simular os métodos internos dela
OrderProcessor orderProcessor = spy(new OrderProcessor());
Order order = new Order();
order.setTotal(150);
// Aqui, estamos mockando métodos internos (isValidOrder, calculateDiscount, etc.) diretamente
// Isso é um sinal de que o código está muito acoplado, pois precisamos simular comportamentos internos da própria classe.
doReturn(true).when(orderProcessor).isValidOrder(order);
doReturn(10.0).when(orderProcessor).calculateDiscount(order);
doNothing().when(orderProcessor).updateOrderInDatabase(order);
doNothing().when(orderProcessor).sendEmailConfirmation(order);
// Executando o método processOrder, que faz múltiplas operações
orderProcessor.processOrder(order);
// Verificando as interações - note como estamos checando muitas etapas diferentes no mesmo teste
// Isso mostra que o OrderProcessor está fazendo muitas coisas, e o teste precisa verificar tudo.
verify(orderProcessor).isValidOrder(order);
verify(orderProcessor).calculateDiscount(order);
verify(orderProcessor).updateOrderInDatabase(order);
verify(orderProcessor).sendEmailConfirmation(order);
// O fato de precisarmos verificar tantas interações diferentes é outro sinal de acoplamento.
// O OrderProcessor não deveria ter tantas responsabilidades em um único método.
}
@Test
public void shouldThrowExceptionForInvalidOrder() {
// Novamente, criando um "spy" porque estamos simulando os métodos internos da própria classe
OrderProcessor orderProcessor = spy(new OrderProcessor());
Order order = new Order();
order.setTotal(0); // Pedido inválido
// Simulando um pedido inválido mockando o método interno de validação
// Isso indica que a lógica de validação está misturada com outras responsabilidades.
doReturn(false).when(orderProcessor).isValidOrder(order);
// Verificando se uma exceção é lançada corretamente quando o pedido é inválido
assertThrows(IllegalArgumentException.class, () -> orderProcessor.processOrder(order));
// Verificando que nenhum outro método foi chamado após a falha de validação
// Como o OrderProcessor está lidando com validação, cálculo de desconto, banco de dados e e-mail,
// precisamos garantir que ele não continue a execução ao encontrar um erro.
verify(orderProcessor).isValidOrder(order);
verify(orderProcessor, never()).calculateDiscount(order);
verify(orderProcessor, never()).updateOrderInDatabase(order);
verify(orderProcessor, never()).sendEmailConfirmation(order);
// Aqui fica claro que o teste está acoplado a múltiplas responsabilidades.
// O teste tem que garantir que, se a validação falha, as outras etapas não sejam executadas.
// Isso é um sinal de que as responsabilidades de validação, cálculo, persistência e e-mail estão misturadas,
// o que complica os testes e revela que o design está acoplado demais.
}
}
Agora veja depois da refatoração aplicada!
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
public class OrderProcessorTest {
private OrderValidator orderValidator;
private DiscountService discountService;
private OrderRepository orderRepository;
private EmailService emailService;
private OrderProcessor orderProcessor;
@BeforeEach
public void setUp() {
// Criando mocks para as dependências
orderValidator = mock(OrderValidator.class);
discountService = mock(DiscountService.class);
orderRepository = mock(OrderRepository.class);
emailService = mock(EmailService.class);
// Injetando as dependências mockadas no OrderProcessor
orderProcessor = new OrderProcessor(orderValidator, discountService, orderRepository, emailService);
}
@Test
public void shouldProcessOrderSuccessfully() {
Order order = new Order();
order.setTotal(150);
// Definindo o comportamento dos mocks
when(orderValidator.isValid(order)).thenReturn(true);
when(discountService.calculateDiscount(order)).thenReturn(10.0);
// Executando o método processOrder
orderProcessor.processOrder(order);
// Verificando as interações com os mocks
verify(orderValidator).isValid(order);
verify(discountService).calculateDiscount(order);
verify(orderRepository).updateOrder(order);
verify(emailService).sendEmailConfirmation(order);
}
@Test
public void shouldThrowExceptionForInvalidOrder() {
Order order = new Order();
// Simulando que o pedido é inválido
when(orderValidator.isValid(order)).thenReturn(false);
// Verificando se a exceção é lançada quando o pedido é inválido
assertThrows(IllegalArgumentException.class, () -> orderProcessor.processOrder(order));
// Certificando-se de que nenhuma outra interação ocorreu após a falha de validação
verify(orderValidator).isValid(order);
verifyNoMoreInteractions(discountService, orderRepository, emailService);
}
}
Depois da refatoração, o teste ficou muito mais claro e direto. Agora, o OrderProcessor
apenas coordena as operações, delegando as responsabilidades para outras classes especializadas. Isso significa que, no teste, podemos focar em verificar apenas as interações entre o OrderProcessor
e suas dependências, sem precisar nos preocupar com detalhes internos. O teste agora mostra que a classe está mais desacoplada, fácil de escrever e entender, e focada em um único comportamento: garantir que o pedido seja processado corretamente.
Abstrações Vazadas
Vamos conversar brevemente sobre abstração, que é, essencialmente, a prática de esconder detalhes complexos e mostrar apenas o que é necessário. Para ilustrar, imagine que você está dirigindo um carro. Quando gira o volante, você não precisa saber como todo o sistema de direção funciona internamente — basta saber que o carro vai virar. Isso é uma abstração: o carro esconde toda a complexidade mecânica e te dá uma interface simples para interagir com ele, o volante.
No código, abstrações funcionam da mesma maneira. Criamos classes e métodos que expõem apenas as funcionalidades que outras partes do sistema precisam, enquanto escondemos os detalhes da implementação. Por exemplo, se você utiliza um método calculateDiscount()
, você não precisa saber como o desconto está sendo calculado internamente — você só precisa da interface que oferece o cálculo.
Uma boa abstração é como aquele volante do carro: ela te dá uma interface clara, previsível e fácil de usar, sem expor a complexidade que está por trás. Por outro lado, se detalhes internos começam a vazar e influenciam diretamente o uso da interface, temos o que chamamos de abstração vazada. Isso é um sinal de que a interface está mal projetada, porque está forçando quem usa a abstração a lidar com complexidades que deveriam estar escondidas.
Se os testes começam a expor muitos detalhes internos de uma classe ou demandam configurações complexas para testar algo simples, isso pode indicar que a abstração está falhando em esconder a complexidade.
O que é uma Abstração Vazada?
Uma abstração vazada ocorre quando essa camada de simplicidade é quebrada e a complexidade interna começa a aparecer onde não deveria. Voltando à analogia do carro, imagine se, ao girar o volante, você também tivesse que ajustar manualmente o mecanismo da direção hidráulica ou elétrica toda vez. Isso seria uma abstração totalmente mal feita — você estaria lidando com detalhes técnicos que deveriam estar completamente ocultos para o motorista.
No código, uma abstração vazada ocorre quando uma classe ou método revela mais detalhes do que deveria. Isso acontece quando partes internas, que deveriam estar encapsuladas, acabam sendo expostas, exigindo que outras partes do código saibam desses detalhes para funcionar corretamente. E onde isso fica mais evidente? Nos testes de unidade.
Como os Testes Expõem Abstrações Vazadas
Vamos imaginar que criamos uma classe chamada PaymentProcessor. Ela tem a responsabilidade de processar pagamentos, o que parece uma abstração clara. Porém, dentro dessa classe, há vários métodos privados que lidam com cálculos fiscais, verificações de limites de crédito e atualizações do status de pagamento. E, para piorar, quando você tenta testar o PaymentProcessor
, precisa mockar ou ajustar manualmente esses detalhes internos, como os cálculos de impostos ou a validação de crédito.
Quer ver esse problema na prática? 👇🏼
public class PaymentProcessor {
public void processPayment(Payment payment) {
// Verificação de crédito
if (!hasValidCredit(payment)) {
throw new IllegalArgumentException("Crédito inválido");
}
// Cálculo de impostos
double tax = calculateTax(payment);
// Atualização no sistema
updatePaymentStatus(payment);
// Envio de confirmação
sendPaymentConfirmation(payment);
}
private boolean hasValidCredit(Payment payment) {
// Lógica de validação de crédito
return payment.getAmount() < 5000;
}
private double calculateTax(Payment payment) {
// Lógica de cálculo de impostos
return payment.getAmount() * 0.2;
}
private void updatePaymentStatus(Payment payment) {
// Atualização no sistema de pagamento
}
private void sendPaymentConfirmation(Payment payment) {
// Envia confirmação de pagamento
}
}
À primeira vista, parece tudo encapsulado, certo? Mas, quando você vai escrever testes para o PaymentProcessor
, percebe que precisa mockar várias partes internas, como a verificação de crédito e o cálculo de impostos. Você não deveria se preocupar com esses detalhes ao testar o processamento de pagamento como um todo.
Por exemplo, para testar se o pagamento é processado corretamente, o teste pode começar a ficar assim:
@Test
public void shouldProcessPaymentSuccessfully() {
PaymentProcessor paymentProcessor = spy(new PaymentProcessor());
Payment payment = new Payment(1000);
// Temos que mockar métodos internos que não deveríamos precisar nos preocupar
doReturn(true).when(paymentProcessor).hasValidCredit(payment);
doReturn(200.0).when(paymentProcessor).calculateTax(payment);
// Executando o método principal
paymentProcessor.processPayment(payment);
// Verificando as interações
verify(paymentProcessor).hasValidCredit(payment);
verify(paymentProcessor).calculateTax(payment);
verify(paymentProcessor).updatePaymentStatus(payment);
verify(paymentProcessor).sendPaymentConfirmation(payment);
}
Por que Isso é um Problema?
Aqui, os testes estão revelando o problema: a classe PaymentProcessor
expôs detalhes demais. Para testar o processamento de um pagamento, você deveria apenas verificar se o pagamento foi processado corretamente, sem ter que se preocupar com a verificação de crédito, cálculo de impostos, etc.
O que o teste está te dizendo é que a abstração do PaymentProcessor
não está clara e está vazando detalhes. Você criou uma classe que deveria "esconder" a complexidade do processamento de pagamentos, mas ela está vazando responsabilidades. O fato de você ter que mockar tantos detalhes internos indica que essa classe está mal projetada e precisa de atenção.
Como Resolver?
Uma solução para uma abstração vazada como essa é separar as responsabilidades em classes menores e mais focadas. Em vez de deixar o PaymentProcessor
lidar com verificação de crédito e cálculo de impostos, você criaria classes específicas para essas funções:
public class PaymentProcessor {
private final CreditValidator creditValidator;
private final TaxCalculator taxCalculator;
private final PaymentRepository paymentRepository;
private final NotificationService notificationService;
public PaymentProcessor(CreditValidator creditValidator, TaxCalculator taxCalculator,
PaymentRepository paymentRepository, NotificationService notificationService) {
this.creditValidator = creditValidator;
this.taxCalculator = taxCalculator;
this.paymentRepository = paymentRepository;
this.notificationService = notificationService;
}
public void processPayment(Payment payment) {
if (!creditValidator.hasValidCredit(payment)) {
throw new IllegalArgumentException("Crédito inválido");
}
double tax = taxCalculator.calculateTax(payment);
paymentRepository.updatePaymentStatus(payment);
notificationService.sendPaymentConfirmation(payment);
}
}
Agora, com as responsabilidades separadas, o PaymentProcessor
apenas orquestra o processo. Isso facilita o entendimento da classe, simplifica os testes, pois você pode mockar e testar as interações entre as classes sem precisar lidar com os detalhes internos.
Conclusão
Os testes de unidade são uma forma de feedback contínuo sobre o design do sistema, expondo problemas que poderiam passar despercebidos. Se um teste está complicado demais, é um sinal claro de que algo no design não está bem definido — seja uma abstração vazada, falta de encapsulamento ou responsabilidades mal separadas.
Bons testes refletem um bom design. Eles ajudam a manter o código simples, modular e fácil de manter. Mas, quando os testes revelam dificuldades, é hora de olhar para o design de forma crítica e fazer os ajustes necessários. Afinal, o objetivo não é apenas garantir que o sistema funcione, mas que ele seja sustentável a longo prazo.
Fico por aqui neste post! Agradeço por ler até o final! Se gostou do conteúdo, compartilhe! Até o próximo! 😄
Excelente post!
Peguei alguns insights para uma apresentação que tenho para fazer na empresa que atuo, no momento o tema é "A importância do teste de software"