Unit test de qualité : nommage, structure et couverture
1. Vue d’ensemble
Un bon unit test n’est pas juste un test qui « passe ». Il doit être facile à lire, facile à maintenir, et quand il échoue, il doit vous dire immédiatement où est le problème sans avoir à déboguer pendant des heures. Cet article couvre trois piliers : nommer les tests de manière auto-descriptive, structurer les tests selon AAA (Arrange-Act-Assert), et avoir la bonne perspective sur la couverture de code pour éviter le piège du « 100% de couverture mais inutile ».
Après la lecture, vous aurez une checklist concrète pour réviser vos tests avant de créer une pull request.
2. Dépendances nécessaires
Les exemples de cet article utilisent JUnit 5 et AssertJ. Si vous travaillez sur un projet Spring Boot, spring-boot-starter-test inclut déjà les deux.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
3. Nommer les tests : le nom doit raconter une histoire
Le nom du test est la première documentation que d’autres liront quand le test échoue. Un bon nom répond à trois questions : qu’est-ce qui est testé, dans quelle condition, et quel est le résultat attendu?
3.1. Modèle methodName_condition_expectedResult
class OrderServiceTest {
@Test
void calculateTotal_whenCartIsEmpty_returnsZero() {
OrderService service = new OrderService();
Order order = new Order(List.of());
BigDecimal total = service.calculateTotal(order);
assertThat(total).isEqualTo(BigDecimal.ZERO);
}
@Test
void calculateTotal_whenCartHasDiscountedItems_appliesDiscount() {
OrderService service = new OrderService();
Order order = new Order(List.of(
new Item("SKU-1", BigDecimal.valueOf(100), 0.1)
));
BigDecimal total = service.calculateTotal(order);
assertThat(total).isEqualTo(BigDecimal.valueOf(90.0));
}
}
Explication :
- Le nom commence par la méthode testée (
calculateTotal), suivi parwhen...pour indiquer la condition, et finalement le résultat attendu. - Quand un test échoue dans le rapport CI, en lisant le nom seul, vous savez immédiatement dans quelle branche logique se trouve le problème — pas besoin d’ouvrir le code.
Remarque : évitez des noms comme
test1(),testCalculateTotal(). Ils ne disent rien sur le contexte et forcent le lecteur à entrer dans le corps du test pour comprendre ce qui est testé.
3.2. @DisplayName quand le nom de la méthode n’est pas suffisant
Avec des règles métier complexes, les noms de méthodes peuvent devenir longs et difficiles à lire. Utilisez @DisplayName pour écrire une phrase plus naturelle tout en gardant un nom de méthode court pour la navigation du code.
@Test
@DisplayName("Quand le client est VIP et la commande dépasse 1 million, appliquer une réduction supplémentaire de 5%")
void calculateTotal_vipCustomerLargeOrder_extraDiscount() {
// ...
}
Lors de l’exécution avec Maven Surefire ou l’IDE, la phrase @DisplayName s’affiche au lieu du nom de la méthode, ce qui la rend plus lisible dans le rapport.
4. Structure AAA : Arrange – Act – Assert
Chaque test doit avoir trois parties claires, dans le bon ordre, sans les mélanger. Cela aide le lecteur à parcourir rapidement : quelle ligne prépare les données, quelle ligne appelle l’action, quelle ligne vérifie le résultat.
@Test
void withdraw_whenBalanceIsSufficient_deductsAmount() {
// Arrange
Account account = new Account("ACC-001", BigDecimal.valueOf(500));
// Act
account.withdraw(BigDecimal.valueOf(200));
// Assert
assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(300));
}
Explication :
Arrangecrée l’objetAccountavec le solde initial.Actappelle l’action unique testée (withdraw).Assertvérifie le résultat en utilisant AssertJ pour des assertions aussi lisibles que de la prose.
4.1. Un test, une action
Plus un test « en fait beaucoup », plus il est difficile à déboguer quand il échoue. Évitez d’écrire des tests comme ceci :
@Test
void accountOperations() {
Account account = new Account("ACC-001", BigDecimal.valueOf(500));
account.deposit(BigDecimal.valueOf(100));
assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(600));
account.withdraw(BigDecimal.valueOf(200));
assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(400));
account.withdraw(BigDecimal.valueOf(1000)); // erreur : fonds insuffisants
assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(400));
}
Si la première assertion échoue, les deux actions suivantes ne s’exécutent pas — vous perdez des informations. Séparez en trois tests distincts : deposit_..., withdraw_sufficientBalance_..., withdraw_insufficientBalance_throwsException.
4.2. Tester les exceptions avec le bon type et le bon message
@Test
void withdraw_whenBalanceIsInsufficient_throwsIllegalStateException() {
Account account = new Account("ACC-001", BigDecimal.valueOf(100));
assertThatThrownBy(() -> account.withdraw(BigDecimal.valueOf(500)))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Insufficient balance");
}
La ligne assertThatThrownBy appelle l’action et capture l’exception en une seule étape. N’utilisez pas l’ancien style @Test(expected = ...) car il ne permet pas de vérifier le message ou l’état après l’exception.
5. Utiliser @ParameterizedTest pour réduire la duplication de code
Quand plusieurs entrées différentes testent la même logique, utilisez des tests paramétrés au lieu de copier-coller plusieurs méthodes.
@ParameterizedTest
@CsvSource({
"0, 0",
"100, 10",
"1000, 100",
"-50, 0"
})
void calculateShippingFee_variousOrderAmounts_returnsExpectedFee(
BigDecimal orderAmount, BigDecimal expectedFee) {
ShippingCalculator calculator = new ShippingCalculator();
BigDecimal fee = calculator.calculateFee(orderAmount);
assertThat(fee).isEqualTo(expectedFee);
}
Explication :
@CsvSourcefournit des paires entrée/sortie, chaque ligne étant un cas de test indépendant.- Le rapport affiche chaque cas séparément, vous savez immédiatement quel cas échoue, pas besoin de deviner.
calculateShippingFee_variousOrderAmounts_returnsExpectedFee(BigDecimal, BigDecimal)
✓ [1] 0, 0
✓ [2] 100, 10
✗ [3] 1000, 100
✓ [4] -50, 0
6. Mocker au bon niveau, ne pas en abuser
Utilisez mock pour les dépendances externes (base de données, client HTTP, autre service), pas pour l’objet que vous testez.
@ExtendWith(MockitoExtension.class)
class PaymentServiceTest {
@Mock
private PaymentGateway paymentGateway;
@InjectMocks
private PaymentService paymentService;
@Test
void processPayment_whenGatewayApproves_marksOrderAsPaid() {
Order order = new Order("ORD-01", BigDecimal.valueOf(200));
when(paymentGateway.charge(order.getAmount())).thenReturn(ApprovalResult.approved());
PaymentResult result = paymentService.processPayment(order);
assertThat(result.isSuccess()).isTrue();
verify(paymentGateway).charge(BigDecimal.valueOf(200));
}
}
Explication :
@Mockcrée un faux objet pourPaymentGateway— quelque chose qui appelle l’extérieur (potentiellement une API réelle).@InjectMocksinjecte ce mock dans lePaymentServicetesté.verifyconfirme quechargea été appelé avec les bons paramètres, pas seulement vérifie le résultat retourné.
Remarque : si vous devez mocker plus de 3-4 dépendances pour un test, c’est un signe que la classe fait trop de choses. Envisagez de diviser la classe avant de diviser le test.
7. Couverture (coverage) : une métrique de référence, pas un objectif
Une couverture élevée ne signifie pas que les tests sont bons. Par exemple, le test suivant atteint 100% de couverture de lignes pour la méthode divide mais ne détecte pas un bug :
public int divide(int a, int b) {
return a / b;
}
@Test
void divide_returnsQuotient() {
Calculator calculator = new Calculator();
int result = calculator.divide(10, 2);
assertThat(result).isEqualTo(5);
}
Ce test exécute toutes les lignes du code (100% de couverture) mais ne teste pas le cas b = 0 — où une ArithmeticException serait levée en production. La couverture mesure uniquement quelles lignes ont été exécutées, pas si elles ont été testées correctement.
7.1. Utiliser la couverture pour trouver les zones aveugles, pas pour faire un beau rapport
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
</executions>
</plugin>
Exécutez mvn test, JaCoCo génère un rapport HTML à target/site/jacoco/index.html. Utilisez ce rapport pour répondre à la question « quelle branche n’a pas encore été couverte par les tests », puis demandez-vous « cette branche contient-elle une logique importante » — ne suivez pas aveuglément le chiffre % absolu.
Element Missed Instructions Cov.
Calculator 12 of 40 70%
divide() 4 of 8 50% ← cas b=0 non testé
7.2. Les cas limites (edge cases) sont plus importants que le pourcentage
Complétez le test pour le cas manquant ci-dessus :
@Test
void divide_whenDivisorIsZero_throwsArithmeticException() {
Calculator calculator = new Calculator();
assertThatThrownBy(() -> calculator.divide(10, 0))
.isInstanceOf(ArithmeticException.class);
}
Cette seule ligne de test vaut bien plus que d’écrire 5 tests supplémentaires qui répètent des cas normaux juste pour augmenter le pourcentage de couverture.
8. Checklist d’auto-révision des tests
Avant de créer une pull request, vérifiez vous-même :
- Le nom du test énonce-t-il clairement la condition et le résultat attendu, ou est-ce juste
test1,testX? - Chaque test a-t-il exactement une action et une raison d’échouer ?
- Y a-t-il des cas limites : null, empty, 0, valeurs négatives, limites supérieures ?
- Y a-t-il des tests de cas d’erreur (exception), ou seulement le chemin heureux ?
- Où la couverture est-elle faible — est-ce une logique importante manquée ou juste des getter/setter qui n’ont pas besoin de tests ?
9. Conclusion
Un bon unit test est un test qui, quand il échoue, vous dit immédiatement où se trouve le problème sans avoir à déboguer. Nommer selon le modèle method_condition_expected, maintenir une structure AAA claire avec une action par test, et utiliser la couverture comme outil pour trouver les zones aveugles plutôt que comme objectif — ce sont trois principes simples mais suffisants pour élever la qualité de votre suite de tests d’un cran. La partie la plus difficile n’est pas d’écrire des assertions, mais de prendre le temps de réfléchir aux cas limites que votre code pourrait rencontrer en production.