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.
- 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.
Plan de l'article
- Explorer les stratégies de test en Java pour garantir la qualité logicielle
- Panorama des méthodes de test pour les applications Java
- Tests unitaires : socle de la fiabilité du code source
- L’automatisation des suites de tests dans l’écosystème Java
- Oracles de test : juger la conformité du comportement logiciel
- Meilleures pratiques pour concevoir des cas de test en Java
- Outils incontournables pour le développement orienté test
- Couverture de code : ce que le pourcentage mesure vraiment
- Ce qu’aucune stratégie de test ne garantit
- À retenir
- Questions fréquentes
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é.
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.
@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.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/javaaux sources de l’application ou de la bibliothèque,src/test/javaaux sources de test etsrc/test/resourcesaux 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 phasetest: un simplemvn testdé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
@BeforeEachet@AfterEach, qui remettent l’état à zéro entre chaque méthode. - Lisibilité : un test est aussi une documentation exécutable. L’annotation
@DisplayNamede JUnit 5 permet de donner un libellé lisible au rapport d’exécution, et@Nestedde 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.
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.
mock(), when(…).thenReturn(…), verify(…).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é.
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 #
- 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 utiliseorg.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 parassertThrows, 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 ?
Comment vérifier qu’une exception est bien levée en JUnit 5 ?
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 ?
À quoi sert un mock, et quand faut-il éviter d’en poser un ?
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 ?
- JUnit 5 — guide utilisateur (annotations, assertions, tests paramétrés, migration depuis JUnit 4)
- Mockito — code source et javadoc de MockitoExtension
- AssertJ — documentation
- Maven Surefire Plugin et disposition standard des répertoires Maven
- JaCoCo — compteurs de couverture
- PIT — test de mutation
- Selenium WebDriver — documentation





