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 par when... 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 :

  • Arrange crée l’objet Account avec le solde initial.
  • Act appelle l’action unique testée (withdraw).
  • Assert vé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 :

  • @CsvSource fournit 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 :

  • @Mock crée un faux objet pour PaymentGateway — quelque chose qui appelle l’extérieur (potentiellement une API réelle).
  • @InjectMocks injecte ce mock dans le PaymentService testé.
  • verify confirme que charge a é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.

testingjunitjavabest-practicesspring-boot