Étant un développeur convaincu par les bénéfices des méthodes agiles, j’ai rapidement été attiré par les problématique liées aux tests. Avoir une suite de tests automatisée permet de d’assurer du bon fonctionnement du logiciel avant livraison, favorise le refactoring et limite le coût de traitement des anomalies. Sur le long terme cela permet généralement d’augmenter la vélocité de l’équipe et réduit la durée entre deux releases.

Bref tester c’est bien, mais tester bien c’est mieux ! Un bon test c’est quoi ? Pour moi un bon test est un test qui échoue lors de l’introduction d’une régression. Mieux encore il échoue en donnant des informations importantes sur les causes de cette échec. Les premières informations vont être le nom du test, dans le cas de JUnit le nom de la classe et le nom de la méthode. Une bonne convention de nommage est donc un bon point de départ. Pour aller plus loin il faut surtout bien concevoir les assertions.

Prenons un exemple concret d’un service qui retourne une liste de bières disponibles (sur une boutique en ligne par exemple). Nous allons avoir une classe Beer de type DTO (Data Transfer Object), un repository pour la gestion de la base de données et un service.

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;
  }

}
package com.github.jbleduigou.beer.repository;

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

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface BeerRepository extends JpaRepository<Beer, Long> {

  Beer findByName(String name);
}
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));
  }
}

Admettons que l’on souhaite maintenant tester la méthode getAllNonAlcoholicBeers(). Au passage cette méthode est loin d’être optimisée mais ce n’est pas le sujet de cet article. On va écrire un premier test avec JUnit et Mockito.

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.Arrays;  
import java.util.List;  
  
import static org.junit.Assert.assertTrue;  
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.findAll()).thenReturn(  
            Arrays.asList(new Beer(1337L, "Punk IPA", 5.6),  
                    new Beer(9531L, "Nanny State", 0.5)));  
  }  
  
  @Test  
  public void getAllNonAlcoholicBeers() {  
    List<Beer> results = service.getAllNonAlcoholicBeers();  
    assertTrue(results.size() == 1);  
  }  
}

Si l’on lance le test il s’exécute avec succès et semble donc être satisfaisant. Mais si l’on modifie le jeu de données :

@Before  
public void setupMocks() {  
  when(repository.findAll()).thenReturn(  
            Arrays.asList(new Beer(1337L, "Punk IPA", 5.6),  
                   new Beer(7052L, "Hardcore IPA", 9.2)));  
}

Et que l’on relance le test, on se rend compte que le message d’erreur n’est pas très utile :

java.lang.AssertionError  
 at org.junit.Assert.fail(Assert.java:86)  
 at org.junit.Assert.assertTrue(Assert.java:41)  
 at org.junit.Assert.assertTrue(Assert.java:52)  
 at com.github.jbleduigou.beer.service.BeerServiceTest.getAllNonAlcoholicBeers(BeerServiceTest.java:45)

La première amélioration possible serait de remplacer le assertTrue par assertEquals :

@Test  
public void getAllNonAlcoholicBeers() {  
  List<Beer> results = service.getAllNonAlcoholicBeers();  
  assertEquals(1, results.size());  
}

C’est un peu mieux, mais ce n’est pas encore très parlant :

java.lang.AssertionError:   
Expected :1  
Actual   :0  
at org.junit.Assert.fail(Assert.java:88)  
 at org.junit.Assert.failNotEquals(Assert.java:834)  
 at org.junit.Assert.assertEquals(Assert.java:645)  
 at org.junit.Assert.assertEquals(Assert.java:631)  
 at com.github.jbleduigou.beer.service.BeerServiceTest.getAllNonAlcoholicBeers(BeerServiceTest.java:45)

Remplaçons assertEquals par assertThat et l’utilisation d’un matcher :

@Test  
public void getAllNonAlcoholicBeers() {  
  List<Beer> results = service.getAllNonAlcoholicBeers();  
  assertThat(results, hasSize(1));  
}

Le message d’erreur est maintenant plus explicite :

java.lang.AssertionError:   
Expected: a collection with size <1>  
     but: collection size was <0>

at org.hamcrest.MatcherAssert.assertThat(MatcherAssert.java:20)  
 at org.junit.Assert.assertThat(Assert.java:956)  
 at org.junit.Assert.assertThat(Assert.java:923)  
 at com.github.jbleduigou.beer.service.BeerServiceTest.getAllNonAlcoholicBeers(BeerServiceTest.java:45)

Pour l’instant nous avons uniquement testé la taille de la liste mais la même logique peut-être appliquée pour tester les éléments au sein de cette liste :

@Before  
public void setupMocks() {  
    when(repository.findAll()).thenReturn(  
            Arrays.asList(new Beer(1337L, "Punk IPA", 5.6),  
                   new Beer(1430L, "Raspberry Blitz", 0.5)));  
}

@Test  
public void getAllNonAlcoholicBeers() {  
  List<Beer> results = service.getAllNonAlcoholicBeers();  
  assertTrue(results.get(0).getName().equals("Nanny State"));  
}

En utilisant le nouveau jeu de test on obtient là encore un résultat peu explicite.
Remplaçons assertTrue par assertThat et l’utilisation d’un matcher :

@Test  
public void getAllNonAlcoholicBeers() {  
  List<Beer> results = service.getAllNonAlcoholicBeers();  
  assertThat(results, hasItem(new Beer(9531L, "Nanny State", 0.5)));  
}

Le message d’erreur est maintenant bien explicite :

java.lang.AssertionError:   
Expected: a collection containing <Beer(id=9531, name=Nanny State, alcoholByVolume=0.5)>  
     but: was <Beer(id=1430, name=Raspberry Blitz, alcoholByVolume=0.5)>

at org.hamcrest.MatcherAssert.assertThat(MatcherAssert.java:20)  
 at org.junit.Assert.assertThat(Assert.java:956)  
 at org.junit.Assert.assertThat(Assert.java:923)  
 at com.github.jbleduigou.beer.service.BeerServiceTest.getAllNonAlcoholicBeers(BeerServiceTest.java:45)

Une alternative au matcher hasItem est le matcher contains. Son avantage est qu’il vérifie également la taille de la collection :

@Test  
public void getAllNonAlcoholicBeers() {  
  List<Beer> results = service.getAllNonAlcoholicBeers();  
  assertThat(results, contains(new Beer(9531L, "Nanny State", 0.5)));  
}

Son message d’erreur me semble toutefois plus difficile à lire :

java.lang.AssertionError:   
Expected: iterable containing \[<Beer(id=9531, name=Nanny State, alcoholByVolume=0.5)>\]  
     but: item 0: was <Beer(id=1430, name=Raspberry Blitz, alcoholByVolume=0.5)>

at org.hamcrest.MatcherAssert.assertThat(MatcherAssert.java:20)  
 at org.junit.Assert.assertThat(Assert.java:956)  
 at org.junit.Assert.assertThat(Assert.java:923)  
 at com.github.jbleduigou.beer.service.BeerServiceTest.getAllNonAlcoholicBeers(BeerServiceTest.java:45)

Il existe une multitude de matchers prêt à l’emploi que vous trouverez sur la Javadoc de hamcrest : http://hamcrest.org/JavaHamcrest/javadoc/1.3/org/hamcrest/Matchers.html
Sachez que vous pouvez également implémenter vos propres matchers.

Pour résumer, la conception de vos assertions va avoir un impact considérable sur votre projet. En explicitant le problème avec un message d’erreur clair vous allez permettre aux futurs intervenants du projet de comprendre rapidement la cause de ce problème.

Personnellement je respecte les règles suivantes :

  • Bannir assertTrue & assertFalse qui sont un poison
  • Utiliser assertThat avec des matchers
  • Faire échouer les tests sur ma machine afin d’analyser les messages d’erreur et les améliorer si besoin

Photo de couverture par Yaroslav Кorshikov.
Cet article a initialement été publié sur Medium.