Java-Architekturen testen mit ArchUnit
Der folgende Artikel beschreibt die Java Open Source Library ArchUnit [1], mit welcher sich statische Eigenschaften von Architektur (also Abhängigkeiten zwischen Klassen, Methoden, Artefakten, etc.) automatisiert testen lassen. Zur Motivation möchte ich folgenden fiktiven Dialog zwischen Entwicklern anführen:
A: Wieso liegt denn der CustomerRecommendationService nicht in einem "service"-Paket wie alle anderen Services?
B: Hmm, weiß ich auch nicht, sieht eh irgendwie etwas komisch aus, wieso benutzt er denn den OrderUIConverter? Das sollte doch eigentlich nicht so sein?
A: Stimmt, eigentlich sollten die Abhängigkeiten doch von Order nach Customer sein.
B: Und außerdem sollte ein Service doch keine UI-Logik aufrufen.
A: Die VCS-Annotations sagen, dass Entwickler C das eingecheckt hat; fragen wir den am besten mal.
A: Hallo, du hast da letzten Januar den CustomerRecommendationService
eingecheckt, und wir fragen uns gerade, wieso der UI-Logik aufruft.
C: Moment, wie war das noch? Ach ja, wir mussten damals noch schnell ein Feature für das Release reinhacken, eigentlich wollten wir das hinterher noch aufräumen...
In historisch gewachsenen Systemen finden solche Dialoge immer wieder statt. Glück hat man dann, wenn man tatsächlich noch den entsprechenden Entwickler fragen kann. Teilweise hat dieser allerdings schon das Projekt gewechselt und ist nicht mehr verfügbar. Im schlimmeren Fall kommt es zu derartigen Szenen, wenn ein neues Feature eingebaut wurde und die Anwendung plötzlich Fehler in scheinbar völlig unabhängigen Bereichen aufweist.
Der Dialog zeigt einige grundlegende Probleme auf, wenn Architekturverletzungen unbemerkt durchrutschen.
- Derartige Verletzungen kosten Zeit. Man muss überlegen, was der Grund dafür sein könnte, dass die Stelle von der Norm abweicht. Man muss bei anderen Entwicklern nachfragen, und hinterher steht man vor der Entscheidung, diese Stelle auf Kosten der momentanen Entwicklungsgeschwindigkeit zu reparieren oder sie zu belassen. Letzteres hat wiederum zur Folge, dass einige Monate später der nächste Entwickler Zeit verlieren wird.
- Es sorgt für Überraschungen. Wenn beispielsweise ein Team Anpassungen am Order-Modul schätzt, lässt es Annahmen wie "Niemand hängt von dem Modul ab" oder "Die UI kann angepasst werden, ohne die Business-Logik zu beeinflussen" in die Schätzung einfließen. Stellt man plötzlich fest, dass die Änderung beliebig viele andere Komponenten mit beeinflusst, kann der tatsächliche Aufwand im Vergleich zur Schätzung explodieren.
- Der Kontext von Änderungen ist wesentlich schwerer zu verstehen, womit die Gefahr enorm steigt, dass bei der Anpassung der Software Fehler eingebaut werden. Bug-Fixing wird auf Grund von unvorhersehbaren Zusammenhängen ebenfalls teurer.
- Die Kosten für den Ausbau potenzieren sich mit der Zeit. Wenn andere Entwickler die Verletzung nicht beseitigen, sondern darauf aufbauen, sie erweitern und in der Folge weitere Verletzungen einbauen, wird es immer teurer, die ursprüngliche Verletzung und alle Folgen zu beseitigen.
Kontinuierlicher Architekturerhalt
Die genannten Punkte machen eines klar, nämlich die Tatsache, dass ein Ignorieren der inneren Qualität, einer klaren Struktur und einheitlicher und verständlicher Muster, massiv die Handlungsfähigkeit der Entwicklung gefährdet. Wenn Beispiele wie das oben geschilderte nicht mehr die Ausnahme, sondern die Norm sind, explodieren zeitlicher Aufwand und damit Kosten für jegliche Feature-Entwicklung und Bug-Fixing. Was kann man also tun, um solch eine Situation zu vermeiden?
In meinen Augen ist ein Schlüssel der kontinuierliche und vor allem auch automatisiert getestete Erhalt der Architektur. Während früher kurz vor dem Release eine Horde Tester die Anwendung manuell auf gewissen Szenarien abgeprüft hat, haben wir heute umfangreiche Suiten von automatisierten Tests, die gewährleisten, dass einmal erstellte Features auf Dauer funktionieren. Genau diese Denkweise sollten wir auch auf die Architektur anwenden. Man wird immer dedizierte Leute benötigen, die sich um die Struktur des Systems kümmern – auch in agilen Projekten, wo die Rollen stark verteilt sind. Dennoch benötigt es, wie bei Features auch, ein automatisiertes Gerüst an Tests für die innere Qualität der Software. Tests, die automatisch – und zwar kontinuierlich – prüfen, ob Abhängigkeiten korrekt sind und Muster eingehalten werden.
Es gibt einige Tools, die die Struktur von Java-Architekturen automatisiert testen können. Dennoch habe ich festgestellt, dass es gerade in agilen Projekten von Vorteil ist, wenn man diese automatisierten Tests in einer Sprache festhalten kann, die jedem Entwickler vertraut ist, und wenn das Setup dieser Tests so einfach wie möglich ist. ArchUnit leistet dies, indem sich Tests analog zu normalen Java Unit-Tests schreiben und mit dem Test-Framework der Wahl ausführen lassen. Das Einführen von ArchUnit beschränkt sich also auf das simple Einbinden einer weiteren Bibliothek und das Schreiben von Unit-Tests im normalen Umfeld.
Im Folgenden wird die Funktionsweise und Nutzung von ArchUnit näher erläutert.
Architektur und Byte-Code
Arbeitet man mit statisch typisierten und kompilierten Sprachen wie Java, so hat man einen entscheidenden Vorteil. Ein großer Teil des Systems findet sich im Kompilat wieder, welches eine Menge an Zusicherungen bietet. Zudem entspricht dieses statische Modell für Java als Ausgangssprache sehr stark dem Source-Code, wodurch sich auch viele Anforderungen an den Source-Code über den Byte-Code testen lassen. Dies spielt beispielsweise eine Rolle, wenn wir uns primär dafür interessieren, den Source-Code des Systems verständlich zu halten. Betrachten wir den Zusammenhang zwischen Byte-Code und Architektur, so umfasst dies prinzipiell zwei Bereiche. Zum einen, welche Informationen überhaupt dem Byte-Code entnommen werden können, zum anderen, wie sich abstrakte Architekturkonzepte auf Strukturen aus dem Byte-Code abbilden lassen.
Byte-Code versus Reflection-API
Wir können Überlegungen zum Byte-Code anhand von zwei simplen Beispielklassen nachvollziehen
class DependentClass { SomeDependency dependency; void call() { dependency.doSomething(); } }
class SomeDependency { void doSomething() { } }
In einem Java Unit-Test könnte man gewisse Eigenschaften von DependentClass programmatisch über die Reflection-API testen, z. B. ob Annotationen vorliegen:
Method method = DependentClass.class.getDeclaredMethod("call"); assertThat(method.getAnnotation(UiAccess.class)) .as("Server-Code darf keinen UI-Zugriff deklarieren") .isNull();
Allerdings erlaubt die Reflection-API nur Zugriff auf "lokale" Eigenschaften, d. h. wir können keine Information über den Zugriff auf SomeDependency abrufen, obwohl diese Information sehr wohl im Byte-Code enthalten ist:
javap -v DependentClass.class
... #2 = Fieldref // DependentClass.dependency:LSomeDependency; #3 = Methodref // SomeDependency.doSomething:()V ... void call(); descriptor: ()V flags: Code: stack=1, locals=1, args_size=1 0: aload_0 1: getfield #2 // Field dependency:LSomeDependency; 4: invokevirtual #3 // Method SomeDependency.doSomething:()V 7: return LineNumberTable: line 7: 0 line 8: 7 ...
Prinzipiell ließe sich die Information also durchaus programmatisch verwerten, um Unit-Tests für die Strukturen im Byte-Code zu schreiben. ArchUnit füllt diese Lücke, indem es einen Import von Java-Klassen ermöglicht, welcher sich ähnlich zur Reflection-API verhält, allerdings die zusätzliche Information aus dem Byte-Code programmatisch zur Verfügung stellt.
JavaClass dependentClass = new ClassFileImporter().importClass(DependentClass.class); JavaMethod method = dependentClass.getMethod("call"); JavaMethodCall callDoSomething = getOnlyElement(method.getMethodCallsFromSelf()); // z. B. testen, dass das Ziel des Aufrufs nicht in einem Package 'ui' liegt assertThat(callDoSomething.getTargetOwner().getName()).doesNotMatch(".*\\.ui\\..*");
Die Domain-Objekte, welche ArchUnit anbietet, sind soweit möglich analog zur Reflection-API benannt, mit vorangestelltem Präfix "Java". Zum Beispiel entspricht der Klasse Method der Reflection-API die ArchUnit-Klasse JavaMethod. Dies reduziert die Lernkurve, da sich die meisten Objekte so verhalten, wie man es von der Reflection-API kennt. Im Gegensatz zur Reflection-API funktioniert ArchUnit allerdings unabhängig vom ClassLoader. Es ist also unproblematisch, ein beliebiges Jar-File oder ein beliebiges Verzeichnis zu importieren.
JavaClasses classes = new ClassFileImporter().importPath("/some/directory");
Der ClassFileImporter bietet viele verschiedene Importmöglichkeiten an, allerdings lässt sich ArchUnits API an manchen Stellen komfortabler verwenden, wenn sich die importierten Klassen zudem im Classpath befinden (wie es z. B. der Fall ist, wenn man in einem Unit-Test die Klassen des aktuellen Projekts importiert).
Architekturkonzepte im Byte-Code
Will man ein großes System wartbar und erweiterbar halten, so muss man sich vor allem darum kümmern, dass es modular ist und Abhängigkeiten möglichst gering und korrekt gerichtet sind. Ebenso wichtig ist, dass das System konsistent Muster und Hierarchien verwendet, um vom menschlichen Gehirn schnell verstanden zu werden.
Kompiliert man ein großes Java-System, so erhält man eine riesige Datenmenge an statischen Eigenschaften und Abhängigkeiten – welche Klassen existieren im System, welche Felder, Konstruktoren und Methoden, welche Zugriffe existieren, welche Elemente sind wie annotiert, und vieles mehr.
Um automatisiert testen zu können, dass diese Strukturen der Architektur entsprechen, muss typischerweise eine Abbildung des mentalen Modells auf den Byte-Code vollzogen werden. Beim Entwurf der "soften" Architektur muss man also bestimmen, wie die Konzepte über die man spricht (z. B. Services, Controller, aber auch fachliche Komponenten, die sich idealerweise an der Domänensprache orientieren) auf Byte-Code-Ebene identifizierbar sind. Dies kann z. B. über Pakete, Namensschemata oder Annotationen erfolgen. Hat man sich auf derartige Konventionen geeinigt, so lässt sich hieraus ein System von statischen Architekturregeln erstellen, welches Schritt für Schritt erzwingt, dass sich der Code des Systems in diese Muster einreiht. Beispielsweise könnten wir definieren, dass Services in einem Paket service liegen müssen. Und weiterhin, dass Zugriffe auf Services nur aus einem Paket controller heraus erfolgen dürfen. Wenn das System um neue Komponenten erweitert wird, welche existierende Services nutzen wollen, so wird automatisch erzwungen, dass diese ihrerseits Controller sein müssen (auf welche wiederum weitere Regeln zugreifen können). Alternativ kann man natürlich auch feststellen, dass die neuen Komponenten nicht zu den existierenden Konzepten passen und man die bestehenden Konzepte daher erweitern muss.
Es ist wichtig, dass man die Konzepte der Architektur möglichst kompakt als automatisierte Tests schreiben kann, und dass diese hinterher deutlich kommunizieren, was genau die Architekturregel ist, die hier festgehalten wurde. ArchUnit bietet hierfür eine abstrakte API, welche auf den im letzten Abschnitt beschriebenen Konzepten aufsetzt. In natürlicher Sprache würde man etwa definieren
Auf Services darf nur von Controllern aus zugegriffen werden.
Um automatisiert zu testen, müssen wir dies zudem um die Information anreichern, was dies auf Code-Ebene bedeutet, also z. B.
Auf Klassen, die in einem Paket 'service' liegen, darf nur von einem Paket 'controller' aus zugegriffen werden.
ArchUnit erlaubt, diese Regel sehr präzise direkt als Java-Code festzuhalten:
ArchRule serviceAccessRule = classes() .that().resideInAPackage("..service..") .should().onlyBeAccessed().byAnyPackage("..controller..", "..service.."); JavaClasses classes = new ClassFileImporter().importPackages("com.myapp"); serviceAccessRule.check(classes);
Die zwei Punkte in ..service.. stehen hier (analog zu AspectJ) für beliebig viele Pakete. Man beachte, dass man bei präziser Betrachtung auch Zugriffe aus einem Paket service heraus erlauben muss, ansonsten dürfen Klassen im service-Paket nicht auf sich selbst zugreifen.
Nutzt man JUnit 4, so kann man ArchUnits spezifische Unterstützung verwenden, welche einmal importierte Klassen beim Auswerten mehrerer Regeln wiederverwendet:
@RunWith(ArchUnitRunner.class) @AnalyzeClasses(packages = "com.myapp") public class SomeArchitectureTest { @ArchTest public static final ArchRule serviceAccessRule = classes() .that().resideInAPackage("..service..") .should().onlyBeAccessed().byAnyPackage("..controller..", "..service.."); // ... }
Falls die importierten Klassen diese Regel verletzen, erhält man eine detaillierte Fehlermeldung, welche die Regel beschreibt und die Verletzungen inklusive Zeilennummer auflistet:
java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] - Rule 'classes that reside in a package '..service..' should only be accessed by any package ['..controller..', '..service..']' was violated: Method <com.myapp.SomeClass.callService()> calls method <com.myapp.service.SomeService.execute()> in (SomeClass.java:37)
Der Regeltext kann bei Bedarf beliebig überschrieben werden, man könnte also z.B. definieren
@ArchTest public static final ArchRule serviceAccessRule = classes() // ... wie im obigen Beispiel .as("Services should only be accessed by Controllers");
Einige abstrakte Konzepte können noch kompakter getestet werden, z. B. eine klassische Schichtenarchitektur:
@RunWith(ArchUnitRunner.class) @AnalyzeClasses(packages = "com.myapp") public class SomeArchitectureTest { @ArchTest public static final ArchRule layeredArchitecture = layeredArchitecture() .layer("Controllers").definedBy("..controller..") .layer("Services").definedBy("..service..") .layer("Persistence").definedBy("..persistence..") .whereLayer("Controllers").mayNotBeAccessedByAnyLayer() .whereLayer("Services").mayOnlyBeAccessedByLayers("Controllers") .whereLayer("Persistence").mayOnlyBeAccessedByLayers("Services"); }
Oder Zyklen zwischen gewissen fachlichen oder technischen Schnitten:
@ArchTest public static final ArchRule servicesShouldBeFreeOfCycles = slices().matching("com.myapp.(**).service..").should().beFreeOfCycles();
Hier werden Schnitte über den geklammerten Teil identifiziert, bzw. danach gruppiert, z. B. (**), was für beliebig viele Pakete steht, die Regel würde also z. B. einen Abhängigkeitszyklus zwischen den Paketen com.myapp.order.service und com.myapp.customer.administration.service finden.
Eigene Konzepte mit ArchUnit definieren
ArchUnit bringt viele vorgefertigte Regeln mit, beispielsweise die oben vorgestellte Fluent-API, welche IDE-Unterstützung für typische Anwendungsfälle, wie Zugriffe von gewissen Klassen auf gewisse Klassen, ermöglicht. Grundsätzlich sind diese Regeln immer in folgender Form definiert:
Klassen [Einschränkung, z.B. im Paket 'service'] sollen [Bedingung]
Die Fluent-API nutzt dabei im Zentrum eine generische API der Form
classes.that(predicate).should(condition)
Hierbei ist predicate vom Typ DescribedPredicate, also ein Prädikat, welches für die Regel relevante Klassen auswählt und eine Beschreibung für die Erstellung des Regeltexts mitliefert. Das Argument condition ist vom Typ ArchCondition und erlaubt die Implementierung einer Bedingung an Klassen. Die oben vorgestellte Regel für Paketzugriffe ist lediglich eine spezielle Verwendung dieser API:
DescribedPredicate<JavaClass> resideInAPackageService = //... ArchCondition<JavaClass> onlyBeAccessedByAnyPackageControllerOrService = //... classes() .that(resideInAPackageService) .should(onlyBeAccessedByAnyPackageControllerOrService);
Auf diese Art und Weise lassen sich beliebige eigene Predicates und Conditions erstellen, um spezifische Architekturkonzepte des bestehenden Projekts festzuhalten.
Tatsächlich geht ArchUnit aber noch einen Schritt weiter, denn die oben vorgestellte Regel, welche sich auf Zyklenfreiheit von "Slices" bezieht, basiert ebenfalls auf einer generischen API, die man für seine Architekturkonzepte selbst verwenden kann. Die grundsätzliche Regelform bleibt auch hier erhalten, allerdings sind die Objekte, über die man sprechen will, eventuell keine Klassen. Grundsätzlich könnte man die obige Regel auch in folgender Form schreiben:
ClassesTransformer<Slice> slices = // specify how to transform classes to slices ArchCondition<Slice> beFreeOfCycles = // check for cycles between slices ArchRule slicesRule = all(slices).should(beFreeOfCycles);
Man kann daher genau an das eigene Projekt angepasste Konzepte einführen, z. B.:
@ArchTest public static final ArchRule customConcepts = all(businessModules()).that(dealWithOrders()).should(beIndependentOfPayment()); static ClassesTransformer<BusinessModule> businessModules() { return new AbstractClassesTransformer<BusinessModule>("business modules") { @Override public Iterable<BusinessModule> doTransform(JavaClasses classes) { // how we map classes to business modules } }; } static DescribedPredicate<BusinessModule> dealWithOrders() { return new DescribedPredicate<BusinessModule>("deal with orders") { @Override public boolean apply(BusinessModule module) { // return true, if a business module deals with orders } }; } static ArchCondition<BusinessModule> beIndependentOfPayment() { return new ArchCondition<BusinessModule>("be independent of payment") { @Override public void check(BusinessModule module, ConditionEvents events) { // check if the actual business module is independent of payment } }; }
Architekturtests im Projektalltag
Zum Abschluss möchte ich noch den Wert automatisierter Architekturtests für große (monolithische) agile Projekte betonen, wo die Rolle des Architekten oft über die Teams verteilt ist, und bei welchen sich die Code-Basis unter Mitwirkung vieler Entwickler (und entsprechender Fluktuation) permanent ändert. In einem derartigen Umfeld ist es besonders schwer, das Wissen über die Architektur über alle Entwickler hinweg zu verteilen. Dennoch ist es wichtig, dass alle Entwickler sich an die beschlossenen Muster und Konventionen halten, damit sich Entwickler beim Wechsel in andere Bereiche des Produkts schnell zurecht finden. Eine automatisierte Prüfung, die bei Verletzungen der Architektur anschlägt, stellt sicher, dass Konzepte nicht aus Unwissenheit ignoriert werden. Zudem wird automatisch sichergestellt, dass Konzept und Umsetzung einander entsprechen. Bei jeder Verletzung kann man bestimmen, ob ein Konzept zu Unrecht ignoriert wurde, oder ob ein entsprechendes Konzept für den aktuellen Fall schlicht und einfach nicht gedacht war. Aus meiner Sicht ist es immens wichtig, Dokumentation, das Wissen in den Köpfen der Entwickler und den tatsächlichen Code-Stand kontinuierlich auf Konsistenz zu testen.
Fazit
Mit ArchUnit lässt sich die statische Architektur von Java-Projekten auf entwicklerfreundliche Art und Weise automatisiert testen. ArchUnit benötigt hierzu keine weitere Infrastruktur und kann als Library in jedes beliebige Java-Projekt eingebunden werden, um Architektur auf Unit-Test-Ebene sicherzustellen. Zudem bietet ArchUnit eine mächtige API, mit welcher nicht nur auf viele Eigenschaften im Java-Byte-Code reagiert werden kann, sondern auch auf abstrakter Ebene Architekturkonzepte dokumentiert und automatisiert getestet werden können.
Abschließend sei erwähnt, dass sich automatisierte statische Architekturtests zur Architektur ähnlich wie Test-Coverage zu Testqualität verhalten. So wie 100 Prozent Test-Coverage in keiner Weise garantieren, dass ein System gut getestet ist, so garantiert ein erfülltes System von statischen Architekturregeln nicht, dass die Architektur gut ist – und Ziele wie etwa geringe Kopplung erfüllt sind. Schließlich kann man z.B. sämtliche Aufrufe durch Nutzung der Reflection-API ersetzen, und man wird alle statischen Regeln bezüglich Abhängigkeiten erfüllen. Allerdings gilt im Umkehrschluss entsprechend der Tatsache, dass 0 Prozent Test-Coverage ein Garant für ein schlecht getestetes System ist, dass eine Verletzung der Architektur auf statischer Ebene mit Sicherheit auf ein System hinweist, welches die gewollten Architekturziele verfehlt. Insofern kann man die statische Analyse als grundsätzlichen Sanity-Check verstehen, der auf dieser Ebene einen großen Wert mit sich bringt – insbesondere für große Systeme und inhomogene Entwicklungsteams.