Desenvolvimento - Java
Desenvolvimento Orientado a Testes
Esse artigo apresenta a técnica de desenvolvimento orientado a testes, que tem como um de seus objetivos antecipar a identificação e correção de falhas durante o desenvolvimento. Será utilizado um pequeno exemplo para demonstrar o uso dessa técnica, conhecida em inglês como Test-driven development ou TDD.
por Vinícius Manhães TelesUma pesquisa do Departamento de Comércio dos EUA, publicada em 2002, revelou que falhas de software são tão comuns e tão danosas que se estima que causem um prejuízo anual de mais de 60 bilhões de dólares para a economia americana. O estudo também alega que, embora não seja possível remover todos os erros, mais de um terço destes custos poderia ser eliminado caso se utilizasse uma infra-estrutura de testes melhor, que permitisse identificar e remover defeitos mais cedo e de forma mais eficaz. Atualmente, calcula-se que cerca de 50% dos defeitos são encontrados apenas nas fases finais dos projetos, ou após os sistemas começarem a ser utilizados em produção.
Esse artigo apresenta a técnica de desenvolvimento orientado a testes, que tem como um de seus objetivos antecipar a identificação e correção de falhas durante o desenvolvimento. Será utilizado um pequeno exemplo para demonstrar o uso dessa técnica, conhecida em inglês como Test-driven development ou TDD.
Um exemplo com JUnit
O exemplo é um programa para gerar números primos, utilizando um conhecido algoritmo criado na antiguidade, descrito no quadro "Como funciona o Crivo de Eratóstenes". O leitor sem grandes inclinações matemáticas não precisa se intimidar com este exemplo, pois não é necessário compreender o algoritmo para aplicar os princípios do TDD, basta entender que o resultado esperado é uma seqüência de números primos até um determinado valor N, por exemplo 2, 3, 5, 7 e 11 para N=11.
Como funciona o Crivo de Eratóstenes? |
Números primos têm papel importante no uso de computadores. Destacam-se em particular as abordagens de criptografia utilizando chaves públicas, que são fortemente baseadas no uso de Números primos grandes. Gerar tais números de forma determinística é um desafio que vem sendo estudado há muito tempo e um dos algoritmos mais conhecidos para esse fim foi criado pelo matemático grego Eratóstenes (que viveu no século III AC), por isso chama-se Crivo de Eratóstenes. O algoritmo possui um funcionamento simples. Ele começa criando uma lista de números que vai de zero até o número máximo solicitado. Por exemplo, se estivéssemos buscando números primos até 10, o algoritmo começaria produzindo a lista a seguir:
Em seguida o algoritmo elimina os números 0 e 1 por não serem primos:
Começando pelo número 2, elimina-se cada um dos seus múltiplos, com exceção dele próprio (que é primo).
Avançando para o próximo número ainda não eliminado, que é 3, o algoritmo elimina cada um de seus múltiplos com exceção dele próprio.
O processo é repetido até se alcançar o número máximo informado. No caso do exemplo acima, os passos executados já foram suficientes para identificar todos os primos até 10. |
Para a construção dos testes sobre o algoritmo de geração de números primos, será utilizado o popular framework JUnit, que já vem integrado em vários IDEs Java, entre eles Eclipse, IntelliJ IDEA, NetBeans e JBuilder. Neste artigo, utilizamos o Eclipse, por exemplo.
Para utilizar o JUnit, você precisará colocar o arquivo junit.jar no classpath do seu ambiente de desenvolvimento preferido. Ele pode ser obtido fazendo-se o download do arquivo Junit3.8.1.zip, e extraindo-se o arquivo junit.jar.
Nosso ponto de partida será a classe de teste apresentada na Listagem 1, que faz uso de uma outra classe que não criamos ainda, chamada GeradorPrimos (que implementará o Crivo de Eratóstenes). Desta forma o teste se torna uma "especificação" de como a classe GeradorPrimos deverá funcionar quando estiver pronta.
Listagem 1: verifica se é capaz de gerar primos até o valor máximo 2. |
import junit.framework.TestCase; |
public class GeradorPrimosTeste extends TestCase { |
public void testePrimosGeradosAteNumeroDois() throws Exception { |
GeradorPrimos geradorPrimos = new GeradorPrimos(); |
assertEquals("2", geradorPrimos.gerarPrimosAte(2)); |
} |
} |
Espera-se que a classe gere um string, com uma lista de números primos, separados por vírgula, menores ou iguais ao valor passado como argumento. Por exemplo, se buscássemos números primos até dez, teríamos como resultado a string "2, 3, 5, 7".
O método assertEquals("2", geradorPrimos.gerarPrimosAte(2)), do nosso primeiro método de teste, é usado para verificar se a classe que estamos testando é capaz de gerar primos corretamente até o valor máximo 2. É um caso muito simples, afinal, estamos só no início. O único número primo até 2 é o próprio 2, que é a resposta esperada para esse caso.
O código do teste ainda não é compilável, pois a classe GeradorPrimos ainda não existe: o compilador vai considerar inválida a chamada ao método geradorPrimos.gerarPrimosAte(). Isso é normal quando escrevemos testes primeiro (antes do próprio código da aplicação). Estamos utilizando uma técnica conhecida como Programação por Intenção, na qual escrevemos linhas de código usando classes e métodos que ainda não existem e serão criados para atender às necessidades do teste.
Neste ponto, já temos a interface básica da classe GeradorPrimos definida, pelo seu uso nos testes. Estamos chamando de "interface" as assinaturas, ou seja, o nome dos métodos, os tipos de retorno e os tipos dos parâmetros.
Vamos criar uma primeira versão da classe GeradorPrimos, escrita da forma mais simples possível (ao menos por enquanto). O código da Listagem 2 é suficiente para o teste compilar, mas isso ainda não significa que a classe GeradorPrimos passará no teste. Quando o teste funcionar o JUnit vai apresentar uma barra verde indicando que tudo correu bem. Se falhar apresentará uma barra vermelha e uma mensagem indicando qual dos testes quebrou.
Listagem 2: estrutura mínima do método gerarPrimosAte(). |
public class GeradorPrimos { |
public String gerarPrimosAte(int i) { |
return null; |
} |
} |
Podemos ver isso executando o JUnit com o que temos até o momento. Para executar o teste no Eclipse, você deve escolher a opção Run|Run As>JUnit Test. Você também pode usar o atalho apresentado na Figura 1.
Figura 1: executando o jUnit no Eclipse. |
Ao executarmos esse teste, o JUnit mostra a barra vermelha apresentada na Figura 2.
Figura 2: teste falha e JUnit apresenta uma barra vermelha. |
Isso era de se esperar, afinal ainda não escrevemos a implementação correta do método que gera os números primos. Quando se programa utilizando TDD, é importante ter certeza de que o teste realmente será capaz de capturar um erro. Por isso sempre começamos inserindo um erro no código e conferindo se o teste falhou.
Em seguida, para verificar se o teste detecta o funcionamento correto da classe, fazemos outra implementação simples (e temporária) do método gerarPrimosAte(), retornando a string "2":
public String gerarPrimosAte(int i) {
return
"2";
}
Executando o teste novamente, o JUnit mostra que tudo correu bem, como pode ser observado na Figura 3.
Figura 3: teste funciona. |
No desenvolvimento orientado a testes, a primeira preocupação é escrever o teste e assegurar que ele funcione corretamente. Para fazer isso com segurança, é necessário certificar-se de que ele falha, quando temos absoluta certeza de que deveria falhar - e que passa quando temos total confiança de que deveria passar. Descobrimos isso começando por soluções obviamente simples que quebrem ou façam o teste funcionar. Depois disso, com a segurança de que o teste está correto, podemos cuidar da implementação real da classe, com a tranqüilidade de que o teste irá acusar erros ou sucessos de forma coerente.
Esse é um conceito muito utilizado em Extreme Programming, e conhecido em inglês pelo termo "baby steps": avançar cuidadosamente dando um pequeno passo seguro de cada vez e só passando à atividade seguinte quando há certeza de que a atividade atual está 100% em ordem.
Incrementando os testesPara continuar, poderíamos simplesmente escrever o restante do algoritmo, mas a verdade é que a classe já produz respostas certas para os testes que temos até o momento. Seria melhor começar criando novos cenários que levassem à necessidade de estender a implementação da classe GeradorPrimos. Por exemplo (seguindo o princípio dos baby steps) será que a classe conseguirá gerar números primos até 3? Eis um teste para descobrirmos isso:
public void testePrimosGeradosAteNumeroTres() throws Exception
{
GeradorPrimos geradorPrimos = new
GeradorPrimos();
assertEquals("2, 3",
geradorPrimos.gerarPrimosAte(3));
}
Executando o teste no JUnit, descobrimos (naturalmente) que ele falha, como podemos ver na Figura 4.
Figura 4: um dos testes falha e o JUnit apresenta a barra vermelha. |
Agora vamos forçar o teste a funcionar com a implementação mais simples possível. Veja a Listagem 3. Com essa modificação, o teste passa. Mas, algo começa a incomodar. Primeiro, o código ainda simplifica demais o problema, pois quando outros números forem informados será difícil gerar a resposta com essa linha de raciocínio. Além disso, a variável i não expressa bem sua intenção e o código de teste começou a apresentar uma incômoda duplicação. Para percebê-la, veja o código completo da classe de teste na Listagem 4.
Listagem 3: código suficiente para gerar primos até o valor máximo 3. |
public String gerarPrimosAte(int i) { |
if (i == 2) |
return "2"; |
else |
return "2, 3"; |
} |
Listagem 4: dois testes até o momento e duplicação de código. |
import junit.framework.TestCase; |
public class GeradorPrimosTeste extends TestCase { |
public void testePrimosGeradosAteNumeroDois() throws Exception { |
GeradorPrimos geradorPrimos = new GeradorPrimos(); |
assertEquals("2", geradorPrimos.gerarPrimosAte(2)); |
} |
public void testePrimosGeradosAteNumeroTres() throws Exception { |
GeradorPrimos geradorPrimos = new GeradorPrimos(); |
assertEquals("2, 3", geradorPrimos.gerarPrimosAte(3)); |
} |
} |
Simplificando seqüências de testes
A variável geradorPrimos é instanciada da mesma forma nos dois métodos de teste e a estrutura do assertEquals() é basicamente a mesma, mudando apenas os parâmetros. Isso fere um importante princípio, conhecido pela sigla DRY, do inglês "Don"t Repeat Yourself" (não se repita). É importante eliminar duplicações para tornar nosso código mais claro e mais fácil de manter. Antes de prosseguirmos com o desenvolvimento, faremos algumas refatorações simples, começando por eliminar essa duplicação.
Refatorar é uma prática comum em Extreme Programming e significa alterar o código, sem alterar o que ele faz. Trata-se de uma mudança efetuada apenas para melhorar a estrutura do código, tornando-o mais simples, mais legível e, portanto, mais fácil de manter. A razão mais comum para refatorar é a identificação de duplicações. Elas são danosas porque quando temos que alterar algo que está duplicado, nosso trabalho é maior, pois a alteração tem que ser feita em vários lugares, ao invés de um só. Além disso, o potencial de erros se eleva. Por exemplo, quando alteramos um trecho de código que se repete em dez lugares diferentes, existe a chance de alterarmos em quase todos os lugares e esquecermos um, o que normalmente gera um erro.
Para retirarmos a duplicação identificada, vamos extrair um método. Trata-se de uma refatoração na qual isolamos um trecho do código, que se repete em vários lugares, em um método que possa ser chamado em cada um dos lugares nos quais o trecho de código era utilizado. Assim, qualquer alteração nesse trecho de código pode ser feita em um único lugar e afetará todos os pontos da aplicação que o utilizam.
Para solucionar a duplicação, extraímos o método verificaPrimosGerados() que você encontra na Listagem 5. Ele recebe como parâmetro a lista de primos que espera-se que seja produzida e o número máximo até o qual se deve procurar por primos.
Voltando ao problema da variável i, no código de geração de primos, a renomeamos para valorMaximo. Note que o número 2 se comporta como um "número mágico" neste código: apenas lendo-o não temos como saber qual é o seu significado exato no programa. Literais espalhados ao longo do código frequentemente têm essa característica: quem escreve o código é capaz de compreendê-los (pelo menos logo depois de escrever), mas outros programadores não conseguem entender seu significado rapidamente, o que prejudica a manutenção. Resolvemos isso introduzindo uma constante, cujo nome expressa o significado do número. Veja a Listagem 6.
Listagem 5: duplicação de código eliminada com verificaPrimosGerados(). |
import junit.framework.TestCase; |
public class GeradorPrimosTeste extends TestCase { |
public void testePrimosGeradosAteNumeroDois() throws Exception { |
verificaPrimosGerados("2", 2); |
} |
public void testePrimosGeradosAteNumeroTres() throws Exception { |
verificaPrimosGerados("2, 3", 3); |
} |
private void verificaPrimosGerados(String listaEsperada, |
int numeroMaximo) throws Exception { |
GeradorPrimos geradorPrimos = new GeradorPrimos(); |
assertEquals(listaEsperada, |
geradorPrimos.gerarPrimosAte(numeroMaximo)); |
} |
} |
Listagem 6: introdução de uma constante no lugar do literal 2. |
public class GeradorPrimos { |
public static final int MENOR_PRIMO = 2; |
public String gerarPrimosAte(int valorMaximo) { |
if (valorMaximo == MENOR_PRIMO) |
return "2"; |
else |
return "2, 3"; |
} |
} |
Testando erros
Agora que o código de teste e o código do gerador de primos estão mais organizados, podemos avançar com segurança na solução do problema. Antes de verificar o que acontece ao tentar gerar primos até 4, percebemos que não existem testes para o caso de algum usuário tentar utilizar como valor máximo um número menor que 2. Em princípio, faria pouco sentido, mas é importante tratar essa possibilidade.
O comportamento desejado para esses casos é que o método lance a exceção ValorMaximoInvalidoException. Utilizamos um teste para expressar esse comportamento, como demonstrado na Listagem 7. A estrutura de teste apresentada no método testeSeRejeitaValorMaximoUm() é tipicamente utilizada quando se deseja assegurar que um código lance uma exceção sob certas circunstâncias.
Analisando o processamento desse método, note que, se o gerador de primos estiver funcionando corretamente, a chamada geradorPrimos.gerarPrimosAte(1) irá lançar a exceção esperada e o processamento será desviado para o bloco catch. Lá dentro, a instrução assertTrue(true) serve apenas para informar ao leitor deste código que alcançar o bloco catch é o comportamento esperado do teste.
Listagem 7: testa se gera exceção quando um valor máximo inválido é informado. |
public void testeSeRejeitaValorMaximoUm() throws Exception { |
GeradorPrimos geradorPrimos = new GeradorPrimos(); |
try { |
geradorPrimos.gerarPrimosAte(1); |
fail("Deveria ter lancado ValorMaximoInvalidoException"); |
} catch (ValorMaximoInvalidoException e) { |
assertTrue(true); |
} |
} |
Se a exceção não for lançada, a linha contendo a instrução fail() será executada forçando o JUnit a apresentar uma falha com a descrição passada ao método fail(): "Deveria ter lançado ValorMaximoInvalidoException". Inicialmente esse código não é compilável, pois a classe ValorMaximoInvalidoException ainda não existe. Vamos criá-la:
public class ValorMaximoInvalidoException extends Exception
{
public ValorMaximoInvalidoException()
{
super("O valor maximo deve ser maior ou igual a
2");
}
}
Finalmente, para que o código compile, é necessário que o gerador de primos declare lançar esta exceção:
public String gerarPrimosAte(int
valorMaximo)
throws ValorMaximoInvalidoException
{
(...)
Agora o teste compila, e gera a falha abaixo quando executado:
testeSeRejeitaValorMaximoUm()
junit.framework.AssertionFailedError:
Deveria ter lancado
ValorMaximoInvalidoException
Como sempre, primeiro esperamos que o teste falhe, depois o fazemos passar. Fazendo a correção apresentada na Listagem 8, o teste funciona.
Listagem 8: lança exceção quando o valor máximo é inválido. |
public String gerarPrimosAte(int valorMaximo) |
throws ValorMaximoInvalidoException { |
if (valorMaximo < MENOR_PRIMO) |
throw new ValorMaximoInvalidoException(); |
if (valorMaximo == MENOR_PRIMO) |
return "2"; |
else |
return "2, 3"; |
} |
} |
Refatorando os testes
O novo teste, introduzido na Listagem 7, duplicou a instanciação da variável geradorPrimos. Para evitar esse problema, refatoramos o teste, tornando a variável um atributo da classe. Veja a Listagem 9 e compare-a com os métodos apresentados nas Listagens 5 e 7.
Listagem 9: elimina duplicação da instanciação da variável geradorPrimos introduzindo um atributo na classe. |
import junit.framework.TestCase; |
public class GeradorPrimosTeste extends TestCase { |
GeradorPrimos geradorPrimos = new GeradorPrimos(); |
public void testePrimosGeradosAteNumeroDois() throws Exception { |
verificaPrimosGerados("2", 2); |
} |
public void testePrimosGeradosAteNumeroTres() throws Exception { |
verificaPrimosGerados("2, 3", 3); |
} |
private void verificaPrimosGerados(String listaEsperada, int numeroMaximo) throws Exception { |
GeradorPrimos geradorPrimos = new GeradorPrimos(); |
assertEquals(listaEsperada, geradorPrimos.gerarPrimosAte(numeroMaximo)); |
} |
public void testeSeRejeitaValorMaximoUm() throws Exception { |
try { |
geradorPrimos.gerarPrimosAte(1); |
fail("Deveria ter lancado ValorMaximoInvalidoException"); |
} catch (ValorMaximoInvalidoException e) { |
assertTrue(true); |
} |
} |
} |
Analisando a classe GeradorPrimos, notamos que a legibilidade do método gerarPrimosAte() foi prejudicada, porque ele cuida primeiro da exceção e depois se preocupa com o processamento do roteiro que seria natural caso o parâmetro de entrada tivesse sido válido. É recomendável que os métodos primeiro cuidem do roteiro natural de processamento e depois tratem os casos excepcionais. Resolvemos isso com a refatoração apresentada na Listagem 10.
Listagem 10: refatoração para tratar a exceção após o roteiro natural de execução do método. |
public String gerarPrimosAte(int valorMaximo) |
throws ValorMaximoInvalidoException { |
if (valorMaximo > MENOR_PRIMO) { |
if (valorMaximo == MENOR_PRIMO) |
return "2"; |
else |
return "2, 3"; |
} else { |
throw new ValorMaximoInvalidoException(); |
} |
} |
Ao fazer refatorações, devemos sair de um estado no qual todos os testes estão passando para outro no qual os testes continuem funcionando. Para verificar isso, sempre executamos todos os testes após refatorar o código. Fazendo isso obtemos a resposta abaixo:
testePrimosGeradosAteNumeroDois()
Error:
ValorMaximoInvalidoException:
O valor maximo deve ser maior ou igual a 2
Opa! Ao refatorar, o código deixou de funcionar. Sorte nossa termos um teste para apontar o problema imediatamente! Aparentemente, o método não consegue mais gerar primos até 2. Analisando melhor o que foi feito, podemos notar o uso de if (valorMaximo > MENOR_PRIMO), enquanto o correto teria sido if (valorMaximo >= MENOR_PRIMO), um erro comum fruto de falta de atenção. Fazendo-se essa pequena correção, todos os testes voltaram a funcionar.
Chegamos até aqui para assegurar que o gerador de primos rejeita números menores que 2 como entrada. Acabamos de verificar que isso já está sendo feito para o caso do número 1. Mas, será que o mesmo acontece para zero e números negativos? A melhor forma de saber é escrevendo mais um teste, como o apresentado na Listagem 11. Esse teste funciona de primeira, mas para isso, duplicamos muito código. Podemos solucionar isso rapidamente extraindo um método, como o mostrado na Listagem 12.
Listagem 11: testa se lança exceção quando se tenta gerar primos até o valor máximo zero. |
public void testeSeRejeitaValorMaximoZero() throws Exception { |
GeradorPrimos geradorPrimos = new GeradorPrimos(); |
try { |
geradorPrimos.gerarPrimosAte(0); |
fail("Deveria ter lancado ValorMaximoInvalidoException"); |
} catch (ValorMaximoInvalidoException e) { |
assertTrue(true); |
} |
} |
Listagem 12: refatoração para eliminar a duplicação no método que testa a rejeição de valores máximos inválidos. |
public void testeSeRejeitaValorMaximoZero() throws Exception { |
verificaSeRejeitaNumerosMenoresQueDois(0); |
} |
public void testeSeRejeitaValorMaximoUm() throws Exception { |
verificaSeRejeitaNumerosMenoresQueDois(1); |
} |
private void verificaSeRejeitaNumerosMenoresQueDois(int valorMaximo) { |
try { |
geradorPrimos.gerarPrimosAte(valorMaximo); |
fail("Deveria ter lancado ValorMaximoInvalidoException"); |
} catch (ValorMaximoInvalidoException e) { |
assertTrue(true); |
} |
} |
Continuando com os testes, devemos verificar também se o programa rejeita números negativos. Para isso, adicionamos um teste para verificar o caso do -1. Agora, que já fizemos uma refatoração que deu origem ao método verificaSeRejeitaNumerosMenoresQueDois() começamos a colher frutos, já que esse novo teste se revela trivial:
public void testeSeRejeitaValorMaximoNegativo() throws Exception
{
verificaSeRejeitaNumerosMenoresQueDois(-1);
}
O tempo investido refatorando permitiu adicionar outro teste de maneira mais rápida, mantendo o código organizado. Como se vê, a refatoração normalmente demanda um pequeno investimento inicial, porém gera economia de tempo futura, mantendo o código organizado. É comum ocorrer situações em que extraímos métodos como o verificaSeRejeitaNumerosMenoresQueDois(), que são então utilizados inúmeras vezes em uma mesma classe de teste. Nestes casos, especialmente, deixar de refatorar é mais custoso e o código fica mais difícil de compreender e manter.
Poderíamos adicionar outros testes para números negativos, mas não parece que sejam necessários. Iremos inferir que o que temos até o momento é suficiente para cuidar dos casos em que o valor máximo informado tenha que ser rejeitado.
Até onde testar?
Podemos prosseguir com o desenvolvimento do gerador de primos, tentando fazer com que ele gere números além do valor máximo 3. Para isso, criamos um novo teste verificando se o programa funciona para o valor máximo 4:
public void testePrimosGeradosAteNumeroQuatro() throws Exception
{
verificaPrimosGerados("2, 3", 4);
}
Mais uma vez, colhemos os frutos da refatoração, pois o método verificaPrimosGerados(), extraído mais cedo, nos ajudou a criar esse novo método de teste mais facilmente. Poderíamos esperar que o teste falhasse, porque a classe ainda não estava preparada para gerar primos até 4, mas o teste funcionou. Olhando melhor o código, observa-se que isso faz sentido, embora o caso não tivesse sido previsto. De qualquer forma, será difícil passar no teste a seguir:
public void testePrimosGeradosAteNumeroCinco() throws Exception
{
verificaPrimosGerados("2, 3, 5", 5);
}
O teste falha com a seguinte mensagem:
testePrimosGeradosAteNumeroCinco()
junit.framework.ComparisonFailure:
expected:<..., 5> but was:<...>
Agora é hora de implementar o Crivo de Eratóstenes. Começamos isolando o código que irá gerar os números primos em um novo método chamado numerosPrimos(), conforme a Listagem 13.
Listagem 13: refatoração para isolar o método que acomodará a parte principal do Algoritmo de Eratóstenes. |
public String gerarPrimosAte(int valorMaximo) throws ValorMaximoInvalidoException { |
if (valorMaximo >= MENOR_PRIMO) { |
return numerosPrimos(valorMaximo); |
} else { |
throw new ValorMaximoInvalidoException(); |
} |
} |
private String numerosPrimos(int valorMaximo) { |
boolean [] candidatos = inicializaListaCandidatos(valorMaximo); |
if (valorMaximo == MENOR_PRIMO) |
return "2"; |
else |
return "2, 3"; |
} |
Vamos precisar de uma lista representando possíveis candidatos de números primos. Usamos um vetor de booleanos, no qual true indica que o número é primo. Por exemplo, candidatos[3] = true indica que o número três é um primo. Para inicializar esse vetor, criamos o método inicializaListaCanditados(), que retorna o vetor preenchido com true em todas as suas posições, exceto às referentes ao número zero e um, números que já sabemos antecipadamente que não são primos. Seguindo o TDD, começamos a implementação do método a partir de um teste.
Há um pequeno inconveniente. O método deveria ser privado, pois não há necessidade de torná-lo público; também não seria bom fazê-lo apenas para torná-lo testável. Entretanto, se for mantido privado, será difícil testá-lo a partir da classe de teste. É possível resolver esse problema flexibilizando as restrições. Ao invés de mantê-lo privado, faremos com que tenha visibilidade de pacote. Assim, se a classe de teste for colocada no mesmo pacote da classe que desejamos testar, será possível acessar o método através do teste, sem a necessidade de torná-lo público. A Listagem 14 apresenta o teste escrito para esse método.
Listagem 14: testa a inicialização do array de números candidatos a primos. |
public void testeInicializacaoListaCandidatos() throws Exception { |
int valorMaximo = 5; |
boolean [] candidatos = geradorPrimos.inicializaListaCandidatos(valorMaximo); |
assertFalse(candidatos[0]); |
assertFalse(candidatos[1]); |
for (int i = GeradorPrimos.MENOR_PRIMO; i <= valorMaximo; i++) { |
assertTrue(candidatos[i]); |
} |
} |
Nossa implementação inicial do método inicializaListaCandidatos() é apresentada abaixo:
boolean[] inicializaListaCandidatos(int valorMaximo)
{
return null;
}
O código compila, mas o teste não passa, como era de se esperar inicialmente. O JUnit gera uma barra vermelha com a seguinte mensagem:
testeInicializacaoListaCandidatos()
Error:
java.lang.NullPointerException
Lembre-se que a primeira coisa que buscamos quando criamos um novo método de teste é assegurar que ele gere uma barra vermelha, introduzindo um erro proposital na classe que estamos testando. Isso foi feito neste caso quando fizemos o método inicializaListaCandidatos() retornar null.
Localização de Classes |
É possível colocar classes de testes no mesmo pacote no qual se encontra a classe a ser testada, permitindo acessar métodos e atributos com visibilidade de pacote. Entretanto, há o inconveniente de misturar classes da aplicação e de teste no mesmo pacote. Normalmente é preferível manter as classes separadas e utilizar uma técnica para continuar se beneficiando da visibilidade de pacote. Basta adotar duas raízes de código fonte, com a mesma estrutura, fazendo com que o código gerado por ambas seja direcionado para uma mesma raiz de bytecodes (.class). Veja, por exemplo, a organização dos arquivos deste projeto no Eclipse, na Figura 5. |
Figura 5: organização dos arquivos no Eclipse. |
Depois de assegurar que o teste é capaz de detectar um erro proposital, queremos que ele passe quando retornamos a resposta esperada da forma mais óbvia possível:
boolean[] inicializaListaCandidatos(int valorMaximo)
{
return new boolean[] {false, false, true, true, true,
true};
}
O teste passou como esperado. Agora que temos confiança de que o teste está correto, é hora de implementar o método inicializaListaCandidatos(). A Listagem 15 mostra a implementação que criamos, que, a princípio, parece estar correta. Vejamos se o teste confirma isso. Infelizmente ele falhou com uma mensagem enigmática:
testeInicializacaoListaCandidatos()
junit.framework.AssertionFailedError
Listagem 15: inicializa vetor com candidatos a número primos. |
boolean[] inicializaListaCandidatos(int valorMaximo) { |
boolean [] resultado = new boolean[valorMaximo]; |
resultado[0] = resultado [1] = false; |
for (int i = 0; i < resultado.length; i++) { |
resultado[i] = true; |
} |
return resultado; |
} |
Erramos no código da classe e felizmente o teste detectou isso. Porém, a mensagem que o JUnit forneceu nos dá poucas pistas sobre o que deu errado. Podemos melhorar o teste para tentar identificar a causa do erro mais facilmente. Veja as mudanças que fizemos no teste mostradas na Listagem 16.
Listagem 16: adiciona mensagem ao assert para facilitar a depuração de erros. |
public void testeInicializacaoListaCandidatos() throws Exception { |
int valorMaximo = 5; |
boolean [] candidatos = geradorPrimos.inicializaListaCandidatos(valorMaximo); |
assertEquals("candidatos[0]", false, candidatos[0]); |
assertEquals("candidatos[1]", false, candidatos[1]); |
for (int i = GeradorPrimos.MENOR_PRIMO; i <= valorMaximo; i++) { |
assertEquals("candidatos[" + i + "]:", true, candidatos[i]); |
} |
} |
Melhorando o feedback de falhas em testes
O JUnit permite especificar uma mensagem a ser exibida quando ocorre uma falha, para facilitar a depuração. No caso do assertFalse(), por exemplo, existem duas sobrecargas:
assertFalse(boolean condição)
assertFalse(String mensagem,
boolean condição)
Isso também ocorre com o método assertEquals().
assertEquals(Object valorEsperado, Object
valorObtido)
assertEquals(String mensagem, Object valorEsperado, Object
valorObtido)
Na primeira versão do teste de inicialização, na Listagem 14, foi usada a forma mais simples do assertFalse(), mas ela não ajudou muito na depuração. Para melhorar o feedback do código, substituiremos o assertFalse() por assertEquals(String mensage, Object valorEsperado, Object valorObtido). Executando o teste com a nova implementação, apresentada na Listagem 16, o JUnit informa:
testeInicializacaoListaCandidatos() junit.framework.AssertionFailedError: candidatos[0] expected:<false> but was:<true>
Agora está mais fácil identificar o problema, sabemos exatamente a posição do vetor preenchida com o valor incorreto. De alguma forma, estamos inicializando a posição [0] com true, quando deveria ser false. Analisando o código da classe GeradorPrimos, observamos que foi utilizada uma instrução for cuja variável i começa assumindo o valor 0. O correto seria ela começar com o valor 2, que representa o menor número primo. A correção é apresentada na Listagem 17.
Listagem 17: corrige inicialização do método inicializaListaCandidatos(). |
boolean[] inicializaListaCandidatos(int valorMaximo) { |
boolean [] resultado = new boolean[valorMaximo]; |
resultado[0] = resultado [1] = false; |
for (int i = MENOR_PRIMO; i < resultado.length; i++) { |
resultado[i] = true; |
} |
return resultado; |
} |
Executando o teste novamente, ainda encontramos um erro:
testeInicializacaoListaCandidatos()
java.lang.ArrayIndexOutOfBoundsException:
5
Inicializamos o vetor com menos posições do que o necessário. Precisamos lembrar que a contagem começa em zero e termina no valor máximo. Portanto, o número de posições no vetor tem que ser o valor máximo + 1. A correção apresentada na Listagem 18 resolve essa questão fazendo o código passar no teste.
Listagem 18: corrige tamanho do vetor de candidatos a número primos. |
boolean[] inicializaListaCandidatos(int valorMaximo) { |
boolean [] resultado = new boolean[valorMaximo + 1]; |
resultado[0] = resultado [1] = false; |
for (int i = 0; i < resultado.length; i++) { |
resultado[i] = true; |
} |
return resultado; |
} |
Embora a modificação no teste tenha ajudado, o código de teste ainda tem duplicações. Refatorando rapidamente, chegamos ao código da Listagem 19. Assim está melhor. Agora, se tivermos que fazer qualquer alteração na mensagem utilizada para depuração, por exemplo, teremos de mudar apenas no método verificaSeCandidatoTemValorEsperado() (isso de fato acabará acontecendo mais adiante e a refatoração se mostrará benéfica mais uma vez). Da forma como estava antes, seria necessário alterar em três lugares diferentes.
Listagem 19: refatoração para retirar duplicação. |
public void testeInicializacaoListaCandidatos() throws Exception { |
int valorMaximo = 5; |
boolean [] candidatos = geradorPrimos.inicializaListaCandidatos(valorMaximo); |
verificaSeCandidatoTemValorEsperado(0, false, candidatos[0]); |
verificaSeCandidatoTemValorEsperado(1, false, candidatos[1]); |
for (int i = GeradorPrimos.MENOR_PRIMO; i <= valorMaximo; i++) { |
verificaSeCandidatoTemValorEsperado(i, true, candidatos[i]); |
} |
} |
private void verificaSeCandidatoTemValorEsperado(int i, |
boolean valorEsperado, boolean candidato) { |
assertEquals("candidatos[" + i + "]:", valorEsperado, candidato); |
} |
Para que o programa fique completo, só falta terminar de implementar o método de geração de primos, o que é feito na Listagem 20.
Listagem 20: implementação do Algoritmo de Eratóstenes. |
private String numerosPrimos(int valorMaximo) { |
boolean [] candidatos = inicializaListaCandidatos(valorMaximo); |
for (int valor = MENOR_PRIMO; valor < valorMaximo; valor++) { |
if (candidatos[valor]) { |
for (int naoPrimos = MENOR_PRIMO * valor; |
naoPrimos < valorMaximo; naoPrimos += valor) { |
candidatos[naoPrimos] = false; |
} |
} |
} |
String resultado = String.valueOf(MENOR_PRIMO); |
for (int i = MENOR_PRIMO + 1; i < valorMaximo; i++) { |
if (candidatos[i]) { |
resultado += ", " + i; |
} |
} |
return resultado; |
} |
Finalizando o Crivo de Eratóstenes
O resultado não agrada. O métodop parece correto, mas está grande demais e poderia se beneficiar de uma boa refatoração. Em todo o caso, vejamos se o JUnit realmente considera o método correto:
testePrimosGeradosAteNumeroTres()
junit.framework.ComparisonFailure:
expected:<..., 3> but was:<...>
testPrimosGeradosAteNumeroCinco()
junit.framework.ComparisonFailure:
expected:<..., 5> but was:<...>
Ele apresentou duas falhas. O problema é que em todas as instruções for, foi usado como limite superior do loop expressões do tipo valor < valorMaximo. Deveria ter sido valor <= valorMaximo. Sendo assim, chegamos ao código apresentado na Listagem 21.
Listagem 21: correção do Algoritmo de Eratóstenes. |
private String numerosPrimos(int valorMaximo) { |
boolean [] candidatos = inicializaListaCandidatos(valorMaximo); |
for (int valor = MENOR_PRIMO; valor <= valorMaximo; valor++) { |
if (candidatos[valor]) { |
for (int naoPrimos = MENOR_PRIMO * valor; |
naoPrimos <= valorMaximo; naoPrimos += valor) { |
candidatos[naoPrimos] = false; |
} |
} |
} |
String resultado = String.valueOf(MENOR_PRIMO); |
for (int i = MENOR_PRIMO + 1; i <= valorMaximo; i++) { |
if (candidatos[i]) { |
resultado += ", " + i; |
} |
} |
return resultado; |
} |
Finalmente todos os testes passam. Já sabemos que o programa funciona bem para gerar primos até o número cinco. Não temos, é claro, como testar todos os possíveis primos, mas podemos testar para mais alguns, o suficiente para nos deixar mais tranqüilos. Sendo assim, acrescentamos os novos testes indicados na Listagem 22.
Listagem 22: novos cenários de testes. |
public void testePrimosGeradosAteNumeroDez() throws Exception { |
verificaPrimosGerados("2, 3, 5, 7", 10); |
} |
public void testePrimosGeradosAteNumeroVinteDois() throws Exception { |
verificaPrimosGerados("2, 3, 5, 7, 11, 13, 17, 19", 22); |
} |
Esses testes também passaram. Podemos inferir que continuará funcionando para números maiores. Mas há uma última questão: o método numerosPrimos() está muito grande. Ele tem mais responsabilidades do que deveria. Além disso, algumas das variáveis têm nomes que dificultam o entendimento.
Algumas refatorações devem resolver o problema. Podemos fazê-las tranquilamente, pois se errarmos, os testes nos informarão. Como primeiro passo, movemos a responsabilidade de formatar o resultado para outro método, como mostrado na Listagem 23.
Listagem 23: refatoração para melhorar a legibilidade do Algoritmo de Eratóstenes. |
private String numerosPrimos(int valorMaximo) { |
boolean [] candidatos = inicializaListaCandidatos(valorMaximo); |
for (int valor = MENOR_PRIMO; valor <= valorMaximo; valor++) { |
if (candidatos[valor]) { |
for (int naoPrimos = MENOR_PRIMO * valor; |
naoPrimos <= valorMaximo; naoPrimos += valor) { |
candidatos[naoPrimos] = false; |
} |
} |
} |
return apresentaResultado(valorMaximo, candidatos); |
} |
private String apresentaResultado(int valorMaximo, |
boolean[] candidatos) { |
String resultado = String.valueOf(MENOR_PRIMO); |
for (int i = MENOR_PRIMO + 1; i <= valorMaximo; i++) { |
if (candidatos[i]) { |
resultado += ", " + i; |
} |
} |
return resultado; |
} |
Todos os testes continuam funcionando. Agora é preciso dar um jeito na variável candidatos[]. Esse nome agora parece inadequado. Como se trata de um vetor de booleanos, seria melhor um nome do tipo ehPrimo[], como usado na Listagem 24. Note como fica mais fácil compreender o código depois dessa mudança.
Listagem 24: o vetor candidatos[] foi renomeado para ehPrimo[]. |
private String numerosPrimos(int valorMaximo) { |
boolean [] ehPrimo = inicializaListaCandidatos(valorMaximo); |
for (int valor = MENOR_PRIMO; valor <= valorMaximo; valor++) { |
if (ehPrimo[valor]) { |
for (int naoPrimos = MENOR_PRIMO * valor; |
naoPrimos <= valorMaximo; naoPrimos += valor) { |
ehPrimo[naoPrimos] = false; |
} |
} |
} |
return apresentaResultado(valorMaximo, ehPrimo); |
} |
private String apresentaResultado(int valorMaximo, boolean[] ehPrimo) { |
String resultado = String.valueOf(MENOR_PRIMO); |
for (int i = MENOR_PRIMO + 1; i <= valorMaximo; i++) { |
if (ehPrimo[i]) { |
resultado += ", " + i; |
} |
} |
return resultado; |
} |
boolean[] inicializaListaDePrimosPotenciais(int valorMaximo) { |
boolean [] resultado = new boolean[valorMaximo + 1]; |
resultado[0] = resultado [1] = false; |
for (int i = MENOR_PRIMO; i < resultado.length; i++) { |
resultado[i] = true; |
} |
return resultado; |
} |
Ao mudar o nome do método inicializaListaCandidatos() para inicializaListaDePrimosPotenciais(), naturalmente um ou mais métodos de teste também tiveram que ser atualizados para utilizar o novo nome.
O código está mais legível e os testes continuam funcionando. Podemos fazer a última refatoração em um método de teste para levar em conta a mudança no nome do vetor candidatos[] para ehPrimo[]. Veja a Listagem 25.
Listagem 25: o vetor candidatos[] foi renomeado para ehPrimo[]. |
public void testInicializacaoListaDePrimosPotenciais() throws Exception { |
int valorMaximo = 5; |
boolean [] ehPrimo = |
geradorPrimos.inicializaListaCandidatos(valorMaximo); |
verificaSeEhPrimo(0, false, ehPrimo[0]); |
verificaSeEhPrimo(1, false, ehPrimo[1]); |
for (int i = GeradorPrimos.MENOR_PRIMO; i <= valorMaximo; i++) { |
verificaSeEhPrimo(i, true, ehPrimo[i]); |
} |
} |
private void verificaSeEhPrimo(int i, boolean esperado, boolean numero) { |
assertEquals("ehPrimo[" + i + "]:", esperado, numero); |
} |
Conclusões
Assim encerramos a implementação do Crivo de Eratóstenes. O processo de criação e uso dos testes é mais rápido do que aparenta. Cada passo foi muito simples e levou poucos segundos para ser executado ou no máximo poucos minutos.
Usando TDD, quando acabamos, realmente acabamos. Ou seja, dificilmente temos que retornar ao código futuramente para corrigir falhas, pois possíveis falhas já foram detectadas e corrigidas durante a confecção dos testes. Além disso, se alguém alterar esse código no futuro, os testes irão dizer se a mudança foi bem sucedida ou não. O processo não é infalível, mas códigos gerados assim raramente apresentam problemas.
Lembre-se sempre dos três passos básicos do desenvolvimento orientado a testes:
- Escrever um teste e assegurar que ele não funcione introduzindo um erro óbvio no código sendo testado.
- Fazer o teste funcionar com a implementação mais óbvia possível.
- Refatorar o método sendo testado e o próprio método de teste. O primeiro, para colocar a implementação desejada para a aplicação e o segundo para eliminar duplicações e melhorar a legibilidade.
No início, trabalhar com TDD pode parecer um pouco doloroso, pois temos que fazer o inverso do que estamos acostumados. Mas, como em todo aprendizado, a dificuldade vem apenas no começo e nos tornamos melhores à medida que praticamos. Pelos problemas que foram descritos no início e o impacto negativo que eles trazem para nós, para nossa indústria e nossos clientes, o esforço certamente é válido!