Skip to Content
04 TestingTests d'Architecture avec ArchUnit

Tests d’Architecture avec ArchUnit

Les tests d’architecture garantissent automatiquement le respect de l’architecture hexagonale.

Sans tests automatisés, rien n’empêche un développeur de violer l’architecture. ArchUnit est la solution !


Pourquoi des Tests d’Architecture ?

Le Problème Sans Tests

Sans tests automatisés, rien n’empêche un développeur de faire ça :

// ❌ VIOLATION : Le domain dépend de l'infrastructure package org.smoka.domain.model; import org.smoka.infrastructure.adapter.TrendRepositoryAdapter; // ❌ INTERDIT ! public class TrendResult { private TrendRepositoryAdapter adapter; // ❌ Le domain ne doit pas connaître les adapters }

Ou ça :

// ❌ VIOLATION : L'application dépend de l'infrastructure package org.smoka.application.usecase; import org.smoka.infrastructure.adapter.TrendRepositoryAdapter; // ❌ INTERDIT ! public class GetTrendsUseCase { private TrendRepositoryAdapter adapter; // ❌ Doit dépendre de l'interface TrendRepository }

Conséquences :

  • ❌ Architecture compromise
  • ❌ Couplage fort
  • ❌ Impossible de changer d’implémentation
  • ❌ Tests difficiles

Installation

Ajouter la dépendance Maven

Ajoutez ArchUnit dans votre pom.xml :

<dependency> <groupId>com.tngtech.archunit</groupId> <artifactId>archunit-junit5</artifactId> <version>1.3.0</version> <scope>test</scope> </dependency>

Créer le fichier de test

Créez le fichier suivant :

Configurer le setup

@BeforeAll static void setup() { // Importe toutes les classes du projet classes = new ClassFileImporter() .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) .withImportOption(location -> !location.contains("$")) // Exclure classes générées .importPackages("org.smoka"); }

Important :

  • DO_NOT_INCLUDE_TESTS : Exclut les classes de test
  • !location.contains("$") : Exclut les classes générées par Micronaut, Lombok et le compilateur

Règles d’Architecture

1. Règles de Dépendances entre Couches

Le DOMAIN ne dépend de RIEN

@Test void domainShouldNotDependOnAnything() { noClasses() .that().resideInAPackage("..domain..") .should().dependOnClassesThat().resideInAnyPackage( "..application..", "..infrastructure.." ) .check(classes); }

Vérifie que :

  • domain/model/TrendQuery.java ne dépend PAS de application/
  • domain/model/TrendResult.java ne dépend PAS de infrastructure/
  • domain/port/output/TrendRepository.java ne dépend PAS de frameworks

Autorisé :

  • java.* (Collections, LocalDateTime, Optional, etc.)
  • lombok.* (annotations pour réduire le boilerplate)
  • ✅ Autres classes du domain/

Interdit :

  • io.micronaut.*
  • jakarta.persistence.*
  • application.*
  • infrastructure.*

2. Règles des Ports de Sortie

Les Ports de Sortie sont des INTERFACES

@Test void outputPortsShouldBeInterfacesInDomain() { classes() .that().resideInAPackage("..domain.port.output..") .should().beInterfaces() .check(classes); }

Vérifie que :

  • TrendRepository.java est bien une interface
  • Pas de classes concrètes dans domain/port/output/

Les ports de sortie sont des contrats que l’infrastructure doit respecter.


3. Règles des Adaptateurs

Les Adaptateurs Implémentent des Ports

@Test void adaptersShouldImplementDomainPorts() { classes() .that().resideInAPackage("..infrastructure.adapter..") .and().haveSimpleNameEndingWith("Adapter") .should().dependOnClassesThat().resideInAPackage("..domain.port.output..") .check(classes); }

Vérifie que :

  • MockTrendRepositoryAdapter dépend de TrendRepository (interface du domain) ✅
  • Si un adapter n’implémente aucun port, le test échouera ❌

On utilise dependOnClassesThat() au lieu de implement() car c’est plus flexible et fonctionne avec l’API ArchUnit.


4. Règles des Use Cases

Les Use Cases sont dans le Bon Package

@Test void useCasesShouldResideInCorrectPackage() { classes() .that().haveSimpleNameEndingWith("UseCase") .should().resideInAPackage("..application.usecase..") .check(classes); }

Convention de nommage :

  • GetTrendsUseCase dans application.usecase/
  • TrendService dans application.service/

5. Règles du Domain

Le Domain est PUR (pas d’annotations framework)

@Test void domainModelsShouldNotHaveFrameworkAnnotations() { noClasses() .that().resideInAPackage("..domain.model..") .should().dependOnClassesThat().resideInAnyPackage( "io.micronaut..", "jakarta.persistence..", "org.springframework.." ) .check(classes); }

Vérifie que :

  • ❌ Pas de @Entity JPA
  • ❌ Pas de @Singleton Micronaut
  • ❌ Pas de @Component Spring
  • ✅ Seulement @Data, @Builder Lombok (acceptable car annotations de compilation)

6. Règles des Controllers

Les Controllers ne Contiennent PAS de Logique Métier

@Test void controllersShouldNotContainBusinessLogic() { noClasses() .that().resideInAPackage("..application.port.input..") .and().haveSimpleNameEndingWith("Controller") // Seulement les *Controller .should().dependOnClassesThat().resideInAPackage("..domain.model..") .check(classes); }

Principe : Les controllers délèguent aux use cases, ils ne manipulent pas le domain directement.

Note importante : Cette règle s’applique uniquement aux classes *Controller. Les DTOs peuvent (et doivent) dépendre du domain pour faire le mapping.


Lancer les Tests

Via Maven

mvn test -Dtest=HexagonalArchitectureTest

Ou dans votre cycle de build complet :

mvn clean test

Cas d’Usage Réels

Cas 1 : Quelqu’un ajoute une dépendance JPA dans le Domain

Code violant l’architecture :

// ❌ domain/model/TrendResult.java import jakarta.persistence.Entity; import jakarta.persistence.Id; @Entity // ❌ VIOLATION ! public class TrendResult { @Id private Long id; }

Test qui échoue :

domainModelsShouldNotHaveFrameworkAnnotations FAILED Class <TrendResult> depends on <jakarta.persistence.Entity>

Solution :

// ✅ domain/model/TrendResult.java @Value @Builder public class TrendResult { Long id; // Pas d'annotation JPA }

Intégration CI/CD

GitHub Actions

name: Architecture Tests on: [push, pull_request] jobs: architecture-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-java@v3 with: java-version: '21' distribution: 'temurin' - name: Run Architecture Tests run: mvn test -Dtest=HexagonalArchitectureTest

Résultat : Impossible de merger si l’architecture est violée ! 🚫


Ajouter une Nouvelle Règle

Exemple : Interdire l’utilisation de System.out.println

@Test @DisplayName("Ne pas utiliser System.out.println (utiliser un logger)") void shouldNotUseSystemOutPrintln() { ArchRule rule = noClasses() .should().callMethod(System.class, "out") .because("Utiliser un logger (SLF4J) au lieu de System.out.println"); rule.check(classes); }

Cette règle force les développeurs à utiliser un logger au lieu de System.out.println.


Résumé

ArchUnit garantit automatiquement que votre architecture hexagonale est respectée !

RègleVérifieEmpêche
domainShouldNotDependOnAnythingDomain indépendantCouplage au framework
applicationShouldNotDependOnInfrastructureInversion de dépendancesCouplage fort
layeredArchitectureShouldBeRespectedArchitecture en couchesViolations de flux
outputPortsShouldBeInterfacesPorts = contratsImplémentations dans le domain
adaptersShouldImplementDomainPortsAdaptateurs dépendent des portsAdaptateurs isolés
domainModelsShouldNotHaveFrameworkAnnotationsDomain purCouplage JPA/Micronaut
controllersShouldNotContainBusinessLogicSéparation des responsabilitésLogique dans controllers

Notes importantes :

  • ✅ Les DTOs peuvent dépendre du domain pour le mapping
  • ✅ Les classes générées (Micronaut $Definition, Lombok) sont exclues automatiquement
  • ✅ Les dépendances vers java.*, lombok.* sont ignorées dans les règles de couches

Checklist

Avant de commit :

  • mvn test -Dtest=HexagonalArchitectureTest passe ✅
  • Le domain ne dépend de rien
  • L’application ne dépend pas de l’infrastructure
  • Les adaptateurs implémentent des interfaces du domain
  • Les controllers ne contiennent pas de logique métier
  • Les DTOs sont dans le bon package

Prochaines étapes

Maintenant que votre architecture est protégée, explorez comment tester votre code efficacement.


Ressources

Last updated on