Tests unitaires ou tests d’intégration ?

Si vous vous intéressez quelque peu à la qualité du code, vous avez pris l’habitude de le tester et vous vous êtes forcément confrontés à ce dilemme : test unitaire ou un test d’intégration ?

Quelle différence ?

Pour commencer, rappelons la différence entre les deux types de tests. C’est essentiellement fonction de ce qu’on appelle l’isolation.

Tests d’intégration

Les tests d’intégration testent le fonctionnement global d’une méthode qui interagit avec d’autres composants. On place le système dans un état donné, on applique des entrées et on vérifie que les sorties sont bien celles attendues : c’est un test en boîte noire.
Par exemple, vous avez une méthode comme suit en Java :

public Pony createPony( String name )
{
  Pony newPony = ponyFarm.getNewPony( name );
  ponyRepository.save( newPony );
  return newPony;
}

Le test d’intégration consistera à :

  • appeler la méthode ;
  • vérifier que la valeur de retour est conforme ;
  • vérifier que le poney a bien été persisté en base avec les bonnes informations.

On teste donc la méthode createPony mais aussi sa collaboration avec les autres composants :

  • l’appel à ponyFarm.getNewPony(...) ;
  • le fonctionnement de la création dans le ponyRepository ;
  • le backend en-dessous du ponyRepository, par exemple du MongoDB ou du MySQL.

C’est le fait de tester la collaboration avec les autres composants qui caractérise les tests d’intégration.

Tests unitaires

Un test est dit unitaire à partir du moment où il est réalisé en isolation complète par rapport aux autres composants. Cela signifie que vous allez simuler (ou « mocker ») tous les appels de la méthode testée vers des composants externes, éventuellement à l’aide d’une librairie. Par exemple, en Java, l’excellent Mockito permet d’écrire des choses du type :

when( ponyFarm.getNewPony( "Jean-Jacques" )).thenReturn( testPony );
when( ponyRepository.save( any( Pony.class ))).thenReturn( true ); // (inutile ici)

Ainsi, le test consistera toujours à appeler la méthode mais, cette fois-ci, les appels à ponyFarm & ponyRepository sont complètement simulés : on part du principe qu’ils sont testés par ailleurs et on se concentre sur la vérification du fonctionnement de notre méthode createPony.

Avantages et inconvénients

Fiabilité

Clairement, les tests d’intégration sont plus fiables : on teste le système dans son ensemble et dans un état se rapprochant du cas réel, avec un vrai backend, un chargement de contexte Spring, un vrai serveur front web, etc.
Les tests unitaires, à cause des mocks systématiques, sont susceptibles d’éluder tout un tas de cas que votre système devra gérer en production.

Vitesse d’exécution

C’est à mon sens l’intérêt majeur des tests unitaires : ils sont quasi instantanés, là où les tests d’intégration durent au minimum plusieurs secondes par test et donc plusieurs minutes au total. Cette rapidité permet aux tests unitaires de pouvoir être exécutés très souvent (sur chaque commit, sur chaque compilation ou même sur chaque enregistrement de fichier) donnant ainsi un feedback immédiat au développeur. Plus vous constatez rapidement que vous avez cassé quelque chose et plus il est facile de le réparer. Travailler ainsi est sécurisant et très agréable.

Simplicité

La plupart du temps, les tests d’intégration sont plus complexes à écrire. Ils nécessitent souvent de mettre votre système dans un état initial particulier (typiquement de créer tout un tas d’objets en base de données) voire de nettoyer les modifications apportées au système lors du test (en supprimant les insertions faites en base). Ce qui est généralement assez fastidieux.

Les tests unitaires peuvent parfois être difficiles à écrire s’il y a beaucoup de mocks à définir. Si c’est le cas, il est possible que vous soyez en train de tester une méthode qui fait trop de choses : un refactoring serait probablement souhaitable.

S’il faut considérer la simplicité, ça n’est pas uniquement parce que nous, développeurs, sommes flemmards :D. C’est aussi car les évolutions de votre produit imposeront parfois de modifier les tests existants. Dès lors, simplicité = maintenabilité.

Comment choisir ?

À mon sens, il convient de se rappeler que par nature, tout test est un compromis : le test parfait (fiable, rapide et simple) n’existe pas. Même le plus complet des tests d’intégration fait des hypothèses qui ne protègent pas à 100% d’erreurs inattendues. Même le plus simple des tests unitaires prend du temps à écrire et à s’exécuter.

Dès lors, il faut identifier quel type de test vous permet le meilleur compromis. Je vous propose de vous baser sur quelque chose de simple : quel est le degré d’interaction de votre méthode avec le reste du monde ? Il y a deux profils types de méthodes.

Méthodes orientées intégration de composants

Il est assez courant d’écrire des méthodes qui consistent essentiellement à déléguer le traitement à d’autres composants. Reprenons notre exemple ci-dessus en Java :

public Pony createPony( String name )
{
  Pony newPony = ponyFarm.getNewPony( name );
  ponyRepository.save( newPony );
  return newPony;
}

Sur trois lignes, deux sont des appels à des composants externes. À partir de là, le test d’intégration est préférable car la valeur ajoutée de la méthode est précisément dans l’intégration de différents composants.
Tester unitairement une telle méthode n’a aucun intérêt : la quasi totalité de son exécution sera simulée. Pire, on testera non pas alors ce que fait la méthode mais comment elle le fait et ça, c’est mâââl. Le test est censé vérifier que la méthode respecte un contrat, peu importe comment. De plus, si jamais vous êtes amenés à refactorer votre méthode en appelant d’autres composants, vous devrez réécrire l’intégralité du test. En résumé, un test unitaire serait ici peu maintenable et peu fiable.

Méthodes orientées algorithme

J’entends par « méthode orientée algorithme » toute méthode dont la valeur ajoutée est dans son algorithme. Elle ne fait pas appel à beaucoup de composants externes mais traite de nombreux cas via de multiples conditions (if ou switch), des boucles, des appels à des services de bas niveau (traitement de chaînes, opération mathématiques, etc.), des appels récursifs. Typiquement, le code d’un parser ou encore les parcours d’arbres. Voici un bout de code (simplifié pour l’exemple) de parser XML :

public void parseNodes( XMLReader reader, Map<String, String> objectAttributes, boolean parentHasMoreChildren, String parentNodeName )
{
  // this node is not a leaf
  if ( parentHasMoreChildren )
  {
    // parsing all children for this level
    while ( reader.hasMoreChildren() )
    {
       ...
       this.parseNodes( reader, objectAttributes, this.hasMoreChildren(), this.buildNodeName( parentNodeName, nodeName ) );
    }
  }
  // there is a leaf to read
  else
  {
    objectAttributes.put( parentNodeName, reader.getValue() );
  }
}

Écrire un test d’intégration ici n’a aucun sens : le fonctionnement de la méthode ne dépend en rien de l’état de la base de données ou de quelque autre composant que ce soit. C’est un cas parfait pour un test unitaire qui sera aussi fiable qu’un test d’intégration tout en étant plus rapide, plus simple et plus maintenable.

En pratique

Les deux exemples ci-dessus sont quelque peu extrêmes : généralement, une méthode comporte une part d’algorithmie pure et une part de collaboration avec d’autres composants. Bien entendu, il est tout à fait possible de combiner les deux types de tests. Par exemple, nous avons pour habitude chez Alinto de vérifier les paramètres d’entrée d’une méthode avant tout autre chose :

public void testedMethod(String param1)
{
  if ( param1 == null || param1.isEmpty() )
  {
    throw new WrongParametersException("param1 should not be null nor empty");
  }
  DomainObject obj = externalService.doSomething(param1);
  repository.save(obj);
  ...  
}

Il est alors pertinent de tester la vérification du paramètre de manière unitaire pour couvrir facilement tous les cas d’erreur. Le cas nominal (l’appel à externalService et l’ajout au repository) sera de préférence testé en intégration. Veillez à ne pas recouper les cas de tests parmi les deux types : c’est inutile et cela menace la maintenabilité.

Enfin, il est également possible de réduire considérablement la complexité des tests d’intégration en mockant une partie des composants. C’est certes une concession sur la fiabilité mais cela peut s’avérer parfois rentable.
Reprenons notre exemple précédent et supposons que l’appel à externalService nécessite, pour réussir, de réaliser une vingtaine d’insertions en base avant le test : ce dernier serait donc très pénible à écrire.
Il peut alors être intelligent de mocker l’appel à externalService et lui faire retourner un objet créé très simplement :

public class ExternalServiceMock extends ExternalService 
{
  ...
  public DomainObject doSomething(param1) 
  {
    return new DomainObject( param1, "otherValue" );
  }
  ...
}

Il ne reste plus qu’à injecter le mock dans votre test. En Java, cela va souvent être réalisé via Spring en surchargeant la définition du bean ExternalService existant ou en utilisant le bon @Qualifier.

Astuce : il est également possible de faire appel à Mockito pour créer votre mock ou un « spy », lequel permet de ne simuler qu’une partie des méthodes publiques d’une classe. Cela peut vous permettre d’économiser l’écriture de ExternalServiceMock. Voici un exemple :

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations = "classpath:/spring-test-contesxt.xml")
public class MyTestClass
{
  @Autowired
  @Qualifier("serviceBeanName")
  @Spy
  private ExternalService externalService;

  @Before
  public void initTest()
  {
    MockitoAnnotations.initMocks( this );	
    Mockito.doReturn( new DomainObject( param1, "otherValue" ) ).when( this.externalService ).doSomething( anyString() );
  }
  ...
}

En définitive, les deux types de tests ont leur intérêt en fonction des cas. C’est au développeur d’être suffisamment malin pour déterminer la meilleure solution. Souvenez-vous, quoi qu’il arrive, que les tests sont censés rester simples. S’ils ne le sont pas, il y a probablement une meilleure façon d’organiser votre code ou de le tester.

Une réflexion sur “ Tests unitaires ou tests d’intégration ? ”

  1. Hosting sur

    L’ integration continue est la fusion des tests unitaires et des tests d’integration, car le programmeur detient toute l’application sur son poste et peut donc faire de l’integration tout au long de son developpement.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *