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 architecture rule

Annotation & naming rules
Het is ook mogelijk te controleren op naamgeving van classes en het correcte gebruik van annotaties. Zoals Listing 3 waarin we controleren of de controller classes wel een @RestController-annotatie hebben.

@ArchTest
public static final ArchRule namingRule = classes()
.that()
.resideInAPackage("..api..")
.and()
.haveSimpleNameEndingWith("Controller")
.should()
.beAnnotatedWith(RestController.class);

Listing 3: Annotation rule

Overige rules
Er zijn nog meer rules voorhanden. Zie de uitgebreide beschrijving van de fluent api in de ArchUnit user guide [2] bijvoorbeeld, maar het beste is om je te laten leiden door de code completion van je IDE. Zo ontdek je het snelst de uitgebreide mogelijkheden.
Custom rules
Waar de standaard api niet expressief genoeg is, zijn er gelukkig ook nog custom rules. Zo is Listing 4 een voorbeeld van een custom rule die in de fluent api controleert of er geen classes zijn met deprecated fields.

Helaas ondersteunt ArchUnit (nog) geen lambda-style declaraties.

public static ArchCondition<JavaClass> notHaveFieldsAnnotatedWithDeprecated =
new ArchCondition<JavaClass>("should not have a field annotated with @Deprecated") {
@Override
public void check(JavaClass item, ConditionEvents events) {
//if class has a deprecated field
events.add(SimpleConditionEvent.violated(item, "should not have a field annotated with @Deprecated"));
}
};

@ArchTest
public static final ArchRule customRule = classes().should(notHaveFieldsAnnotatedWithDeprecated);

Listing 4: Custom rule

Tests als module
Je kan ArchUnit-tests ook opnemen in een los Maven artifact, zodat diverse applicaties in een project dezelfde architectuur tests kunnen draaien. Vooral in projecten met veel microservices is dat handig om consistentie te bewaken.

 

Figuur 2 laat zien hoe je dit bereikt. Je creëert een artifact nl:myarchtests dat je tests bevat. Vervolgens neem je dit artifact als test-dependency op in je applicatie. Je moet de surefire-plugin van je applicatie wel zo configureren dat de tests die in het artifact staan, meedraaien.

 

Figuur 2: ArchUnit-tests als losse module

Samengevat
ArchUnit is een krachtige open-source-library om Java-projecten te toetsen aan architecture-constraints.

De voordelen op een rij:

● Unittests, dus meteen vanuit IDE te draaien;
● Onderdeel van de code;
● Evolutie van de architecturele constraints en versionering mogelijk, via Maven artifacts.

Voor wie zelf met ArchUnit aan de slag wil, is er een voorbeeldproject beschikbaar op GitLab: https://gitlab.com/craftsmen/archunit-demo. Happy testing!

Referenties
[1] https://martinfowler.com/ieeeSoftware/whoNeedsArchitect.pdf
[2] ArchUnit: https://www.archunit.org/
[3] ArchUnit user guide: https://www.archunit.org/userguide
[4] AspectJ: https://www.eclipse.org/aspectj/
[5] CheckStyle: https://checkstyle.sourceforge.io/
[6] FindBugs: http://findbugs.sourceforge.net/
[7] JUnit 5: https://junit.org/junit5/
[8] SpotBugs: https://spotbugs.github.io/
[9] Onion model: https://www.codeguru.com/csharp/csharp/cs_misc/designtechniques/understanding-onion-architecture.html
[10] Voorbeeldproject: https://gitlab.com/craftsmen/archunit-demo

 

Over de auteur:

Michel Schudel is als software engineer van Craftsmen werkzaam bij de Rabobank. Hij deelt graag kennis over Java in blogs, workshops, en talks op conferenties.