Unit test je architectuur met ArchUnit

[Michel Schudel]Als ontwikkelaar zal je het wel herkennen: je start op een groot project dat al een tijdje draait, en je leest de architectuurdocumentatie die ooit is opgesteld in de vorm van UML-diagrammen. Vervolgens duik je de code in en je vindt, behalve in grote lijnen, weinig meer van die originele architectuur terug. Conventies en afhankelijkheden tussen componenten blijken met voeten getreden te worden, en nieuwe inzichten zijn nooit meer aan de oorspronkelijk bedachte architectuur toegevoegd.

Een evoluerende architectuur

Ralph Johnson omschrijft architectuur als het gemeenschappelijk begrip onder ontwikkelaars over het ontwerp van een systeem[1]. Omdat inzichten veranderen, evolueert de architectuur van een systeem ook. Nu bewaken we de functionaliteit van een evoluerend systeem al met automatische tests; waarom zouden we de architectuur dan ook niet met tests bewaken?  In dit artikel nemen we ArchUnit onder de loep, een open-source-library waarmee je architectural-constraints voor software-architectuur kunt vastleggen als executable code, als  unittest.

Waarom ArchUnit?

Er zijn een aantal tools, zoals CheckStyle, SpotBugs (voorheen FindBugs) en AspectJ die je in staat stellen om code-constraints vast te leggen. Deze tools hebben wel nadelen: het zijn lastig te begrijpen modellen, hanteren xml als constraintspecificatie, en kennen een vrij technische setup voor custom rules.Tests in ArchUnit daarentegen zijn gewone Java-unittests, geschreven via een fluent api. De api is gericht op gebruikelijke constraints in een architectuur, zoals layering, toegang van component A naar component B, en naming-constraints. En omdat het hier unittests betreft, draaien ze gewoon mee met een normale projectbuild. Let wel, ArchUnit beperkt zich tot de interne structuur van Java-applicaties. De tool ondersteunt bijvoorbeeld geen regels tussen JVM’s of constraints die over http/netwerk-grenzen heen gaan.

Aan de slag

ArchUnit gebruiken is een kwestie van de dependency aan je project toevoegen, zie listing 1 voor de Maven-dependency. JUnit 4 en 5 worden allebei ondersteund. In dit artikel gaan we uit van JUnit 5.

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

 Listing 1: JUnit 5 Maven dependency voor ArchUnit

Testvoorbeelden

Neem een Spring Boot-applicatie met drie packages:

  • api – bevat de REST-controllers
  • core – bevat business-logica
  • client – bevat uitgaande calls naar andere systemen

Package rules

We willen vastleggen dat de business-logica in de core-package geen afhankelijkheden heeft naar de api en client-packages. Zie figuur 1.

Figuur 1: Toegestane en verboden package-dependencies

Listing 2 laat zien hoe je zo’n constraint afdwingt in een ArchUnit-test. Je geeft de root-package van je applicatie op met de annotatie @AnalyzeClasses, en stelt vervolgens met de annotatie @ArchTest in de fluent api een regel op. Vervolgens run je de unittest met je IDE of een projectbuild. Je kunt bovendien van wildcards gebruikmaken, zodat je niet elke keer de gehele packagenaam hoeft te definiëren.

@AnalyzeClasses(packages = "nl.craftsmen.archunitdemo")
public class ArchUnitJunit5Test {
@ArchTest
public static final ArchRule packageRule = noClasses()
.that()
.resideInAPackage("..core..")
.should()
.dependOnClassesThat()
.resideInAnyPackage("..api..", "..client..");
}

 Listing 2: ArchUnit-testvoorbeeld

Wat gebeurt er als er toch een niet-toegestane package-import plaatsvindt? Dan faalt de test, met een bericht: ArchUnitJunit5Test Architecture Violation [Priority: MEDIUM] – Rule ‘no classes that reside in a package ‘..core..’ should depend on classes that reside in a package ‘..api..” was violated. Je krijgt dus heel snel feedback over overtreden regels.

 Layer rules

Je kunt packages ook groeperen in layers. Op deze manier breng je de test op een abstracter/gelaagder niveau. Zie listing 2.

@ArchTest
public static final ArchRule rule = Architectures.layeredArchitecture()
.layer("Api").definedBy("..api..")
.layer("Core").definedBy("..core..")
.layer("Client").definedBy("..client..")

.whereLayer("Api").mayNotBeAccessedByAnyLayer()
.whereLayer("Core").mayOnlyBeAccessedByLayers("Api", "Client")
.whereLayer("Client").mayNotBeAccessedByAnyLayer();

Listing 2: Layered