Eine Einführung in ArchUnit

In unserer täglichen Projektarbeit schreiben wir Unit-Tests, Integrationstests usw., um unsere Geschäftsfunktionalität zu testen. Aber was wäre, wenn wir dieselben Prinzipien anwenden könnten, um unsere Architektur zu testen?
Genau hier kommt ArchUnit ins Spiel. ArchUnit ist eine Java-Testbibliothek, mit der wir Aussagen über die Struktur, Beziehungen und Eigenschaften unseres Codes treffen können.
Durch den Einsatz dieser Bibliothek können wir Codequalität und -standards aufrechterhalten, indem wir Konventionen erstellen – entweder projektspezifisch oder projektübergreifend –, denen Entwickler folgen können, wodurch die Gesamteffizienz gesteigert wird. Eine nachlässige Einhaltung von Architekturregeln birgt das Risiko, später Bugs und Ärger zu verursachen – und genau dafür ist ArchUnit hervorragend geeignet.
Ein vollständiges, funktionierendes Beispiel findet sich hier.
Projektinstallation
Für ein typisches Projekt kann die ArchUnit-Basisabhängigkeit von MavenCentral bezogen werden.
Wenn wir in unserem Projekt auch JUnit verwenden, empfiehlt es sich, stattdessen die spezifischeren Module archunit- oder junit4archunit- zu verwenden. Sie verbessern die Laufzeit unserer Unit-Tests durch das Caching der importierten Klassen und verfügen zudem über zusätzliche Features. In den Beispielen für diesen Artikel verwende ich die Abhängigkeit junit5archunit-.junit5
Für Maven:
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>0.23.1</version>
<scope>test</scope>
</dependency>
Für Gradle:
dependencies {
testImplementation 'com.tngtech.archunit:archunit-junit5:0.23.1'
}
Einfaches Beispiel
Using the archunit-junit5 module
Wenn wir das spezifische junit5-Modul für unser Projekt verwenden, können wir mithilfe der Annotation @AnalyzeClasses etwas Boilerplate-Code einsparen. Außerdem reicht es bei Verwendung der Annotation @ArchTest, eine ArchRule für diesen Test zu deklarieren. Der Import und die Assertions gegen die Klassen werden automatisch durchgeführt.
@AnalyzeClasses(packages = ["io.cloudflight.archunit"])
class MyFirstArchTest {
@ArchTest
val services_should_only_be_accessed_by_controllers: ArchRule = classes()
.that().resideInAPackage("..service..")
.should().onlyBeAccessed().byAnyPackage("..controller..", "..service..");
}
Unter der Haube speichert ArchUnit die importierten Klassen zwischen den Tests zudem automatisch zwischen, was die Effizienz unserer Tests weiter verbessert.
Bei der Verwendung der Annotation @AnalyzeClasses gibt es mehrere Möglichkeiten, die importierten Klassen zu steuern. Standardmäßig wird der gesamte Classpath geladen und gegen unsere Tests geprüft. Wir können spezifische Pakete definieren, die wir testen möchten, wie im obigen Beispiel. Wenn wir die importierten Klassen weiter filtern müssen, könnten wir das Attribut importOptions verwenden – beispielsweise indem wir festlegen, dass wir nicht gegen die Testklassen prüfen möchten: @AnalyzeClasses(importOptions = [ImportOption.DoNotIncludeTests::class])
ArchUnit-Tests schreiben
Hier ist eine Liste der Testtypen, die wir mit dieser Bibliothek potenziell schreiben können:
- Paketabhängigkeitsprüfungen
- Klassenabhängigkeitsprüfungen
- Prüfungen der Klassen- und Paketzugehörigkeit
- Vererbungsprüfungen
- Annotationsprüfungen
- Schichtprüfungen
- Zyklusprüfungen
- Benutzerdefinierte Prüfungen
Beispiele
Verwendung der Library-API und vordefinierter Regeln
Die ArchUnit Library API bietet eine Reihe gängiger Regeln, die viele Projekte wahrscheinlich benötigen.
import com.tngtech.archunit.library.GeneralCodingRules.*
...
@ArchTest
val classes_should_not_access_standard_streams_from_library = NO_CLASSES_SHOULD_ACCESS_STANDARD_STREAMS
@ArchTest
val classes_should_not_throw_generic_exceptions = NO_CLASSES_SHOULD_THROW_GENERIC_EXCEPTIONS
@ArchTest
val classes_should_not_use_java_util_logging = NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING
Das sind nur einige Beispiele – es gibt noch mehr GeneralCodingRules.
Definieren einer geschichteten Architektur
Durch die Definition einer Reihe von Schichten und der Beziehungen zwischen ihnen kann die Bibliothek die Interaktion zwischen diesen Schichten prüfen. Eine der häufigsten Konventionen für geschichtete Architekturen ist beispielsweise, dass Repositories nur von Services aufgerufen werden sollten.
@ArchTest
val layer_dependencies_are_respected: ArchRule = layeredArchitecture()
.withOptionalLayers(true)
.layer(Layers.API)
.definedBy(Packages.API)
.layer(Layers.CONTROLLER)
.definedBy(Packages.CONTROLLER)
.layer(Layers.SERVICE)
.definedBy(Packages.SERVICE)
.layer(Layers.ENTITY)
.definedBy(Packages.ENTITY)
.layer(Layers.REPOSITORY)
.definedBy(Packages.REPOSITORY)
.whereLayer(Layers.API)
.mayOnlyBeAccessedByLayers(Layers.SERVICE)
.whereLayer(Layers.CONTROLLER)
.mayNotBeAccessedByAnyLayer()
.whereLayer(Layers.SERVICE)
.mayOnlyBeAccessedByLayers(Layers.CONTROLLER)
.whereLayer(Layers.ENTITY)
.mayOnlyBeAccessedByLayers(Layers.SERVICE, Layers.REPOSITORY)
.whereLayer(Layers.REPOSITORY)
.mayOnlyBeAccessedByLayers(Layers.SERVICE)
.`as`("Package structuring does not match the expected one.")
Dabei ist Layers.API die Definition einer Schicht in unserem Code und Packages.API die Definition eines Paket-Bezeichners (Details siehe Code unten).
object Packages {
const val API = "..api.."
const val CONTROLLER = "..controller.."
const val ENTITY = "..entity.."
const val SERVICE = "..service.."
const val REPOSITORY = "..repository.."
}
object Layers {
const val API = "Api"
const val CONTROLLER = "Controller"
const val SERVICE = "Service"
const val REPOSITORY = "Repository"
const val ENTITY = "Entity"
}
Falls wir eine Onion-/Hexagonale Architektur verwenden, bietet die Bibliothek auch dafür Unterstützung – hier nachzulesen.
Konventionen zur Paketzugehörigkeit definieren
Mithilfe der Fluent DSL der Bibliothek können wir einfach benutzerdefinierte Tests schreiben, wie das folgende Beispiel, das prüft, ob Klassen mit der Annotation @ tatsächlich einen einfachen Namen haben, der mit dem String ServiceService endet, und sich in einem Paket namens service befinden.
@ArchTest
val services_must_reside_in_service_package = classes()
.that().areAnnotatedWith("org.springframework.stereotype.Service")
.should().resideInAPackage(Packages.SERVICE)
.andShould().haveSimpleNameEndingWith(Layers.SERVICE)
.`as`("Services should reside in a package ${Packages.SERVICE}")
@ArchTest
val controllers_must_reside_in_controller_package = classes()
.that().areAnnotatedWith("org.springframework.web.bind.annotation.RestController")
.should().resideInAnyPackage(Packages.CONTROLLER)
.andShould().haveSimpleNameEndingWith(Layers.CONTROLLER)
.`as`("Controllers should reside in a package ${Packages.CONTROLLER}")
Benutzerdefinierte Konventionsprüfungen
Wenn wir uns mit unserem Team auf eine Konvention geeinigt haben, können wir auch benutzerdefinierte Prüfungen dafür schreiben. Ein einfaches Beispiel wäre die Prüfung, dass Interfaces keine @RequestMapping-Annotation haben sollten:
@ArchTest
val no_interface_should_do_request_mapping: ArchRule = noClasses()
.that().areInterfaces()
.should().beAnnotatedWith(RequestMapping::class.java)
.`as`("Implementations only should define RequestMappings.")
Fazit
Eine wichtige Erkenntnis aus ArchUnit für mich ist, dass es einfacher ist, eine Architekturentscheidung explizit durch geschriebene Unit-Tests durchzusetzen, als sich implizit an diese Konventionen zu erinnern und sie zu befolgen. Auf diese Weise verlassen wir uns nicht darauf, Wiki-Seiten zu lesen, um Konventionen zu etablieren. Bugs, die beim Code-Review nicht auffallen würden und zu technischen Schulden führen würden, können verhindert werden. Das verbessert damit die Belastbarkeit unserer Architektur.
Ein weiterer großer Vorteil der Verwendung dieser Bibliothek besteht darin, dass Code-Refactoring durch die IDE auch für unsere Tests übernommen wird – wir brechen also unsere Tests nicht durch das Refactoring unseres Codes.
Durch das Schreiben von ArchUnit-Tests können wir Entscheidungen, Strukturen und Konventionen einhalten, die vereinbart und in Form von Unit-Tests niedergeschrieben wurden, und damit unbeabsichtigte Codeänderungen eliminieren, die später schwer rückgängig zu machen wären.
Bei Cloudflight verwenden wir ArchUnit für unsere benutzerdefinierte Archunit-CleanCode-Verifier-Bibliothek, die häufige Best Practices für unsere Projekte überprüft.
Fußnoten
- Der gesamte Beispielcode aus diesem Artikel findet sich in diesem Repository.
- Für eine detailliertere Anleitung besucht bitte den ArchUnit User Guide.
- Für eine Reihe von Beispielen, die für jedes Teilmodul des Projekts geschrieben wurden (
archunit,junit4undjunit5), besucht bitte das ArchUnit Examples Repository. - Für eine Reihe benutzerdefinierter Beispiele für das Java Persistence Framework (JPA), das Spring Framework und Spring Boot besucht bitte das Cloudflight Archunit-CleanCode-Verifier Repository.
Agent Hub
Gehen Sie über vereinzelte AI-Piloten hinaus und schaffen Sie eine sichere, skalierbare Grundlage für Agentic AI in Ihrer gesamten Organisation.
Zur Demo




