Lorsque que l’on devient plus à l’aise avec l’utilisation de Junit il arrive un moment où l’on souhaite tester un scénario qui doit retourner une exception. Il existe différentes approches possibles pour le faire, chacune ayant ses avantages et ses inconvénients.

Un bon test d’exception c’est quoi ?

Avant de vous montrer les différentes approches possibles je souhaite tout d’abord revenir sur le besoin.

On va attendre de notre test qu’il :

  • Réussisse si le code testé renvoie la bonne exception
  • Échoue si le code testé renvoie la mauvaise exception
  • Échoue si le code testé ne renvoie pas d’exception

Le code testé

Les exemples que je vais vous présenter aujourd’hui testeront la méthode getBeerById() de la classe BeerService.

Pour rappel voici le code de cette classe :

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

L’approche brut de pomme 🍎

La solution qui vient en tête en premier consiste à catcher l’exception et à asserter cette expression.

package com.github.jbleduigou.beer.service;

@RunWith(MockitoJUnitRunner.class)
public class BeerServiceTest {
  
  @InjectMocks
  private BeerService service;

  @Mock
  private BeerRepository repository;
  
  @Test
  public void getBeerShouldThrowExceptionGivenNotFound() {
    try {
      service.getBeerById(3457L);
    catch (EntityNotFoundException e) {
      assertThat(e.getMessage(), is("Beer with id=3457 not found"));
      return;
    }
    fail("No exception thrown");
  }
}

Les trois critères que nous avions défini sont satisfaits et on peut donc dire que cette approche fonctionne.
Le seul problème à mon goût est la lourdeur de la syntaxe, il y a moyen de faire mieux.

L’approche par annotation 😒

L’autre possibilité, un poil plus élégante est d’utiliser les annotations.

package com.github.jbleduigou.beer.service;

@RunWith(MockitoJUnitRunner.class)
public class BeerServiceTest {
  
  @InjectMocks
  private BeerService service;

  @Mock
  private BeerRepository repository;
  
  @Test(expected = EntityNotFoundException.class)
  public void getBeerShouldThrowExceptionGivenNotFound() {
    service.getBeerById(3457L);
  }
}

La syntaxe est nettement plus allégée et encore une fois les trois critères sont satisfaits.

Dans le cas où ce n’est pas le bon type d’exception nous avons bien un test qui échoue :

java.lang.Exception: Unexpected exception, expected<com.github.jbleduigou.beer.exception.EntityNotFoundException> but was<java.lang.IllegalArgumentException>
at org.junit.internal.runners.statements.ExpectException.evaluate(ExpectException.java:28)
 at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:26)
 at org.junit.rules.ExpectedException$ExpectedExceptionStatement.evaluate(ExpectedException.java:239)
 at org.junit.rules.RunRules.evaluate(RunRules.java:20)

Si aucune exception n’est retournée nous avons là encore un test qui échoue :

java.lang.AssertionError: Expected exception: com.github.jbleduigou.beer.exception.EntityNotFoundException

Le principal reproche que l’on peut faire c’est qu’il n’est plus possible d’asserter sur le message de l’exception. Le message d’erreur lorsque qu’il manque une exception n’est pas non plus très explicite.

L’approche par @Rule 😃

La solution que je préfère personnellement est l’utilisation d’une Rule.
Voici à quoi cela ressemble :

package com.github.jbleduigou.beer.service;

@RunWith(MockitoJUnitRunner.class)
public class BeerServiceTest {
  
  @InjectMocks
  private BeerService service;

  @Mock
  private BeerRepository repository;
  
  @Rule
  public ExpectedException exception = ExpectedException.none();
   
  @Test
  public void getBeerShouldThrowExceptionGivenNotFound() {
    exception.expectMessage("Beer with id=3457 not found");
    exception.expect(EntityNotFoundException.class);

    service.getBeerById(3457L);

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

}

On commence par déclarer une règle de type ExpectedException.
Ensuite dans le test on précise le type d’exception attendue et/ou le message attendu.

La syntaxe est à la fois allégée et explicite.

Dans le cas où ce n’est pas le bon type d’exception nous avons bien un test qui échoue :

java.lang.AssertionError: 
Expected: (exception with message a string containing "Beer with id=3457 not found" and an instance of com.github.jbleduigou.beer.exception.EntityNotFoundException)
     but: an instance of com.github.jbleduigou.beer.exception.EntityNotFoundException <java.lang.IllegalArgumentException: Beer with id=3457 not found> is a java.lang.IllegalArgumentException

Si aucune exception n’est retournée nous avons un test qui échoue :

java.lang.AssertionError: Expected test to throw (exception with message a string containing "Beer with id=3457 not found" and an instance of com.github.jbleduigou.beer.exception.EntityNotFoundException)

Junit 5, ca ne marche plus ! 😮

Si vous êtes passé à Junit 5 vous constaterez qu’il n’est plus possible d’utiliser ni l’approche par annotation ni l’approche par rule. C’est d’ailleurs une des choses qui m’a pris le plus de temps lors de la migration à Junit 5 sur un projet récemment.

La syntaxe qui est recommandée avec cette version est l’utilisation de assertThrows. Concrètement ça ressemble à ça :

package com.github.jbleduigou.beer.service;

@ExtendWith(MockitoExtension.class)
public class BeerServiceTest {

  @InjectMocks
  private BeerService service;

  @Mock
  private BeerRepository repository;
  
  @Test
  public void getBeerShouldThrowExceptionGivenNotFound() {
    Throwable exception = assertThrows(
                       EntityNotFoundException.class, 
                       () -> service.getBeerById(3457L));
  
    assertThat(
       exception.getMessage(),
       is("Beer with id=3457 not found"));

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

Le test réussi quand l’exception est renvoyée correctement.

En cas de message d’erreur erroné c’est le assertThat qui va générer une erreur :

java.lang.AssertionError: 
Expected: is "Panic!"
     but: was "Beer with id=3457 not found"

Dans le cas où ce n’est pas le bon type d’exception s’est le assertThrows qui réagit :

org.opentest4j.AssertionFailedError: Unexpected exception type thrown ==> expected: <java.lang.IllegalArgumentException> but was: <com.github.jbleduigou.beer.exception.EntityNotFoundException>
at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:65)

Enfin si aucune exception n’est renvoyée s’est également le assertThrows qui nous prévient :

org.opentest4j.AssertionFailedError: Expected com.github.jbleduigou.beer.exception.EntityNotFoundException to be thrown, but nothing was thrown.
at org.junit.jupiter.api.AssertThrows.assertThrows(AssertThrows.java:71)

Et voilà vous en savez maintenant plus sur la gestion des exceptions avec Junit.

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

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

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