Qualité, tests et déploiement

Tester, c’est piloter la qualité : on réduit les risques, on documente le comportement du système et on rend le code fiable à long terme. Les tests servent autant à prévenir les régressions qu’à guider les décisions d’architecture (couplage, frontières, dépendances). Ils forment un filet de sécurité qui autorise le refactoring et accélère les livraisons.

À travers ce module, nous allons nous attarder sur les tests unitaires en Java et voir comment fonctionne le TDD : le cycle rouge → vert → refactor, l’écriture d’assertions claires, et l’usage d’outils comme JUnit et des doubles de test pour isoler les composants.

Unit tests

Un test unitaire correspond à une seule idée (un comportement) et porte un nom explicite ; bien conçus, les tests unitaires guident l’architecture en forçant à réduire le couplage, injecter les dépendances et isoler les effets de bord.

Qu’est-ce qu’un test unitaire ?

  • Vérifie une unité de code (fonction/méthode/classe) en isolation.
  • Il doit être rapide, déterministe et facile à lire.
  • Utile pour ocumenter l’intention : un test bien nommé explique le « contrat ».
  • Nommez clairement l’intention : calculateTotalWDiscount().

La structure d’un test unitaire se fait classiquement selon le schéma AAA (Arrange–Act–Assert), qui rend les tests lisibles, focalisés et faciles à maintenir.

AAA (Arrange–Act–Assert)

  1. Arrange : préparez les données.
  2. Act : exécutez la méthode testée.
  3. Assert : vérifiez le résultat attendu.

Il existe plusieurs frameworks de test (famille xUnit) pour structurer et automatiser les vérifications. En Java, on utilisera simplement JUnit 5 (et, au besoin, des doublures de test) pour écrire des tests unitaires clairs et rapides.

JUnit 5 : mini mémo

  • Annotations : @Test, @DisplayName, @Nested, @BeforeEach, @AfterEach
  • Assertions : assertEquals, assertThrows, assertTrue/False, assertAll
  • Paramétrés : @ParameterizedTest + @ValueSource / @CsvSource
  • Désactivation : @Disabled("raison")
1) Méthode pure : cas nominal + bords
class MathUtils {
    static int clamp(int x, int min, int max) {
        if (min > max) throw new IllegalArgumentException("min>max");
        return Math.max(min, Math.min(max, x));
    }
}
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.*;

class MathUtilsTest {
    @Test @DisplayName("Clamp borne inférieure")
    void clamp_returnsMin_whenBelowMin() {
        assertEquals(0, MathUtils.clamp(-5, 0, 10));
    }

    @Test @DisplayName("Clamp borne supérieure")
    void clamp_returnsMax_whenAboveMax() {
        assertEquals(10, MathUtils.clamp(99, 0, 10));
    }

    @Test @DisplayName("Clamp paramètre invalide")
    void clamp_throws_whenMinGreaterThanMax() {
        assertThrows(IllegalArgumentException.class, () -> MathUtils.clamp(1, 10, 0));
    }
}

2) test paramétré : couvrir plusieurs cas sans dupliquer
class StringUtils {
    // Ignore la casse et la ponctuation
    static boolean isPalindrome(String s) {
        if (s == null) return false;
        var t = s.replaceAll("\\W", "").toLowerCase();
        return new StringBuilder(t).reverse().toString().equals(t);
    }
}
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

class StringUtilsTest {

    @ParameterizedTest(name = "\"{0}\" -> {1}")
    @CsvSource({
        "kayak, true",
        "'A man, a plan, a canal: Panama', true",
        "hello, false",
        "'', true",
        "null, false"
    })
    void isPalindrome_cases(String input, boolean expected) {
        // Petit hack : convertir la chaîne "null" en null réel pour le cas de test
        String value = "null".equals(input) ? null : input;
        assertEquals(expected, StringUtils.isPalindrome(value));
    }
}

3) mock d’une dépendance (ex. service + repo)
record Account(String id, int balance) {}

interface AccountRepo {
    Account find(String id);
    void save(Account a);
}

class TransferService {
    private final AccountRepo repo;
    TransferService(AccountRepo repo) { this.repo = repo; }

    void transfer(String from, String to, int amount) {
        if (amount <= 0) throw new IllegalArgumentException("amount<=0");
        var a = repo.find(from);
        var b = repo.find(to);
        if (a == null || b == null) throw new IllegalStateException("account missing");
        if (a.balance() < amount) throw new IllegalStateException("insufficient");
        repo.save(new Account(a.id(), a.balance() - amount));
        repo.save(new Account(b.id(), b.balance() + amount));
    }
}
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

import org.junit.jupiter.api.Test;

class TransferServiceTest {

    @Test
    void transfer_moves_funds_and_persists() {
        var repo = mock(AccountRepo.class);
        when(repo.find("A")).thenReturn(new Account("A", 100));
        when(repo.find("B")).thenReturn(new Account("B", 50));

        var svc = new TransferService(repo);
        svc.transfer("A", "B", 40);

        verify(repo).save(new Account("A", 60));
        verify(repo).save(new Account("B", 90));
        verifyNoMoreInteractions(repo);
    }

    @Test
    void transfer_fails_on_insufficient_funds() {
        var repo = mock(AccountRepo.class);
        when(repo.find("A")).thenReturn(new Account("A", 10));
        when(repo.find("B")).thenReturn(new Account("B", 0));

        var svc = new TransferService(repo);
        assertThrows(IllegalStateException.class, () -> svc.transfer("A", "B", 50));
        verify(repo, never()).save(any());
    }
}

TDD : Test-Driven Development

Le TDD, ou développement piloté par les tests (Test-Driven Development), est une approche de développement logiciel où les développeurs écrivent des tests automatisés avant d’écrire le code fonctionnel. Le processus itératif se déroule en trois étapes principales : écrire d’abord un test qui échoue, puis le code minimal pour le faire passer, et enfin refactorer sans altérer le comportement.

Au-delà de “vérifier que ça marche”, TDD structure l’architecture : modules cohésifs, dépendances explicites (inversion de dépendances), coutures (seams) pour isoler les E/S et un code naturellement testable, donc maintenable.

Le cycle « rouge → vert → refactor »

  • rouge (red) : écrire un test précis qui échoue (comportement souhaité, pas l’implémentation).
  • vert (green) : implémenter le strict minimum pour faire passer ce test.
  • refactor : améliorer le design (noms, duplication, extractions) sans casser les tests.