BT

Disseminando conhecimento e inovação em desenvolvimento de software corporativo.

Contribuir

Tópicos

Escolha a região

Início Artigos Asserções Customizadas em Testes

Asserções Customizadas em Testes

Favoritos

Escrever asserções para testes parece ser bem simples: tudo o que precisa-se é comparar os resultados com as espectativas. Normalmente utilizando métodos de asserções, como por exemplo assertTrue() e assertEquals(), disponíveis por frameworks de teste. Entretanto, em cenários mais complexos pode parecer estranho verificar o resultado utilizando asserções tão básicas.

O ponto é que utilizando estas asserções básicas, os testes tornam-se obscuros com tantos detalhes de baixo nível.

Neste artigo, será mostrado como pode-se utilizar as chamadas "bibliotecas de matcher" para implementar asserções customizadas a fim de tornar os testes mais legíveis e de fácil manutenção.

Como demonstração, será considerada a seguinte tarefa: desenvolver uma classe para o módulo de relatório da uma aplicação de modo que fornecendo duas datas ("inicio" e "fim"), seja retornado os intervalos de uma hora entre essas duas datas. Os intervalos são usados posteriormente para recuperar dados necessários da base de dados e apresentá-los para o usuário final na forma de gráficos.

Abordagem Padrão

Primeiramente será apresentado o modo padrão de escrita de asserções. Será utilizado o JUnit , porém podendo ser aplicado da mesma forma, por exemplo, ao TestNG. Aqui serão utilizados os métodos de asserções como assertTrue(), assertNotNull() e assertSame().

A seguir é exibido um dos muitos testes pertencentes a classe HourRangeTest. O teste primeiro recupera a lista de intervalos de horas de duas datas do mesmo dia no método getRanges(). Depois verifica se o conjunto retornado é exatamente como deveria ser.

private final static SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm");

@Test
public void shouldReturnHourlyRanges() throws ParseException {
// given
Date dateFrom = SDF.parse("2012-07-23 12:00");
Date dateTo = SDF.parse("2012-07-23 15:00");
// when
final List<Range> ranges = HourlyRange.getRanges(dateFrom, dateTo);
// then
assertEquals(3, ranges.size());
assertEquals(SDF.parse("2012-07-23 12:00").getTime(), ranges.get(0).getStart());
assertEquals(SDF.parse("2012-07-23 13:00").getTime(), ranges.get(0).getEnd());
assertEquals(SDF.parse("2012-07-23 13:00").getTime(), ranges.get(1).getStart());
assertEquals(SDF.parse("2012-07-23 14:00").getTime(), ranges.get(1).getEnd());
assertEquals(SDF.parse("2012-07-23 14:00").getTime(), ranges.get(2).getStart());
assertEquals(SDF.parse("2012-07-23 15:00").getTime(), ranges.get(2).getEnd());
}

Este é, sem sobra de dúvidas, um teste válido; entretanto, há muitos fragmentos repetidos na parte após o //then. Obviamente eles foram criados usando o recurso de copiar e colar, o que leva inevitavelmente à erros. Além do mais, se for necessário escrever mais testes como este (e é claro que se deve escrever mais testes para verificar a classe HourlyRange!), as mesmas linhas de asserções se repetiriam mais e mais vezes em cada teste.

A legibilidade do teste atual é enfraquecida pelo excesso de asserções, mas também pela complicada natureza de cada asserção. Há vários detalhes de baixo nível, que não ajudam à compreender o cenário central do teste. Trechos de código são lidos muito mais frequentemente do que escritos (também se aplica aos testes), sendo assim, deve-se sempre ter a legibilidade como foco de aperfeiçoamento.

Outro ponto negativo do teste, desta vez relacionado à mensagem de erro recebida quando algo da errado. Por exemplo, se um dos intervalos retornados pelo método getRanges() possuir um tempo diferente do esperado, tudo que vamos ver é algo como:

org.junit.ComparisonFailure:
Expected :1343044800000
Actual :1343041200000

Essa mensagem não é muito clara e pode ser melhorada.

Métodos Privados

A fim de aperfeiçoar o exemplo anterior pode-se extrair a asserção para um método privado.

private void assertThatRangeExists(List<Range> ranges, int rangeNb,
String start, String stop) throws ParseException {
assertEquals(ranges.get(rangeNb).getStart(), SDF.parse(start).getTime());
assertEquals(ranges.get(rangeNb).getEnd(), SDF.parse(stop).getTime());
}
@Test
public void shouldReturnHourlyRanges() throws ParseException {
// given
Date dateFrom = SDF.parse("2012-07-23 12:00");
Date dateTo = SDF.parse("2012-07-23 15:00");
// when
final List<Range> ranges = HourlyRange.getRanges(dateFrom, dateTo);
// then
assertEquals(ranges.size(), 3);
assertThatRangeExists(ranges, 0, "2012-07-23 12:00", "2012-07-23 13:00");
assertThatRangeExists(ranges, 1, "2012-07-23 13:00", "2012-07-23 14:00");
assertThatRangeExists(ranges, 2, "2012-07-23 14:00", "2012-07-23 15:00");
}

A quantidade de código repetido foi reduzida e a legibilidade aumentou.

Outra vantagem dessa abordagem é ter melhor controle para permitir aprimorar a mensagem de erro que é exibida na verificação que falhou. O código de asserção é extraído em um método, permitindo alterar a asserção com mensagens de erro mais legíveis com facilidade.

O reuso de métodos de asserção como este pode ser facilitado movendo-os para uma classe base que a classe de testes precisaria estender.

O habito de utilizar métodos privados possui alguns pontos negativos, que tornam-se mais evidentes a medida que a quantidade de código de teste cresce e estes métodos privados começam a ser utilizados em muitos métodos de teste:

  1. É difícil lembrar de nomes de métodos de asserções que retratam claramente o que eles verificam;
  2. Seguindo o crescimento dos requisitos, estes métodos tendem a receber parâmetros adicionais para verificações mais sofisticadas (o método assertThatRangeExists() recebe 4 parâmetros, o que já é muito!);
  3. Às vezes, a fim de reutilizar o método em muitos testes, alguma lógica complicada é adicionado ao método (normalmente, na forma de flags booleanas que verificam ou ignoram alguns casos especiais).

Tudo isso significa que ao longo do tempo pode-se encontrar problemas relacionados com a legibilidade e manutenibilidade dos testes escritos com a ajuda de métodos de asserção privados. A seguir será apresentada outra solução livre desses pontos negativos.

Bibliotecas de Matcher

Como foi mencionado anteriormente, as asserções providas pelo JUnit ou TestNG não são flexíveis o suficiente. No mundo Java há pelo menos duas bibliotecas open source que suprem essas necessidades: AssertJ (um fork do projeto FEST Fluent Assertions) e o Hamcrest. Os dois são bastante poderosos e permitem atingir efeitos similares. A principal diferença do AssertJ sobre o Hamcrest é sua API, baseada em interfaces fluentes, que é perfeitamente suportada pelas IDEs.

Para integrar o AssertJ com o JUnit ou TestNG basta somente realizar os imports necessários, parar de utilizar as asserções padrões do framework de teste e iniciar o uso das asserções fornecidas pelo AssertJ.

O AssertJ fornece muitas asserções que seguem o mesmo padrão: começam com o método assertThat(), um método estático da classe Assertions que recebe o objeto testado como parâmetro. Logo em seguida são os métodos de asserção, no qual cada um verifica as propriedades do objeto testado:

assertThat(myDouble).isLessThanOrEqualTo(2.0d);

assertThat(myListOfStrings).contains("a");

assertThat("some text")
.isNotEmpty()
.startsWith("some")
.hasLength(9);

O AssertJ provê um conjunto muito mais rico de asserções que o JUnit ou TestNG. Pode-se encadear os métodos entre si, assim como é mostrado no exemplo anterior, permitindo que a IDE sugira os métodos que podem ser utilizados baseado no tipo de objeto sendo testado. Por exemplo, no caso de uma variável de ponto flutuante, após assertEquals(myDouble) e CTRL+SPACE pressionado (dependendo da IDE), será exibido uma lista de métodos como isEqualTo(expectedDouble), isNegative() ou isGreaterThan(otherDouble) - todos que fazem sentido com a verificação do valor de ponto flutuante.

Suggestões da IDE

Asserções Customizadas

Outra característica das bibliotecas de matcher é permitir a escrita de asserções customizadas. Essas asserções customizadas vão se comportar exatamente como as asserções do AssertJ se comportam - por exemplo, será possível encadeá-las.

Antes de implementar uma asserção customizada, a seguir é exibido como pode ficar o teste da classe HourlyRange, sendo utilizado o método assertThat() da classe RangeAssert.

@Test
public void shouldReturnHourlyRanges() throws ParseException {
// given
Date dateFrom = SDF.parse("2012-07-23 12:00");
Date dateTo = SDF.parse("2012-07-23 15:00");
// when
List<Range> ranges = HourlyRange.getRanges(dateFrom, dateTo);
// then
RangeAssert.assertThat(ranges)
.hasSize(3)
.isSortedAscending()
.hasRange("2012-07-23 12:00", "2012-07-23 13:00")
.hasRange("2012-07-23 13:00", "2012-07-23 14:00")
.hasRange("2012-07-23 14:00", "2012-07-23 15:00");
}

Algumas das vantagem de asserções customizadas podem ser vistas até mesmo em um pequeno exemplo como o mostrado anteriormente. A primeira coisa à se notar sobre o teste é que a parte do //then ficou menor e bem mais legível agora.

Outras vantagens irão aparacer quando eles forem aplicados em uma base de código maior. Continuando à utlizar a asserção customizada, nota-se que:

  1. Não é necessário utilizar todas as asserções, mas pode-se selecionar aquelas que são mais importantes para o caso de teste especifico;
  2. Para cenários de teste específicos, pode-se mudar as asserções de acordo com a preferência (por exemplo, passando objetos Date ao invés de String) com muita facilidade, favorecendo uma DSL. O que é mais importante, uma mudança como essa não afetaria nenhum outro teste;
  3. Maior legibilidade - não há dificuldade em nomear corretamente um método de verificação, visto que a asserção é composta por muitas pequenas asserções, cada uma focada em um pequeno aspecto da verificação.

A única desvantagem das asserções customizadas, comparadas com métodos de asserção privados, é a quantidade de esforço na criação delas. Vamos dar uma olhada no código da nossa asserção customizada para analisar o tamanho do trabalho extra.

Para se criar uma asserção customizada, basta estender a classe AbstractAssert do AssertJ ou algumas de suas muitas subclasses. Como mostrado a seguir, a classe RangeAssert estende da classe ListAssert do AssertJ. Isso faz sentido pois será verificado o conteúdo de uma lista de intervalos (List<Range>).

Cada asserção customizada escrita com o AssertJ contém código que é responsável pela criação do objeto de asserção e a injeção do objeto testado, para que os outros métodos possam operar sobre ele. Como mostrado na listagem a seguir, tanto o construtor como o método estático assertThat() recebem como parâmetro um objeto do tipo List<Range>.

public class RangeAssert extends ListAssert<Range> {

protected RangeAssert(List<Range> ranges) {
super(ranges);
}

public static RangeAssert assertThat(List<Range> ranges) {
return new RangeAssert(ranges);
}

Os métodos hasRange() e isSortedAscending (mostrados na próxima listagem) são exemplos típicos de métodos de asserção customizados da classe RangeAssert. Eles compartilham as seguintes propriedades:

  1. Os dois começam com uma chamada para o método isNotNull() que verifica se o objeto testado está nulo. Isso garante que a verificação não falhe devido à algum NullPointerException (este passo não é necessário, mas é recomendado);
  2. Os dois métodos retornam "this" (o próprio objeto da asserção customizada - da classe RangeAssert, neste caso), permitindo que as chamadas possam ser encadeadas;
  3. A verificação é feita utilizando asserções fornecidas pela classe Assertions (parte do framework do AssertJ);
  4. Os dois métodos usam um objeto "actual" (fornecido pela superclasse ListAssert), que mantém a lista de Ranges (List<Range>) sendo verificada.

private final static SimpleDateFormat SDF
= new SimpleDateFormat("yyyy-MM-dd HH:mm");

public RangeAssert isSortedAscending() {
isNotNull();
long start = 0;
for (int i = 0; i < actual.size(); i++) {
Assertions.assertThat(start)
.isLessThan(actual.get(i).getStart());
start = actual.get(i).getStart();
}
return this;
}

public RangeAssert hasRange(String from, String to) throws ParseException {
isNotNull();

Long dateFrom = SDF.parse(from).getTime();
Long dateTo = SDF.parse(to).getTime();

boolean found = false;
for (Range range : actual) {
if (range.getStart() == dateFrom && range.getEnd() == dateTo) {
found = true;
}
}
Assertions
.assertThat(found)
.isTrue();
return this;

}
}

O AssertJ permite adicionar uma mensagem de erro personalizada facilmente. Neste caso, como se trata de uma comparação de valores, o método as() é suficiente, como no exemplo a seguir:

Assertions
.assertThat(actual.size())
.as("number of ranges")
.isEqualTo(expectedSize);

O método as() é outro método fornecido pelo framework AssertJ. Agora, quando o teste falha, uma mensagem personalizada é impressa ajudando a identificar o problema imediatamente.

org.junit.ComparisonFailure: [number of ranges]
Expected :4
Actual :3

Às vezes é necessário mais do que o nome do objeto testado para compreender o que ocorreu. Seria muito bom poder imprimir todos os intervalos durante a falha. Isso pode ser feito utilizando o método overriginErrorMessage(), como a seguir:

public RangeAssert hasRange(String from, String to) throws ParseException {
...
String errMsg = String.format("ranges\n%s\ndo not contain %s-%s",
actual ,from, to);

...
Assertions.assertThat(found)
.overridingErrorMessage(errMsg)
.isTrue();
...
}

Durante a falha, pode-se observar uma mensagem de erro muito mais detalhada. Seu conteúdo dependeria do método toString() da classe Range:

HourlyRange{Mon Jul 23 12:00:00 CEST 2012 to Mon Jul 23 13:00:00 CEST 2012},
HourlyRange{Mon Jul 23 13:00:00 CEST 2012 to Mon Jul 23 14:00:00 CEST 2012},
HourlyRange{Mon Jul 23 14:00:00 CEST 2012 to Mon Jul 23 15:00:00 CEST 2012}]
do not contain 2012-07-23 16:00-2012-07-23 14:00

Conclusões

Neste artigo foram abordadas algumas formas de escrever asserções. Primeiro foi tratado o modelo "tradicional", baseado nas asserções disponíveis pelos frameworks de teste. Mesmo sendo suficiente em muitos casos, pode-se perceber que às vezes não possui flexibilidade para expressar o objetivo do teste. Na sequência, foram introduzidos os métodos de asserção privados, que também não se apresentaram como a solução ideal. Por último, foram apresentadas as asserções customizadas com o AssertJ, onde se atingiu maior legibilidade e manutenabilidade do código de teste.

Segue uma dica sobre asserções: elas podem melhor significantemente os testes se ao invés de utilizar as asserções fornecidas pelos framework de testes (como o JUnit e TestNG), forem utilizadas as asserções fornecidas por bibliotecas de matcher (como o AssertJ ou Hamcrest).

Mesmo que o custo de escrever asserções customizadas for muito pequeno, não é necessário adicioná-los somente pela facilidade. Use-os quando a legibilidade e/ou manutenabilidade do seu código de teste está em perigo. Tente introduzir asserções customizadas nos seguintes cenários:

  • Quando encontrar dificuldade em expressar o objetivo do teste com as asserções fornecidas pelas bibliotecas de matcher;
  • No lugar dos métodos privados de asserção.

Em testes unitários raramente será necessário asserções customizadas. Entretanto, elas podem ser insubstituíveis no caso de testes de integração e ponto-a-ponto (funcionais). Estes testes permitem escrever código na linguagem de domínio, encapsulando detalhes técnicos, e tornando nosso teste muito mais simples de manter.

Sobre o autor

Tomek KaczanowskiTomek Kaczanowski trabalha como desenvolver Java para a CodeWise (Krakow, Polônia) focando em qualidade de código, testes e automação. Entusiasta TDD, proponente open source e adorador ágil. Também é autor de livro, blogueiro e palestrante. Twitter: @tkaczanowski

Avalie esse artigo

Relevância
Estilo/Redação

Conteúdo educacional

  • Palestra sobre o assunto

    by Luiz Fernando Oliveira Corte R...,

    Seu comentário está aguardando aprovação dos moderadores. Obrigado por participar da discussão!

    Na AgileBrazil do ano passado, dei uma palestra que abordava esse mesmo assunto, mas com mais enfoque no Hamcrest.

    É um assunto bem extenso, que inclusive merece comentários fora do mundo Java. O pessoal de Ruby tem ferramentas incríveis para deixar os testes mais legíveis e fáceis de manter. Vale a pena dar uma estudada no RSpec, por exemplo!

HTML é permitido: a,b,br,blockquote,i,li,pre,u,ul,p

HTML é permitido: a,b,br,blockquote,i,li,pre,u,ul,p

BT