jUnit: Testando fluxos de exceção e erro
Aprenda como validar em detalhes as exceções lançadas pelo seu código usando a Rule ExpectedException
Quando começamos a escrever testes automatizados é comum nos preocuparmos apenas com os testes de caminho feliz, também conhecidos como Happy path. Não é por acaso, um desenvolvedor normalmente não pensa nos caminhos que divergem do fluxo principal da funcionalidade, ou seja, os casos alternativos e excepcionais. Para o desenvolvedor, seguir o fluxo principal é lógico e faz todo sentido, mas para o usuário final não é; o usuário sempre consegue tomar um caminho na qual nosso sistema não esperava. Para nossa infelicidade, ignorar estes fluxos alternativos é o que causa boa parte dos bugs existentes nas aplicações.
Imagine que temos uma sistema de leilão na qual os clientes podem dar lances. A priori, o código para registrar estes lances seria semelhante o este:
public class Leilao {
private String descricao;
private List<Lance> lances = new ArrayList<>();
/**
* Registra novo lance no leilão
*/
public void darLance(Lance lance) {
if (lance.getValor() < 0)
throw new RuntimeException("Lance com valor negativo");
this.lances.add(lance);
}
}
Escrever um teste de unidade com jUnit para esta classe é bem simples, basta garantir que o lance dado pelo cliente estará na lista de lances do leilão. Dessa forma, o código do nosso teste seria algo como:
public class LeilaoTest {
@Test
public void deveDarLance() {
Lance lance = new Lance("Rafael", 299.99);
Leilao kindle = new Leilao("Kindle Paperwhite");
kindle.darLance(lance);
assertEquals("total de lances", 1, kindle.getLances().size());
}
}
O código está coberto por testes, mas repare que cobrimos somente o happy path, ou seja, o nosso fluxo principal. Uma boa maneira de perceber isso é através de ferramentas de cobertura de código, como o plugin EclEmma para Eclipse:
Será que apenas este único teste é suficiente? Certamente que não!
Veja que o trecho de código não coberto por testes é um if
bem simples que representa um fluxo alternativo da nossa lógica de negócio. O problema, é que nem sempre testar uma regra como esta é tão fácil quanto escrevê-la. Em um teste manual o desenvolvedor teria que montar todo o cenário através de pré-cadastros no sistema; inserir e atualizar dados no banco de dados na mão; levantar o Tomcat, logar no sistema e fazer todo o passo a passo para cair no cenário. O final da história todo mundo já está cansando de saber: o desenvolvedor não faz o teste, afinal são apenas 2 linhas de código.
Um teste manual se torna muito caro para uma lógica tão simples, mas não para um teste executado pela máquina. É justamente nesse momento que os testes automatizados mostram sua força, pois escrever um teste para esta regra é muito barato, e depois de coberto não temos que nos preocupar mais com ela.
Para isso, basta criarmos um novo método de teste para este cenário e indicarmos ao jUnit que esperamos uma exceção ao dar um lance com valor negativo:
@Test(expected=RuntimeException.class)
public void naoDeveDarLanceQuandoValorForNegativo() {
Lance lance = new Lance("Rafael", -1.99);
Leilao kindle = new Leilao("Kindle Paperwhite");
kindle.darLance(lance);
}
Repare que informamos ao jUnit que nosso teste espera uma exceção do tipo RuntimeException
através do atributo expected
da anotação @Test
. Ao rodar a bateria de testes com o plugin do Eclemma temos como resultado a seguinte cobertura:
Muito melhor, não é mesmo?
Não tivemos que levantar Tomcat, nem logar no sistema nem mesmo inserir ou atualizar registros no banco de dados. O teste roda em milissegundos e cobre todos os caminhos na nossa lógica de negócio. Se um dia a lógica de negócio mudar ou evoluir o teste nos dirá se o programador quebrou algo ou não.
Validando a mensagem de erro da exceção
Muitas vezes a mensagem de erro da exceção é importante para o negócio e para o usuários do sistema, pois ela ajudará nosso usuário a entrar com valores válidos ou mesmo evitar um chamado para equipe de suporte.
Nese caso, precisamos alterar o teste para validar a mensagem de erro que vem junto com a exceção. Para isso, teríamos que utilizar o bom e velho try-catch
para capturar a exceção e examinar seus detalhes:
@Test
public void naoDeveDarLanceQuandoValorForNegativo() {
Lance lance = new Lance("Rafael", 0.0);
Leilao kindle = new Leilao("Kindle Paperwhite");
try {
kindle.darLance(lance);
fail("Cadê a exceção?"); // importante, não pode esquecer!
} catch (RuntimeException e) {
assertEquals("Lance com valor negativo", e.getMessage());
}
}
Veja que somos obrigados a capturar a exceção e verificar seu conteúdo. Além disso, tivemos que invocar o método fail()
do jUnit para evitarmos falso-positivo no teste, afinal de contas, a exceção pode não ser lançada e o teste acabaria passando! Mas será que esta é melhor forma de validar exceções com jUnit?
ExpectedException Rule - validando exceções em detalhes
A abordagem padrão para validar exceções do jUnit é útil para casos simples, mas ela tem seus limites. Por exemplo, não podemos testar a mensagem da exceção ou verificar possíveis informações contidas nela - tanto é que tivemos que recorrer ao try-catch
. Por esse motivo, desde o jUnit 4.7 temos como alternativa a Rule ExpectedException, na qual nos permite validar em detalhes uma exceção:
import org.junit.Rule;
import org.junit.rules.ExpectedException;
// outros imports
public class LeilaoTest {
@Rule
public ExpectedException thrown = ExpectedException.none();
@Test
public void naoDeveDarLanceQuandoValorForNegativo() {
thrown.expect(RuntimeException.class);
thrown.expectMessage("Lance com valor negativo");
Lance lance = new Lance("Rafael", 0.0);
Leilao kindle = new Leilao("Kindle Paperwhite");
kindle.darLance(lance);
}
}
Como toda Rule do jUnit, basta declarar um atributo público na classe e anotá-lo com a anotação @Rule
. Em seguida, configuramos nossas expectativas do teste antes de invocar a lógica de négocio. No nosso caso, se recebermos uma exceção diferente de RuntimeException
ou outra mensagem de erro o teste falha.
Para casos onde temos diversas exceções passíveis de serem lançadas pela lógica de negócio a rule ExpectedException faz toda a diferença.
Mas não para por aí!
Podemos ir mais longe e utilizar a biblioteca Hamcrest em conjunto com jUnit para inspecionarmos o conteúdo da exceção, como por exemplo seus atributos:
import static org.hamcrest.Matchers.hasProperty;
import static org.hamcrest.Matchers.is;
// outros imports
public class LeilaoTest {
@Rule
public ExpectedException thrown = ExpectedException.none();
@Test
public void naoDeveDarLanceQuandoValorForNegativo() {
thrown.expect(LanceInvalidoException.class);
thrown.expectMessage("Lance com valor negativo");
// detalhes da exceção
thrown.expect(hasProperty("codigoDeErro", is(-2020)));
Lance lance = new Lance("Rafael", 0.0);
Leilao kindle = new Leilao("Kindle Paperwhite");
kindle.darLance(lance);
}
}
O Hamcrest é um biblioteca que nos permite verificar o resultado dos nossos testes de maneira mais concisa e legível:
thrown.expect(hasProperty("codigoDeErro", is(-2020)));
Na classe de testes, fizemos o import static dos Matchers do Hamcrest para melhorar a clareza do nosso código. Outro detalhe, é que desta vez estamos validando a exceção LanceInvalidoException
na qual deve possuir o atributo codigoDeErro
com valor igual a -2020. Portanto, precisamos criar nossa exceção customizada:
public class LanceInvalidoException extends RuntimeException {
private int codigoDeErro;
public LanceInvalidoException(int codigoDeErro, String mensagem) {
super(mensagem);
this.codigoDeErro = codigoDeErro;
}
public int getCodigoDeErro() {
return codigoDeErro;
}
}
Por fim, para esse teste passar precisamos alterar a classe Leilao
para lançar nossa exceção e não mais a RuntimeException
:
public void darLance(Lance lance) {
if (lance.getValor() < 0)
throw new LanceInvalidoException(-2020, "Lance com valor negativo");
this.lances.add(lance);
}
O Hamcrest tem inúmeros outros matchers que se encaixam perfeitamente com os asserts do jUnit, além disso, dependendo do caso nós podemos criar nossos próprios matchers para validações mais detalhadas e complexas.
Para ver o código de exemplo em detalhes, basta acessar o projeto no nosso Github, @triadworks.
Não dê bobeira, cubra os fluxos alternativos
Toda lógica de negócio costuma ter caminhos alternativos que nós desenvolvedores precisamos ficar atentos; mesmo um if
aparentemente ingênuo precisa estar coberto por testes para não termos surpresas no futuro. Se esta lógica envolver exceções então, não hesite, use a rule ExpectedException do jUnit.
Tentar garantir a qualidade do software somente com testes manuais é caro, demorado e não sustentável para maioria das empresas, por esse motivo, cobrir o código com testes automatizados é uma maneira simples a um preço justo.
Caso queria dominar testes automatizados no mundo Java, conheça nosso curso de TDD e Testes, nele abordamos de ponta a ponta como você e sua equipe podem garantir a qualidade do sistema através da escrita de testes.
E você, como cobre seus cenários erro com testes?
You might also be interested in these articles...
Desenvolvedor e instrutor na TriadWorks