Menu
Dossiers

Découvrez les secrets de Java: techniques de test pour une qualité logicielle infaillible

Explorer les stratégies de test en Java pour garantir la qualité logicielle #

Tester une application Java n’a rien d’un savoir caché : c’est une discipline outillée, documentée publiquement, et dont chaque brique — bibliothèque, annotation, méthode — est décrite dans une documentation officielle que n’importe qui peut lire. Ce guide fait le tour des familles de tests, des outils qui les portent, et surtout de ce qu’ils permettent réellement d’obtenir.

En bref
Une stratégie de test en Java s’appuie sur trois niveaux complémentaires : des tests unitaires qui vérifient une méthode isolée, des tests d’intégration qui vérifient les échanges entre modules, et des tests de bout en bout qui vérifient le comportement vu par l’utilisateur. Cet empilement réduit le risque de régression ; il ne fait disparaître ni les bugs, ni le besoin de relecture humaine.
  • Le socle outillé : JUnit 5 (JUnit Jupiter) pour écrire et exécuter, Mockito pour isoler les dépendances, AssertJ pour des assertions lisibles.
  • Le piège n° 1 : JUnit 4 et JUnit 5 ne partagent ni les mêmes annotations ni les mêmes paquets. Mélanger les deux produit des tests qui ne s’exécutent jamais.
  • Le piège n° 2 : la couverture de code mesure ce que vos tests exécutent, pas ce qu’ils vérifient.
  • La limite honnête : aucune combinaison de tests ne prouve l’absence de défauts dans un programme.

Panorama des méthodes de test pour les applications Java #

L’écosystème Java propose une palette de méthodes de test, chacune répondant à des besoins spécifiques. Le spectre s’étend de la vérification élémentaire d’une méthode à des contrôles pointus sur la sécurité et la tenue en charge. Ces familles ne se remplacent pas : elles se superposent, et chacune attrape des défauts que les autres laissent passer.

  • Tests unitaires : vérifient le bon fonctionnement de chaque composant isolé du code, en détectant très tôt les anomalies potentielles.
  • Tests d’intégration : s’assurent que les différents modules de l’application interagissent correctement entre eux, en détectant les ruptures de contrat entre composants.
  • Tests de performance : mesurent la capacité de l’application à répondre sous contrainte, en simulant des charges importantes ou des accès concurrents nombreux.
  • Tests de sécurité : cherchent les failles exploitables, qu’il s’agisse d’injections, de fuites de données ou d’élévations de privilèges.
  • Tests d’interface utilisateur : valident le comportement de l’application en interaction avec l’utilisateur, via l’interface graphique ou les API publiques.

Un projet n’a pas besoin des cinq familles dès le premier jour. La règle de bon sens est de commencer par les tests unitaires, qui sont les plus rapides à écrire et à exécuter, puis de remonter progressivement vers l’intégration et le bout en bout, dont le coût de maintenance est nettement plus élevé.

À lire Découvrez les Secrets des Grands Groupes pour Fusionner des Fichiers CSV et Boostez Vos Analyses de Données

Tests unitaires : socle de la fiabilité du code source #

Les tests unitaires représentent la première ligne de défense contre l’introduction de régressions. Leur mise en place demande une granularité fine : chaque test cible une méthode ou un comportement précis d’une classe. Côté outillage, JUnit 5 fournit le moteur d’exécution et les annotations de cycle de vie.

Dans JUnit 5, le guide utilisateur officiel précise que toutes les annotations principales sont situées dans le paquet org.junit.jupiter.api, au sein du module junit-jupiter-api. Voici la classe de test standard telle qu’elle figure dans ce guide :

import static org.junit.jupiter.api.Assertions.fail;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
class StandardTests {
    @BeforeAll
    static void initAll() { }
    @BeforeEach
    void init() { }
    @Test
    void succeedingTest() { }
    @Test
    @Disabled("for demonstration purposes")
    void skippedTest() {
        // not executed
    }
    @AfterEach
    void tearDown() { }
    @AfterAll
    static void tearDownAll() { }
}

Deux détails de cet exemple ne sont pas cosmétiques. D’abord, @BeforeAll et @AfterAll doivent être déclarées static, sauf si le cycle de vie « per-class » de l’instance de test est activé. Ensuite, @Disabled accepte un motif : documenter pourquoi un test est désactivé évite qu’il reste mort pendant des mois sans que personne s’en aperçoive.

Ne mélangez pas JUnit 4 et JUnit 5
C’est l’erreur la plus fréquente sur les projets qui migrent. Le chapitre de migration du guide JUnit 5 est explicite : @Before et @After n’existent plus, on utilise @BeforeEach et @AfterEach ; @BeforeClass et @AfterClass deviennent @BeforeAll et @AfterAll ; @Ignore devient @Disabled ; @Category devient @Tag ; @RunWith est remplacé par @ExtendWith ; @Rule et @ClassRule par @ExtendWith et @RegisterExtension ; enfin @Test(expected = …) et la règle ExpectedException disparaissent au profit de Assertions.assertThrows(…). Les paquets diffèrent aussi : org.junit pour JUnit 4, org.junit.jupiter.api pour JUnit 5. Un test annoté avec le mauvais @Test n’est tout simplement pas ramassé par le moteur.
▶ YouTubeCoding with John903 k vues · en anglais
Java Unit Testing with JUnit - Tutorial - How to Create And Use Unit Tests
Java Unit Testing with JUnit – Tutorial – How to Create And Use Unit Tests

L’automatisation des suites de tests dans l’écosystème Java #

Automatiser l’exécution des tests est indispensable pour maintenir la qualité dans la durée, surtout lorsque les cycles de développement sont courts. Sur un projet Maven, cette automatisation ne demande aucune configuration exotique : elle repose sur la disposition standard des répertoires et sur un plugin livré avec le cycle de build.

À lire VPN et Hadopi : Ce que les internautes français doivent vraiment savoir

  • Emplacement des sources : la disposition standard de Maven réserve src/main/java aux sources de l’application ou de la bibliothèque, src/test/java aux sources de test et src/test/resources aux ressources de test.
  • Exécution : le plugin Maven Surefire exécute les tests unitaires pendant le cycle de build. Il ne possède qu’un seul goal, surefire:test, rattaché à la phase test : un simple mvn test déclenche donc la suite complète.
  • Rapports : par défaut, les résultats sont écrits dans ${basedir}/target/surefire-reports/, en texte brut et en XML — c’est ce format XML que les serveurs d’intégration continue lisent pour afficher les échecs.
  • Intégration continue : brancher cette commande sur chaque commit garantit la reproductibilité et la traçabilité des résultats, sans dépendre de la bonne volonté d’un développeur.

L’automatisation déplace la question, elle ne la résout pas. Une suite qui s’exécute à chaque commit mais qui n’assertionne presque rien donne une fausse assurance : elle passe au vert quoi qu’il arrive. C’est exactement le problème que la section sur la couverture aborde plus bas.

Oracles de test : juger la conformité du comportement logiciel #

Définir un oracle de test robuste, c’est garantir une évaluation objective et reproductible du logiciel. L’oracle compare les résultats produits par l’application à des valeurs attendues dérivées des spécifications ou des règles métier. Concrètement, dans un test Java, cet oracle prend la forme d’assertions.

JUnit 5 regroupe les siennes dans la classe org.junit.jupiter.api.Assertions : assertEquals, assertAll, assertThrows, entre autres. La vérification d’une exception attendue s’écrit ainsi, exemple repris du guide officiel :

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> {
    throw new IllegalArgumentException("expected message");
});
assertEquals("expected message", exception.getMessage());

Noter le détail utile : assertThrows retourne l’exception levée, ce qui permet d’enchaîner sur des assertions concernant son message. Un test qui se contente de vérifier « une exception est levée » sans regarder laquelle ni pourquoi est un oracle faible.

À lire cat Linux : Maîtrisez la concaténation et la lecture de fichiers en ligne de commande

Pour des assertions plus expressives, la bibliothèque AssertJ propose une API fluide, importée via org.assertj.core.api.Assertions (artefact assertj-core) :

import static org.assertj.core.api.Assertions.*;
assertThat(frodo.getName()).isEqualTo("Frodo");
assertThat(fellowshipOfTheRing).hasSize(9)
                               .contains(frodo, sam)
                               .doesNotContain(sauron);
assertThatThrownBy(() -> { throw new Exception("boom!"); })
    .hasMessage("boom!");
  • Décision binaire : chaque assertion tranche (succès / échec) sur la conformité du résultat, sans interprétation.
  • Cas limites : un bon oracle intègre les valeurs extrêmes, les entrées nulles et les scénarios d’erreur, pas seulement le chemin nominal.
  • Traçabilité : la conservation des rapports d’exécution permet de savoir quand un comportement a changé, et non seulement qu’il a changé.

Meilleures pratiques pour concevoir des cas de test en Java #

Concevoir des cas de test efficaces repose sur l’indépendance, la lisibilité et la capacité à survivre aux évolutions du code. Quelques pratiques font consensus et se traduisent directement en fonctionnalités outillées.

  • Indépendance des tests : l’échec ou la réussite d’un test ne doit dépendre d’aucun autre, ni de leur ordre d’exécution. C’est ce que servent @BeforeEach et @AfterEach, qui remettent l’état à zéro entre chaque méthode.
  • Lisibilité : un test est aussi une documentation exécutable. L’annotation @DisplayName de JUnit 5 permet de donner un libellé lisible au rapport d’exécution, et @Nested de regrouper les cas d’une même situation.
  • Isolation des dépendances : un test unitaire ne doit pas dépendre d’une base de données ou d’un service distant — d’où les doubles de test, traités dans la section suivante.
  • Paramétrage : plutôt que de dupliquer une méthode pour chaque jeu de données, JUnit 5 fournit les tests paramétrés.

Ce dernier point mérite une précision de version : les tests paramétrés exigent d’ajouter une dépendance sur l’artefact junit-jupiter-params, et les sources d’arguments vivent dans le paquet org.junit.jupiter.params.provider. La forme la plus simple, @ValueSource, s’écrit ainsi :

@ParameterizedTest(name = "Year {0} is a leap year.")
@ValueSource(ints = { 2016, 2020, 2048 })
void if_it_is_one_of_the_following_years(int year) {
    // une exécution par valeur fournie
}

Reste l’angle mort classique : comment savoir si ces tests détectent réellement quelque chose ? C’est le rôle des tests de mutation, abordés juste après.

À lire Les secrets des pros pour une numérotation personnalisée parfaite dans LaTeX: découvrez comment ils le font!

Outils incontournables pour le développement orienté test #

L’écosystème Java se distingue par la richesse de son outillage libre, qui couvre l’écriture des scénarios, l’isolation des dépendances, la mesure de la couverture et le pilotage d’un navigateur. Voici ceux qui reviennent le plus souvent, avec leur rôle exact.

JUnit 5
Le moteur d’exécution des tests. La plateforme JUnit accueille aussi JUnit Vintage, qui exécute les anciens tests JUnit 3 et JUnit 4 — à condition que JUnit 4.12 ou une version ultérieure soit présente dans le classpath.
Mockito
Bibliothèque de doubles de test sous licence MIT, pour simuler une dépendance et vérifier les interactions avec elle. Les trois verbes de base : mock(), when(…).thenReturn(…), verify(…).
AssertJ
Un jeu d’assertions fluides et de messages d’erreur explicites, pensé pour la lisibilité du code de test et l’autocomplétion dans l’IDE.
JaCoCo
Mesure la couverture : instructions (C0), branches (C1), lignes, méthodes, classes, plus la complexité cyclomatique. À lire comme une carte des zones jamais exécutées, pas comme une note de qualité.
PIT (Pitest)
Outil de test de mutation : il injecte automatiquement des fautes dans le code puis relance vos tests. Si un test échoue, la mutation est « tuée » ; si tous passent, la mutation a « survécu » — et la ligne concernée n’était donc pas vraiment vérifiée.
Selenium WebDriver
Pilote un navigateur nativement, comme le ferait un utilisateur, en local ou sur une machine distante via le serveur Selenium. WebDriver est une recommandation du W3C.

Côté Mockito, l’intégration avec JUnit 5 passe par une extension dédiée, org.mockito.junit.jupiter.MockitoExtension, présentée dans sa propre documentation comme l’équivalent Jupiter du runner JUnit 4 MockitoJUnitRunner. L’exemple d’usage officiel :

@ExtendWith(MockitoExtension.class)
public class ExampleTest {
    @Mock
    private List<Integer> list;
    @Test
    public void shouldDoSomething() {
        list.add(100);
    }
}

Et le couple stubbing / vérification, tel qu’il figure sur le site du projet :

import static org.mockito.Mockito.*;
LinkedList mockedList = mock(LinkedList.class);
when(mockedList.get(0)).thenReturn("first");
mockedList.add("one");
verify(mockedList).add("one");
verify(mockedList, times(1)).add("once");

Couverture de code : ce que le pourcentage mesure vraiment #

C’est le point que la plupart des présentations de la qualité logicielle escamotent. Un rapport de couverture répond à une seule question : quelles portions du code ont été exécutées pendant la campagne de tests ? La définition que donne JaCoCo de son compteur de lignes est sans ambiguïté — une ligne source est considérée comme exécutée dès qu’au moins une instruction qui lui est rattachée a été exécutée.

À lire Maîtriser les playlists M3U sur VLC : Guide complet et astuces inédites

Autrement dit, un test qui appelle une méthode sans rien vérifier de son résultat fait monter la couverture exactement comme un test rigoureux. La documentation de PIT formule la limite dans les mêmes termes : la couverture traditionnelle mesure seulement quel code est exécuté par vos tests, elle ne vérifie pas que vos tests sont réellement capables de détecter des fautes dans le code exécuté.

Pourquoi cet article ne vous donne pas de pourcentage à viser
Les objectifs chiffrés du type « il faut atteindre tel pourcentage de couverture » circulent partout, généralement sans source vérifiable. Aucune des documentations officielles citées ici — JUnit, JaCoCo, PIT, Maven — ne publie de seuil recommandé. Fixer un chiffre comme objectif d’équipe a surtout un effet documenté par l’usage : on écrit des tests qui exécutent du code pour atteindre la barre, pas des tests qui vérifient des comportements. Le signal utile n’est pas le pourcentage global, c’est la liste des zones à 0 % et le taux de mutations survivantes.

Ce qu’aucune stratégie de test ne garantit #

Il faut le dire clairement, parce que le vocabulaire commercial du secteur laisse souvent entendre le contraire : aucune stratégie de test ne garantit l’absence de bugs, et il n’existe aucune technique « infaillible » en la matière. Un test vérifie que le programme se comporte comme attendu dans les cas que le test décrit. Il ne dit rien des cas que personne n’a imaginés, ni des hypothèses fausses inscrites dans la spécification elle-même.

  • Les tests valident des scénarios, pas un programme. Le nombre d’états possibles d’une application réelle est hors de portée d’une énumération exhaustive.
  • Un oracle faux valide un comportement faux. Si l’attendu encode une mauvaise règle métier, la suite reste verte pendant que l’application se trompe.
  • Les doubles de test cachent l’intégration. Un mock reproduit ce que vous croyez que la dépendance fait ; le jour où elle change de comportement, seul un test d’intégration le voit.
  • Une suite non maintenue devient un mensonge. Des tests désactivés, des assertions supprimées « pour débloquer le build » : le rapport passe au vert sans qu’aucun comportement ne soit vérifié.

Ce que les tests apportent réellement est plus modeste et plus utile : ils réduisent le risque, ils raccourcissent le délai entre l’introduction d’un défaut et sa détection, et ils rendent le refactoring possible sans jouer à la roulette. C’est déjà considérable — et cela ne demande aucun secret.

À retenir #

L’essentiel en cinq points
  • Trois niveaux complémentaires : unitaire, intégration, bout en bout. Chacun attrape ce que les autres laissent passer.
  • JUnit 5 place ses annotations dans org.junit.jupiter.api ; JUnit 4 utilise org.junit. Ne jamais mélanger les deux dans une même classe.
  • @Test(expected = …) n’existe plus en JUnit 5 : la vérification d’exception passe par assertThrows, qui retourne l’exception pour permettre d’aller plus loin.
  • La couverture mesure l’exécution, pas la qualité des assertions. Les tests de mutation, eux, mesurent la capacité réelle à détecter une faute.
  • Aucune stratégie de test ne supprime les bugs : elle réduit le risque et accélère la détection.

Questions fréquentes #

Faut-il partir sur JUnit 4 ou JUnit 5 pour un nouveau projet ?
JUnit 5 (JUnit Jupiter) est la ligne maintenue et documentée comme telle. Pour une base de code existante, il n’est pas nécessaire de tout réécrire d’un coup : la plateforme JUnit propose JUnit Vintage, un moteur qui exécute les tests JUnit 3 et JUnit 4 aux côtés des nouveaux, à condition que JUnit 4.12 ou une version ultérieure soit présente dans le classpath.
Comment vérifier qu’une exception est bien levée en JUnit 5 ?
Avec assertThrows(TypeException.class, () -> { … }), issu de org.junit.jupiter.api.Assertions. L’attribut @Test(expected = …) de JUnit 4 et la règle ExpectedException n’existent plus. Avantage de la nouvelle forme : elle retourne l’exception capturée, ce qui permet d’asserter ensuite sur son message.
Quel taux de couverture de tests faut-il viser ?
Aucune des documentations officielles des outils cités ici ne publie de seuil à atteindre, et ce guide n’en invente pas. La couverture indique quelles portions de code ont été exécutées ; elle ne dit pas si elles ont été vérifiées. Servez-vous-en pour repérer les zones jamais atteintes, et complétez par un test de mutation pour savoir si vos assertions mordent.
À quoi sert un mock, et quand faut-il éviter d’en poser un ?
Un mock remplace une dépendance pour isoler la logique que vous testez et vérifier les interactions avec elle (verify). Il est contre-productif dès que c’est justement l’intégration entre deux composants que vous voulez éprouver : à ce niveau, un test d’intégration sur les vraies implémentations dit quelque chose qu’aucun double ne peut dire.
Les tests d’interface peuvent-ils remplacer les tests unitaires ?
Non. Un test pilotant un navigateur traverse toute la pile, il est donc lent, sensible aux changements d’affichage et coûteux à maintenir. Il valide un parcours utilisateur ; il ne localise pas le défaut. Les tests unitaires restent la couche qui dit où ça casse.

Digga est édité de façon indépendante. Soutenez la rédaction en nous ajoutant dans vos favoris sur Google Actualités :

Dans la même rubrique : Outils & Tech