Dans un précédent billet je vous parlais de l’utilité de choisir les bonnes assertions dans vos tests. En fin d’article j’évoquais la possibilité d’implémenter ses propres matchers. C’est ce dont on va parler aujourd’hui !

Ecrire ses propres matchers apporte à mon avis deux avantages :

  • Améliorer les messages d’erreurs lorsque les tests échouent
  • Augmenter la lisibilité du code des tests

Voyons cela tout de suite avec un exemple. 
Nous allons tester la classe BeerService et plus particulièrement la méthode getBeerById() :

package com.github.jbleduigou.beer.service;

import com.github.jbleduigou.beer.exception.EntityNotFoundException;
import com.github.jbleduigou.beer.model.Beer;
import com.github.jbleduigou.beer.repository.BeerRepository;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.validation.constraints.NotNull;

import java.util.List;
import java.util.stream.Collectors;

@Service
public class BeerService {

  private final BeerRepository repository;

  @Autowired
  public BeerService(BeerRepository repository) {
    this.repository = repository;
  }

  public List<Beer> getAllBeers() {
    return repository.findAll();
  }

  public List<Beer> getAllNonAlcoholicBeers() {
    return repository.findAll().stream()
            .filter(Beer::isAlcoholFree)
            .collect(Collectors.toList());
  }

  public Beer getBeerById(@NotNull Long beerId) {
    return repository.findById(beerId).orElseThrow(() -> new EntityNotFoundException("Beer", beerId));
  }
}

Pour rappel la classe Beer ressemble à ça :

package com.github.jbleduigou.beer.model;

import com.fasterxml.jackson.annotation.JsonIgnore;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
public class Beer {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  private String name;

  private Double alcoholByVolume;

  @JsonIgnore
  public boolean isAlcoholFree() {
    return alcoholByVolume != null && alcoholByVolume <= 0.5;
  }

}

Pour commencer nous allons implémenter notre matcher qui sera de type Matcher.
Il serait tentant d’implémenter directement l’interface Matcher, mais un petit tour par la Javadoc de cette interface devrait suffire à nous convaincre du contraire.

Matcher implementations should NOT directly implement this interface. Instead, extend the BaseMatcher abstract class, which will ensure that the Matcher API can grow to support new features and remain compatible with all Matcher implementations.

La solution logique serait maintenant d’hériter de la classe BaseMatcher, mais il y a une autre classe dont il peut-être intéressant d’hériter : TypeSafeMatcher. L’avantage est qu’elle vérifie la non nullité de l’élément testé, le type de cet objet et effectue une conversion de type.
Il va falloir implémenter les deux méthodes abstraites matchesSafely() et describeTo().
Ce qui nous donne :

package com.github.jbleduigou.beer.matchers;

import com.github.jbleduigou.beer.model.Beer;

import org.hamcrest.Description;
import org.hamcrest.TypeSafeMatcher;

import java.util.Objects;

class BeerWithName extends TypeSafeMatcher<Beer> {

  private final String name;

  BeerWithName(String name) {
    this.name = name;
  }

  @Override
  protected boolean matchesSafely(Beer beer) {
    return Objects.equals(beer.getName(), name);
  }

  @Override
  public void describeTo(Description description) {
    description.appendText("beer with name ").appendValue(name);
  }
}

Notez bien qu’il n’est pas nécessaire de tester si beer est null dans matchesSafely, ce test est déjà effectué par TypeSafeMatcher :

/**
 * Methods made final to prevent accidental override.
 * If you need to override this, there's no point on extending TypeSafeMatcher.
 * Instead, extend the {@link BaseMatcher}.
 */
@Override
@SuppressWarnings({"unchecked"})
public final boolean matches(Object item) {
    return item != null
            && expectedType.isInstance(item)
            && matchesSafely((T) item);
}

Afin de faciliter l’écriture des tests nous allons tout de suite implémenter une factory :

package com.github.jbleduigou.beer.matchers;

import com.github.jbleduigou.beer.model.Beer;

import org.hamcrest.Matcher;

public class BeerMatchers {

  public static Matcher<Beer> beerWithName(final String name) {
    return new BeerWithName(name);
  }
}

Ecrivons un test qui échoue afin de vérifier le comportement de ce matcher :

package com.github.jbleduigou.beer.service;

import com.github.jbleduigou.beer.exception.EntityNotFoundException;
import com.github.jbleduigou.beer.model.Beer;
import com.github.jbleduigou.beer.repository.BeerRepository;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;

import java.util.Arrays;
import java.util.List;
import java.util.Optional;

import static com.github.jbleduigou.beer.matchers.BeerMatchers.beerWithName;
import static org.hamcrest.Matchers.hasItem;
import static org.junit.Assert.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;

@RunWith(MockitoJUnitRunner.class)
public class BeerServiceTest {

  @InjectMocks
  private BeerService service;

  @Mock
  private BeerRepository repository;

  @Before
  public void setupMocks() {
    when(repository.findById(9531L)).thenReturn(Optional.of(new Beer(9531L, "Nanny State", 0.5)));
  }
  
  @Test
  public void getBeerShouldReturnNannyState() {
    Beer result = service.getBeerById(9531L);

    assertThat(result, beerWithName("Punk IPA"));

    verify(repository).findById(9531L);
    verifyNoMoreInteractions(repository);
  }
}

Nous avons déjà un comportement correct puisque l’on comprend aisément ce qui est attendu et ce que l’on a réellement obtenu :

java.lang.AssertionError: 
Expected: beer with name "Punk IPA"
     but: was <Beer(id=9531, name=Nanny State, alcoholByVolume=0.5)>

Il malgré tout possible d’améliorer cette dernière ligne en surchargeant la méthode describeMismatchSafely() :

package com.github.jbleduigou.beer.matchers;

import com.github.jbleduigou.beer.model.Beer;

import org.hamcrest.Description;
import org.hamcrest.TypeSafeMatcher;

import java.util.Objects;

class BeerWithName extends TypeSafeMatcher<Beer> {

  private final String name;

  BeerWithName(String name) {
    this.name = name;
  }

  @Override
  protected boolean matchesSafely(Beer beer) {
    return Objects.equals(beer.getName(), name);
  }

  @Override
  public void describeTo(Description description) {
    description.appendText("beer with name ").appendValue(name);
  }

  @Override
  protected void describeMismatchSafely(Beer item, Description mismatchDescription) {
    mismatchDescription.appendText("name was ");
    mismatchDescription.appendValue(item.getName());
  }
}

Si l’on relance le test nous avons maintenant le message suivant :

java.lang.AssertionError: 
Expected: beer with name "Punk IPA"
     but: name was "Nanny State"

Nous pouvons maintenant implémenter un deuxième matcher :

package com.github.jbleduigou.beer.matchers;

import com.github.jbleduigou.beer.model.Beer;

import org.hamcrest.Description;
import org.hamcrest.TypeSafeMatcher;

import java.util.Objects;

class BeerWithAlcoholByVolume extends TypeSafeMatcher<Beer> {

  private final Double alcoholByVolume;

  BeerWithAlcoholByVolume(Double alcoholByVolume) {
    this.alcoholByVolume = alcoholByVolume;
  }

  @Override
  protected boolean matchesSafely(Beer beer) {
    return Objects.equals(beer.getAlcoholByVolume(), alcoholByVolume);
  }

  @Override
  public void describeTo(Description description) {
    description.appendText("beer with alcoholByVolume ").appendValue(alcoholByVolume);
  }

  @Override
  protected void describeMismatchSafely(Beer item, Description mismatchDescription) {
    mismatchDescription.appendText("alcoholByVolume was ");
    mismatchDescription.appendValue(item.getAlcoholByVolume());
  }
}

Il faut mettre à jour la factory :

package com.github.jbleduigou.beer.matchers;

import com.github.jbleduigou.beer.model.Beer;

import org.hamcrest.Matcher;

public class BeerMatchers {

  public static Matcher<Beer> beerWithName(final String name) {
    return new BeerWithName(name);
  }

  public static Matcher<Beer> beerWithAbv(final Double alcoholByVolume) {
    return new BeerWithAlcoholByVolume(alcoholByVolume);
  }
}

Il est maintenant possible de combiner les deux matchers dans la même assertion :

package com.github.jbleduigou.beer.service;

import com.github.jbleduigou.beer.model.Beer;
import com.github.jbleduigou.beer.repository.BeerRepository;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;

import java.util.Optional;

import static com.github.jbleduigou.beer.matchers.BeerMatchers.beerWithAbv;
import static com.github.jbleduigou.beer.matchers.BeerMatchers.beerWithName;
import static org.hamcrest.Matchers.both;
import static org.junit.Assert.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.mockito.Mockito.when;

@RunWith(MockitoJUnitRunner.class)
public class BeerServiceTest {

  @InjectMocks
  private BeerService service;

  @Mock
  private BeerRepository repository;

  @Before
  public void setupMocks() {
    when(repository.findById(9531L)).thenReturn(Optional.of(new Beer(9531L, "Nanny State", 0.5)));
  }

  @Test
  public void getBeerShouldReturnNannyState() {
    Beer result = service.getBeerById(9531L);

    assertThat(result, both(beerWithName("Nanny State")).and(beerWithAbv(0.5)));

    verify(repository).findById(9531L);
    verifyNoMoreInteractions(repository);
  }
}

Je trouve qu’avec ces deux matchers le code du test est très lisible et nous l’avons vu les messages d’erreurs sont également très explicites en cas d’erreur.


Le code des exemples utilisé dans cet article est disponible sur GitHub : https://github.com/jbleduigou/beer-api-java

N’hésitez pas à me faire part de vos commentaires ou questions en bas de cet article ou en m’envoyant un message sur LinkedIn :
http://www.linkedin.com/in/jbleduigou/en

Photo de couverture par Sherman Yang.
Cet article a initialement été publié sur Medium.