Un dojo de code, c’est une séance d’entraînement délibéré : un problème bien défini, des contraintes explicites, et un débrief qui fait réfléchir. Celui-ci tourne autour d’un système de bibliothèque et d’une question fondamentale : quand on écrit des tests, est-ce qu’on teste un comportement ou une implémentation ? Les repos sont disponibles sur GitHub : black-box et white-box.
Le système — une bibliothèque en architecture hexagonale
Le domaine est volontairement simple. Trois concepts métier :
- Member — un abonné, typé
CHILD,ADULTouPREMIUM - Book — un livre, catégorisé
YOUTH,STANDARDouPREMIUM - Loan — un emprunt en cours, avec date de début, date d’échéance et date de retour optionnelle
L’architecture suit le pattern ports & adapters : le domaine expose des interfaces (MemberUseCase, BookUseCase, LoanUseCase) et des ports de sortie (MemberRepository, BookRepository, LoanRepository). L’infrastructure fournit des implémentations H2 en mémoire. Le domaine ne dépend de rien d’externe.
Des tests d’intégration (LibraryIntegrationTest) couvrent déjà les fonctionnalités de base : enregistrer un membre, ajouter un livre au catalogue, emprunter, retourner, lister les emprunts actifs. Ces tests passent au départ — c’est la base saine sur laquelle le dojo va construire.
La mission — implémenter une politique d’accès aux emprunts
Le LoanService de départ est naïf : il emprunte sans aucune vérification métier. La mission du dojo est d’implémenter trois règles dans LoanAccessPolicyTest en suivant le cycle RED → GREEN → REFACTOR :
- Règle 1 — Accès par catégorie : YOUTH est accessible à tous ; STANDARD aux ADULT et PREMIUM ; PREMIUM aux PREMIUM uniquement.
- Règle 2 — Quota d’emprunts simultanés : 2 pour CHILD, 5 pour ADULT, 10 pour PREMIUM.
- Règle 3 — Durée maximale : 21 jours à partir d’aujourd’hui, pas un de plus.
En cas de violation, une exception métier dédiée doit être levée : BookAccessDeniedException, LoanQuotaExceededException, LoanDurationExceededException. Les deux groupes reçoivent exactement la même consigne, mais avec une contrainte différente sur la façon d’écrire les tests.
Boîte noire — tester le contrat, ignorer l’intérieur
La consigne boîte noire est explicite : « Tu ne dois lire aucune classe des packages application ou infrastructure. Tu testes uniquement via les interfaces définies dans domain/port/in. »
En pratique, le setup de test monte une vraie base H2 en mémoire et instancie les services via leurs interfaces :
private MemberUseCase memberUseCase;
private BookUseCase bookUseCase;
private LoanUseCase loanUseCase;
@BeforeEach
void setUp() throws SQLException {
DatabaseConfig.initialize();
memberUseCase = new MemberService(new H2MemberRepository());
bookUseCase = new BookService(new H2BookRepository());
loanUseCase = new LoanService(...);
}
Pour tester la règle 1, on crée réellement un membre CHILD et un livre STANDARD dans la base, puis on tente l’emprunt via loanUseCase.borrow() et on vérifie l’exception. Pas de mock, pas de stub : le système entier tourne. La contrainte n’est pas technique, elle est intellectuelle — on ne regarde pas le code du service pour savoir ce qu’il fait, on déduit le comportement attendu depuis les spécifications.
C’est inconfortable au début. On ne sait pas si LoanService implémente déjà la règle ou pas. On écrit le test RED en aveugle, puis on lance mvn test pour voir. C’est exactement ça, TDD.
Boîte blanche — tester vite, avec des mocks sur les classes concrètes
La consigne boîte blanche ouvre tout : « Tu as accès à tout le code source : modèles, services, repositories. Tu peux mocker les repositories directement et instancier LoanService à la main. » Elle ajoute une note pédagogique : « Garde en tête que tu découvriras en fin de session pourquoi un trop fort couplage aux classes concrètes peut devenir un problème. »
Le setup est minimal grâce à Mockito :
@Mock private H2MemberRepository memberRepository;
@Mock private H2BookRepository bookRepository;
@Mock private H2LoanRepository loanRepository;
@BeforeEach
void setUp() {
loanService = new LoanService(loanRepository, memberRepository, bookRepository);
}
Chaque test configure exactement ce dont il a besoin. Pour la règle du quota, on stubble findActiveByMemberId pour retourner une liste de N emprunts existants, sans toucher la base :
@Test
@DisplayName("Un enfant ne peut pas dépasser 2 emprunts simultanés")
void shouldRejectLoanWhenChildReachedMaxQuota() {
List<Loan> activeLoans = List.of(
new Loan(UUID.randomUUID(), memberId, UUID.randomUUID(), LocalDate.now(), LocalDate.now().plusDays(7)),
new Loan(UUID.randomUUID(), memberId, UUID.randomUUID(), LocalDate.now(), LocalDate.now().plusDays(7))
);
when(memberRepository.findById(memberId))
.thenReturn(Optional.of(new Member(memberId, "Alice", MemberType.CHILD)));
when(bookRepository.findById(bookId))
.thenReturn(Optional.of(new Book(bookId, "Livre", "Auteur", BookCategory.YOUTH)));
when(loanRepository.findActiveByMemberId(memberId)).thenReturn(activeLoans);
assertThrows(LoanQuotaExceededException.class, () ->
loanService.borrow(memberId, bookId, LocalDate.now().plusDays(7))
);
}
Les tests sont rapides, précis, isolés. Chaque test vérifie exactement une chose, sans bruit. La couverture des cas limite est facile à atteindre.
La progression TDD — RED, GREEN, REFACTOR
Dans les deux cas, la progression suit le même rythme. On commence par écrire un premier test RED sur la règle la plus simple — un CHILD qui tente d’emprunter un livre STANDARD. Le test échoue parce que le LoanService de départ ne vérifie rien. C’est voulu.
On implémente le minimum dans LoanService.borrow() pour faire passer ce test : récupérer le membre, récupérer le livre, comparer les types, lever l’exception si nécessaire. Le test passe (GREEN). On n’en fait pas plus.
Puis on ajoute le test suivant — un ADULT qui tente un livre PREMIUM — et on étend la règle. Puis la règle de quota. Puis la durée. À chaque itération, les tests existants agissent comme un filet de sécurité : on ne peut pas casser une règle déjà implémentée sans le voir immédiatement.
La phase REFACTOR arrive naturellement quand les conditions s’accumulent dans borrow(). On peut extraire une méthode checkAccessPolicy(), ou une classe dédiée. Les tests restent verts : c’est eux qui définissent ce que le refactoring doit préserver.
Le débrief — pourquoi ça compte vraiment
C’est là que le dojo prend tout son sens. On compare les deux bases de tests face à un scénario de refactoring : on renomme H2MemberRepository en InMemoryMemberRepository, ou on extrait la politique d’accès dans un objet domaine dédié.
Les tests boîte blanche cassent immédiatement. Ils mockaient H2MemberRepository par son nom de classe — le compilateur se plaint avant même de lancer les tests. Pire : si on refactore LoanService.borrow() pour déléguer la vérification à un LoanPolicy, les mocks ne correspondent plus à la nouvelle structure. Il faut réécrire des tests qui testaient pourtant la même règle métier.
Les tests boîte noire ne bougent pas. Ils ne connaissent ni H2MemberRepository ni LoanService. Ils savent juste que loanUseCase.borrow(childId, standardBookId, dueDate) doit lever une exception. Ce contrat est stable — il est défini par la règle métier, pas par l’implémentation du moment.
La note pédagogique de la consigne boîte blanche prenait tout son sens à ce moment : « un trop fort couplage aux classes concrètes peut devenir un problème » — ce n’était pas une mise en garde abstraite, c’était une prophétie.
Ce que j’en retiens
La boîte blanche permet d’écrire des tests plus vite, avec plus de contrôle sur les cas limite. Elle est souvent plus confortable, surtout quand on débute avec TDD. Mais ce confort a un prix : les tests s’attachent à la structure interne, et chaque refactoring devient un risque de casse inutile.
La boîte noire demande un effort supplémentaire au départ — comprendre et formuler le comportement attendu sans regarder l’implémentation. Mais elle produit des tests qui durent, qui documentent les règles métier, et qui libèrent le refactoring plutôt que de le freiner.
Les sources sont sur GitHub : dmissud/black-box et dmissud/white-box. Les consignes sont dans CONSIGNE.md à la racine de chaque repo — suffisant pour animer la session avec n’importe quel groupe Java.
Laisser un commentaire