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

BT