Java · Tests

TDD en Java : faire évoluer le cycle d’une commande

Voir Red–Green–Refactor sur une commande Java/JUnit puis distinguer tests unitaires, intégration, doubles et couverture.

Un test écrit après coup peut documenter un comportement existant et clarifier son contrat. Pour concevoir ici un nouveau comportement Java, commencez par le résultat attendu avant son implémentation. Avec le développement piloté par les tests (TDD), le travail commence par un exemple observable, suivi d’un échec comportemental, du minimum de code pour le faire passer puis d’une amélioration de la conception sous la protection du test.

L’exemple suit une commande fictive. Les extraits Java et JUnit illustrent le cycle du test; certains imports et types auxiliaires sont omis. Ces fragments ne constituent pas un projet autonome : intégrez-les à un projet configuré pour JUnit Jupiter avec ses dépendances et son moteur avant de lancer les tests.

La référence JUnit 6.1.3 citée dans cet article exige un environnement d’exécution Java 17 ou supérieur, selon sa section « Supported Java Versions » (guide JUnit).

Partir d’un comportement vérifiable

Le scénario reçoit des lignes, crée une commande à l’état DRAFT, la place après validation, l’enregistre puis retourne son identifiant. Une ligne porte un SKU et une quantité. La quantité doit être positive; une commande vide ne peut pas passer à PLACED.

Le contrat d’entrée est une liste de lignes demandées et l’identifiant vient d’un fournisseur injecté. Ce dernier choix rend le test reproductible sans figer dans le scénario la façon dont l’application produit les identifiants.

class PlaceOrderTest {
    @Test
    void enregistreLaCommandePlaceeEtRetourneSonIdentifiant() {
        var repository = new InMemoryOrderRepository();
        var id = new OrderId("order-42");
        PlaceOrder useCase = new PlaceOrderService(repository, () -> id);
        var command = new PlaceOrderCommand(
            List.of(new OrderLineInput("BOOK-7", 2))
        );

        var result = useCase.handle(command);

        assertEquals(id, result.orderId());
        assertEquals(1, repository.saved().size());
        assertEquals(id, repository.saved().get(0).id());
        assertEquals(OrderStatus.PLACED, repository.saved().get(0).status());
    }
}

Le faux dépôt mémoire conserve l’état afin que le test vérifie le résultat durable demandé au port et pas seulement un appel interne. L’interface utilisée par le scénario est PlaceOrder; l’implémentation de production s’appelle PlaceOrderService.

Red : distinguer compilation et échec comportemental

Écrire ce test avant les types peut d’abord produire une erreur de compilation. Elle signale une API absente mais ce n’est pas encore le « red » comportemental de la boucle TDD. Après avoir ajouté les types et un squelette compilable, on peut obtenir un test exécutable qui échoue sur le comportement attendu.

Par exemple, le squelette suivant retourne l’identifiant mais n’enregistre pas la commande :

final class PlaceOrderService implements PlaceOrder {
    private final Supplier<OrderId> ids;

    PlaceOrderService(OrderRepository repository, Supplier<OrderId> ids) {
        this.ids = ids;
    }

    @Override
    public PlaceOrderResult handle(PlaceOrderCommand command) {
        return new PlaceOrderResult(ids.get());
    }
}

Dans l’état conceptuel de cette boucle, le test compile puis échoue sur le nombre de commandes sauvegardées.

Green : ajouter le minimum au cas d’usage

La première implémentation utile crée la commande, applique sa transition métier, demande sa sauvegarde et retourne son identifiant :

interface PlaceOrder {
    PlaceOrderResult handle(PlaceOrderCommand command);
}

interface OrderRepository {
    void save(Order order);
}

record OrderId(String value) {}
record OrderLineInput(String sku, int quantity) {}
record PlaceOrderCommand(List<OrderLineInput> lines) {}
record PlaceOrderResult(OrderId orderId) {}

enum OrderStatus { DRAFT, PLACED }

final class PlaceOrderService implements PlaceOrder {
    private final OrderRepository repository;
    private final Supplier<OrderId> ids;

    PlaceOrderService(OrderRepository repository, Supplier<OrderId> ids) {
        this.repository = repository;
        this.ids = ids;
    }

    @Override
    public PlaceOrderResult handle(PlaceOrderCommand command) {
        var lines = command.lines().stream()
            .map(line -> new OrderLine(line.sku(), line.quantity()))
            .toList();
        var order = Order.create(ids.get(), lines);
        order.place();
        repository.save(order);
        return new PlaceOrderResult(order.id());
    }
}

Order.create(id, lines) produit un agrégat en état DRAFT. place() vérifie qu’il contient au moins une ligne puis le fait passer à PLACED. Le service ne persiste qu’après cette transition réussie.

Voici le modèle minimal nécessaire à ce comportement. À ce stade, la ligne n’a pas encore sa règle de validation de quantité :

record OrderLine(String sku, int quantity) {}

final class Order {
    private final OrderId id;
    private final List<OrderLine> lines;
    private OrderStatus status;

    private Order(OrderId id, List<OrderLine> lines) {
        this.id = id;
        this.lines = List.copyOf(lines);
        this.status = OrderStatus.DRAFT;
    }

    static Order create(OrderId id, List<OrderLine> lines) {
        return new Order(id, lines);
    }

    void place() {
        if (lines.isEmpty()) {
            throw new IllegalStateException("Une commande vide ne peut pas être placée");
        }
        status = OrderStatus.PLACED;
    }

    OrderId id() { return id; }
    List<OrderLine> lines() { return lines; }
    OrderStatus status() { return status; }
}

Le faux dépôt utilisé dans le test conserve l’agrégat sauvegardé :

final class InMemoryOrderRepository implements OrderRepository {
    private final List<Order> saved = new ArrayList<>();

    @Override
    public void save(Order order) {
        saved.add(order);
    }

    List<Order> saved() {
        return List.copyOf(saved);
    }
}

Une seconde boucle ajoute la règle locale de la ligne. Le test suivant échouerait d’abord sur l’absence d’exception, puisque le modèle minimal accepte encore la quantité nulle :

@Test
void refuseUneQuantiteNulleSansEnregistrerLaCommande() {
    var repository = new InMemoryOrderRepository();
    PlaceOrder useCase = new PlaceOrderService(
        repository, () -> new OrderId("order-42")
    );
    var command = new PlaceOrderCommand(
        List.of(new OrderLineInput("BOOK-7", 0))
    );

    assertThrows(IllegalArgumentException.class, () -> useCase.handle(command));
    assertTrue(repository.saved().isEmpty());
}

La modification minimale place la validation sur OrderLine, qui protège ses propres données :

record OrderLine(String sku, int quantity) {
    OrderLine {
        if (sku == null || sku.isBlank()) {
            throw new IllegalArgumentException("Le SKU est obligatoire");
        }
        if (quantity <= 0) {
            throw new IllegalArgumentException("La quantité doit être positive");
        }
    }
}

Order.place() protège, de son côté, l’invariant de transition de l’agrégat.

Refactor : améliorer la forme en gardant le contrat

Une fois le comportement vert, la refactorisation peut extraire une fabrique, renommer une méthode ou clarifier l’immuabilité des collections. Le test métier doit continuer à décrire le même résultat sans se lier à des détails privés.

Kent Beck décrit la progression par scénario concret, test exécutable, code minimal puis refactorisation dans son « Canon TDD ». Il présente TDD comme un workflow de programmation, pas comme une obligation pour chaque tâche et distingue la mise au vert de la refactorisation (source primaire de Beck, 2023).

Tests unitaires, tests d’adaptateurs et intégration

Le test du cas d’usage utilise un dépôt mémoire. Il couvre les règles de l’exemple sans charger Spring ou une base. Il ne vérifie pas pour autant que l’adaptateur JPA convertit correctement l’agrégat, ni que le contrôleur traduit le JSON en commande.

  • Tests unitaires : règles d’Order et orchestration de PlaceOrderService avec des collaborateurs contrôlés.
  • Tests d’adaptateurs : mapping du contrôleur HTTP, conversion vers et depuis la représentation JPA, contraintes et requêtes de persistance.
  • Tests d’intégration : assemblage ciblé du cas d’usage, de ses adaptateurs et du contexte Spring lorsque le risque porte sur leur câblage ou leur collaboration.

Spring Boot documente les tranches @WebMvcTest et @DataJpaTest, ainsi que MockMvc pour tester le MVC sans serveur HTTP réel. La documentation indique des outils et périmètres de test; la version employée et la configuration de la base restent à vérifier dans l’application concernée (documentation Spring Boot). Spring Framework décrit également les tests unitaires et d’intégration et son support d’une démarche TDD (documentation Spring Framework).

Doubles de test : état ou interactions ?

Le dépôt mémoire de l’exemple est un fake : une implémentation simplifiée qui stocke les commandes pendant le test. Un stub fournit des réponses préparées. Un mock enregistre les interactions attendues. Martin Fowler reprend cette distinction et oppose les vérifications d’état aux vérifications de comportement dans « Mocks Aren’t Stubs » (source, 2007).

Ici, l’état du dépôt permet de vérifier qu’une commande PLACED a été sauvegardée. Vérifier uniquement que save a été appelé pourrait suffire si l’interaction est elle-même le contrat, par exemple pour un effet externe. Pour une opération métier simple, l’état obtenu décrit plus directement le résultat attendu.

Les doubles n’établissent pas les propriétés de l’infrastructure remplacée. Un dépôt mémoire peut accepter ce qu’une base rejette; un mock peut être configuré pour refléter l’implémentation plutôt que le contrat. Les tests d’adaptateurs complètent alors les tests isolés.

Couverture et stratégie de tests répondent à des questions différentes

Les compteurs de couverture dépendent de l’outil. JaCoCo distingue notamment la couverture d’instructions bytecode, de branches et de lignes source (Coverage Counters). Le rejet d’une quantité nulle est déjà couvert par la seconde boucle. Examinez les risques restants : le modèle minimal ne refuse pas un second appel à place(). Décidez avec le contrat métier si cette transition répétée doit être protégée.

Choisissez les comportements, frontières et risques à vérifier, du domaine jusqu’à l’assemblage. TDD décrit une boucle de conception et de retour. Beck met en garde contre les tests ajoutés uniquement pour faire monter un pourcentage de couverture. La couverture peut aider une revue, lue avec les assertions et les risques laissés sans test.

Quand la boucle est moins directe

TDD aide quand un comportement peut se formuler par un exemple et que le retour rapide permet de clarifier une API ou une règle. Une exploration visuelle, une hypothèse très incertaine ou une dépendance externe difficile à rendre déterministe peuvent demander une phase d’exploration avant de stabiliser les tests.

Une suite verte établit que les scénarios exécutés ont satisfait leurs assertions dans leur environnement. Complétez-la par la vérification des invariants métier, de la persistance, de HTTP et du câblage réel.

Pour poursuivre

Sources techniques

  1. Canon TDDKent Beck
  2. JUnit 6.1.3 User GuideJUnit team
  3. Spring Framework TestingSpring Framework
  4. Testing Spring Boot ApplicationsSpring Boot team
  5. Mocks Aren’t StubsMartin Fowler
  6. Coverage CountersJaCoCo