10 Praktycznych Testów ArchUnit dla Aplikacji Spring

Zły system zawsze pokona dobrego człowieka.

Edwards Deming

Do efektywnego wytwarzania oprogramowania niezbędne jest kontrolowanie jego jakości, w szczególności architektury i struktury kodu. W języku Java z pomocą może przyjść ArchUnit, biblioteka, która pozwala testować pewne aspekty architektury oraz designu. W artykule przedstawię dziesięć praktycznych zastosowań testów ArchUnit, opartych na moich doświadczeniach z rozwijaniem aplikacji opartej o Spring Framework.

Mimo, że ArchUnit jest potężnym narzędziem, trudno jest od razu w pełni wykorzystać jego możliwości oraz określić, co warto testować. Opisane przeze mnie testy nie powstały od razu. Zacząłem od dwóch – trzech prostych testów, a następnie, na przestrzeni lat, gdy jakieś reguły projektowe były łamane, dodawałem kolejne testy lub poprawiałem istniejące. Takie reguły mogą być łamane z różnych powodów: w natłoku zmian łatwo zapomnieć o pewnych zasadach, a nowi członkowie zespołu mogą nie być świadomi istniejących założeń projektowych. Zapisanie tych zasad w postaci testów jest sposobem na formalne wyrażenie architektury i designu, co pomaga utrzymać jakość w projekcie.

W efekcie powstał praktyczny zestaw testów, który mogę zaaplikować w swoich projektach, zapewniając ich spójność i zgodność z założeniami architektonicznymi. Poniżej prezentuję te testy wykorzystujące bibliotekę ArchUnit, z których korzystam w niemalże każdym projekcie w JAVA. Jeśli uznasz je za przydatne, po prostu skopiuj je do swojego projektu.

Kontekst

Niektóre z opisanych testów są uniwersalne, inne zaś są bardziej powiązane ze strukturą projektu. Aby łatwiej je zrozumieć i zaadaptować, poniżej przedstawiam przykładową strukturę pakietów:

pl.tfij.example
+--- users
|    |--- domain
|    \--- infrastructure
+--- orders
|    |--- domain
|    \--- infrastructure
+--- products
|    |--- domain
|    \--- infrastructure
\--- commons

Powyższa struktura nie jest popularną architekturą warstwową. Dlatego w tym poście nie znajdziesz testów w stylu „kontrolery używają serwisów, a serwisy repozytoriów”.

Wszystkie testy ArchUnit umieszczam w jednej klasie testowej, w której deklaruję dwie stałe przydatne w testach. Opisując testy w tym artykule zakładam, że są one osadzone w takiej klasie:

class ArchitectureTest {
    private static final String PROJECT_PACKAGE = "pl.tfij.example";

    private static final JavaClasses ALL_SERVICE_CLASSES = new ClassFileImporter()
            .withImportOption(new ImportOption.DoNotIncludeTests())
            .importPackages(PROJECT_PACKAGE);
    
    // ...
}

Pisząc o testach architektury, warto wspomnieć o jednej dobrej praktyce. Jeśli jakaś reguła architektoniczna została spisana w dokumencie, np. ADR (Architecture Decision Record), to warto, aby test weryfikujący tę regułę linkował do tego dokumentu. Z testu powinno jednoznacznie wynikać, bezpośrednio lub pośrednio, dlaczego dany niezmiennik jest ważny. Nie powinniśmy dopuścić do sytuacji, że gdy test nieprzechodzi, jakiś programista zacznie się zastanawiać “ale niby dlaczego? co jest złego że robię to w ten sposób?” Taka sytuacja najprawdopodobniej doprowadzi do usunięcia testu i pierwotne założenia architektoniczne zostaną zatracone.

1. Cykle w projekcie

Jednym z najpopularniejszych testów ArchUnit jest weryfikacja cykli. Jest to prosty i ważny test od którego zacznę.

@Test
@DisplayName("Packages should be free of cycles")
void packagesShouldBeFreeOfCycles() {
    slices()
            .matching("%s.(**)".formatted(PROJECT_PACKAGE))
            .should().beFreeOfCycles()
            .check(ALL_SERVICE_CLASSES);
}

2. Zależności między modułami

W przedstawionej wcześniej strukturze pakietów, modułami pierwszego rzędu są users, orders, products i commons. Ważne jest, aby zależności tych modułów były przejrzyste i zgodne z założeniami, np. users nie powinien zależeć od products. W ArchUnit nie ma abstrakcji modułu, jest za to abstrakcja pozwalająca testować architekturę warstwową, np. czy wspomniane wcześniej kontrolery zależą od usług, a usługi od repozytoriów. Jeśli potraktować moduły jako warstwy, można zdefiniować test weryfikujący zależności między modułami.

@Test
@DisplayName("Each module should depend only on declared modules")
void modulesDependencyTest() {
    layeredArchitecture()
            .consideringOnlyDependenciesInLayers()
            .ensureAllClassesAreContainedInArchitectureIgnoring(PROJECT_PACKAGE)
            .layer("users")   .definedBy("pl.tfij.example.users..")
            .layer("orders")  .definedBy("pl.tfij.example.orders..")
            .layer("products").definedBy("pl.tfij.example.products..")
            .layer("commons") .definedBy("pl.tfij.example.commons..")
            .whereLayer("users")   .mayOnlyAccessLayers("commons")
            .whereLayer("orders")  .mayOnlyAccessLayers("users", "products", "commons")
            .whereLayer("products").mayOnlyAccessLayers("commons")
            .whereLayer("commons") .mayNotAccessAnyLayer()
            .check(ALL_SERVICE_CLASSES);
}

Reprezentowanie modułów jako warstw wprowadza nieco szumu do testu, jednak jest to najbardziej zwięzłe rozwiązanie w ArchUnit, pozwalające osiągnąć cel, jakim jest zweryfikowanie zależności między modułami.

3. Zgodność kodu z dokumentacją

Innym sposobem na zweryfikowanie zależności między modułami jest użycie do tego diagramu PlantUML, którego dodatkową zaletą jest możliwość kontroli aktualności dokumentacji (jeśli dokumentacja, w tym diagram PlantUML jest przechowywana razem z kodem). W opisanym przypadku można zdefiniować diagram komponentów w PlantUML:

@startuml

component users <<..users..>>
component products <<..products..>>
component orders <<..orders..>>

users <-- orders
products <-- orders

@enduml

Świadomie pominąłem moduł commons na diagramie, aby nie zmniejszać jego czytelności, co jest szczególnie istotne przy bardziej złożonych systemach składających się z wielu modułów. Poniżej załączam wyrenderowaną wersję diagramu.

ArchUnit ma wbudowane wsparcie dla PlantUML. Co prawda wspierany jest tylko podzbiór składni, niemniej nasz kod może być weryfikowany z prostym diagramem, jak przedstawiony powyżej. Test, którym możemy to osiągnąć, wygląda następująco:

@Test
@DisplayName("The code should follow the architecture diagram")
void codeShouldFollowArchitectureDiagram() {
    classes()
            .should(adhereToPlantUmlDiagram(
                    "doc/KeyModulesDependencies.puml", 
                    consideringOnlyDependenciesInDiagram()))
            .check(ALL_SERVICE_CLASSES);
}

Należy zaznaczyć, że ten test ma pewne ograniczenia. Jeśli na diagramie jest przedstawiona zależność A od B, to test przejdzie także, gdy w kodzie takiej zależności nie ma. Innymi słowy, diagram określa możliwe przejścia, które w kodzie mogą się pojawić lub nie.

4. Domena nie powinna zależeć na infrastrukturę

Podstawową regułą architektury heksagonalnej, portów i adapterów, onion architecture czy też clean architecture, jest zasada odwrócenia zależności (dependency inversion) – moduły wysokiego poziomu nie powinny bezpośrednio zależeć od modułów niskiego poziomu. W szczególności domena nie powinna zależeć na infrastrukturę; to infrastruktura może zależeć na domenę. Weryfikację tej reguły można uzyskać prostym testem:

@Test
@DisplayName("Domain should not depend on infrastructure")
void domainShouldNotDependOnInfrastructure() {
    classes()
            .that().resideInAPackage("..domain..")
            .should().onlyDependOnClassesThat().resideOutsideOfPackage("..infrastructure..")
            .check(ALL_SERVICE_CLASSES);
}

W przypadku, gdy w projekcie przestrzegamy restrykcyjnie wytycznych architektury heksagonalnej, możemy zdefiniować testy w stylu:

@Test
@DisplayName("The order module should follow the onion architecture")
void verifyOrdersOnion() {
    onionArchitecture()
            .domainModels(          "pl.tfij.example.orders.domain.model..")
            .domainServices(        "pl.tfij.example.orders.domain.service..")
            .adapter("persistence", "pl.tfij.example.orders.infrastructure.persistence..")
            .adapter("rest",        "pl.tfij.example.orders.infrastructure.rest..");
}

W tym przypadku musimy zdefiniować osobny test dla każdego modułu. Taki test w bardziej ścisły sposób kontroluje przestrzeganie zasad spójności i odpowiedzialności poszczególnych pakietów.

5. Domena nie ma zależności na bazę danych

Jak dotąd zagwarantowaliśmy, że pakiet domain nie zależy na infrastrukturę. Nie ma jednak gwarancji, że klasa infrastruktury zostanie umieszczona we właściwym miejscu. W jednym z projektów okazało się, że klasa zawierająca kod SQL znalazła się w domenie. Aby zapobiec takim błędom, dodaliśmy test:

@Test
@DisplayName("Domain should not depend on database internals")
void domainShouldNotDependOnDatabaseInternals() {
    classes()
            .that().resideInAPackage("..domain..")
            .should().onlyDependOnClassesThat().resideOutsideOfPackages(
                    "org.springframework.jdbc.**", 
                    "java.sql.**", 
                    "jakarta.persistence.**")
            .check(ALL_SERVICE_CLASSES);
}

W analogiczny sposób można wykluczyć pakiety związane z Kafką, serializacją do JSON lub innymi technologiami, które nie powinny pojawić się w domenie. Tego typu testy pomagają zapewnić, że kod domenowy pozostaje czysty i niezależny od szczegółów technicznych, co jest kluczowe dla utrzymania klarowności i elastyczności architektury.

Alternatywnie, zamiast definiować pakiety, które są zabronione w domenie, można zdefiniować zbiór pakietów, które są dozwolone. Osobiście jednak preferuję definiowanie czarnej listy, ponieważ test jest stabilniejszy pod kątem zmian, zwłaszcza we wczesnej fazie, gdy projekt szybko się rozwija.

6. Wstrzykiwanie przez konstruktor

Dobrą praktyką w Spring jest wstrzykiwanie zależności (dependency injection – nie mylić ze wspomnianym wcześniej dependency inversion) przez konstruktor, a nie przez pola lub settery. W internecie można znaleźć wiele artykułów na ten temat, np. Reflectoring: Constructor Injection. Aby zagwarantować, że nie ma wstrzykiwania przez pola, wystarczy sprawdzić, że żadne pole nie jest adnotowane @Autowired lub @Value.

@Test
@DisplayName("Should not inject by field (required injection by constructor)")
void shouldNotInjectByField() {
    fields()
            .should().notBeAnnotatedWith(Autowited.class)
            .andShould().notBeAnnotatedWith(Value.class)
            .check(ALL_SERVICE_CLASSES);
}

Ten test zapewnia, że wszystkie zależności są wstrzykiwane przez konstruktor, co jest uznawane za najlepszą praktykę w Spring.

7. Metryki

W spring łatwo zbierać metryki przy użyciu micrometer a dobre metryki są kluczowym elementem utrzymania aplikacji w systemie produkcyjnym. W szczególności powinniśmy mieć dane o każdym punkcie końcowym. Dlatego każda publiczna metoda konstruktora powinna być adnotowana @Timed. Łatwo zapomnieć o dodaniu takiej adnotacji. Na szczęście łatwo zabezpieczyć się testem ArchUnit:

@Test
@DisplayName("Controller public method should be annotated with @Timed")
void servicePublicMethodsShouldBeAnnotatedWithTimed() {
    methods()
            .that().areDeclaredInClassesThat().haveNameMatching(".*Controller")
            .and().areDeclaredInClassesThat().areNotInterfaces()
            .and().areNotPrivate()
            .should().beAnnotatedWith(Timed.class)
            .check(ALL_SERVICE_CLASSES);
}

W moich projektach klasy service są punktami wejściowymi do domeny. Dlatego dodatkowo zbieram metryki dla każdej z metod w klasach service.

@Test
@DisplayName("Service public method should be annotated with @Timed")
void servicePublicMethodsShouldBeAnnotatedWithTimed() {
    methods()
            .that().areDeclaredInClassesThat().haveNameMatching(".*Service")
            .and().areDeclaredInClassesThat().areNotInterfaces()
            .and().areNotPrivate()
            .should().beAnnotatedWith(Timed.class)
            .check(ALL_SERVICE_CLASSES);
}

8. Konwencje nazewnicze

W różnych projektach kontrolery mogą mieć różne nazwy, takie jak *Controller, *Endpoint, *Resource. Często jednak standard nazewnictwa nie jest konsekwentnie utrzymywany w ramach jednego projektu. Warto zdefiniować testy ArchUnit, aby zagwarantować, że wszystkie klasy kontrolerów kończą się np. na Controller.

@Test
@DisplayName("Controller class should have 'Controller' on the end of its name")
void controllersShouldHaveControllerOnTheEndOfTheClassName() {
    classes()
            .that().areAnnotatedWith(RestController.class)
            .should().haveNameMatching(".*Controller")
            .check(ALL_SERVICE_CLASSES);
}

W analogiczny sposób można testować nazwy klas Repository, Entity lub nazwy pakietów, w których są umieszczane. Zapewnia to spójność i ułatwia utrzymanie kodu.

9. Każdy moduł ma pojedynczą klasę konfiguracyjną

W swoich projektach przestrzegam zasady, że każdy moduł powinien posiadać pojedynczy punkt dostępu – jedna klasa, która umie wytworzyć skonfigurowany moduł. Można to określić jako fabryka skonfigurowanego modułu. U mnie jest to klasa Config, która tworzy bean będący fasadą modułu. Innymi słowy każdy moduł powinien mieć jedną i tylko jedną klasę konfiguracyjną. Taka klasa konfiguracyjna także wykorzystywana jest w testach jednostkowych do wytworzenia modułu. Ułatwia to weryfikację części domenowej całego modułu.

Test gwarantujący istnienie pojedynczej klasy konfiguracyjnej jest nieco bardziej złożony niż przedstawiane wcześniej testy:

@Test
void eachInfrastructurePackageShouldContainsSingleConfigClass() {
    // Import classes from the package
    JavaClasses importedClasses = new ClassFileImporter().importPackages(PROJECT_PACKAGE);
    // Identify infrastructure packages
    Set<String> infrastructurePackages = importedClasses.stream()
            .map(javaClass -> javaClass.getPackageName())
            .filter(packageName -> packageName.endsWith(".infrastructure") || packageName.contains(".infrastructure."))
            .map(packageName -> packageName.substring(0, packageName.indexOf(".infrastructure") + ".infrastructure".length()))
            .collect(Collectors.toSet());
    // Find *Config classes in each infrastructure package
    Map<String, List<String>> configClassesByPackage = infrastructurePackages.stream()
            .collect(Collectors.toMap(
                    packageName -> packageName,
                    packageName -> findConfigClassesInGivenPackage(importedClasses, packageName)));
    // Verify if each infrastructure package has exactly one *Config class
    configClassesByPackage.forEach((packageName, configClasses) -> {
        Assertions.assertEquals(1, configClasses.size(),
                "Package `%s` should contains exactly one *Config class but contains %s".formatted(
                        packageName,
                        String.join(", ", configClasses)));
    });
}

private static List<String> findConfigClassesInGivenPackage(JavaClasses importedClasses, String packageName) {
    return importedClasses.stream()
            .filter(javaClass -> javaClass.getPackageName().startsWith(packageName))
            .map(javaClass -> javaClass.getSimpleName())
            .filter(className -> className.endsWith("Config"))
            .toList();
}

Ten test zapewnia, że każdy moduł w projekcie ma dokładnie jedną klasę konfiguracyjną Config. Taka organizacja kodu ułatwia zarządzanie konfiguracją modułów i ich testowanie. Dzięki temu można łatwo znaleźć i zrozumieć, gdzie znajduje się konfiguracja każdego modułu.

10. Beany tworzone w klasie konfiguracyjnej

W swoim kodzie nie używam adnotacji takich jak @Service lub @Component. Tego typu beany powinny być tworzone w klasie konfiguracyjnej za pomocą @Bean. Jest to spójne z wcześniej opisaną zasadą, że klasa config jest fabryką dostarczającą skonfigurowany moduł. Można to zweryfikować za pomocą testu:

@Test
@DisplayName("Classes should not be annotated with @Service or @Component (create beans in a config class)")
void classesShouldNatBeAnnotatedWithService() {
    classes()
            .should().notBeAnnotatedWith(Service.class)
            .andShould().notBeAnnotatedWith(Component.class)
            .check(ALL_SERVICE_CLASSES);
}

Test weryfikuje czy wszystkie beany są tworzone w klasach konfiguracyjnych, a nie za pomocą adnotacji @Service lub @Component na klasach. Dzięki temu można centralnie zarządzać konfiguracją beanów w projekcie, co ułatwia ich monitorowanie, modyfikację i testowanie.

Ograniczenia ArchUnit

ArchUnit działa w oparciu o skompilowany kod. Wynikają z tego pewne ograniczenia, których należy być świadomym. Na przykład, stałe literały mogą zostać „inline’owane” i zależność, która jest w kodzie źródłowym, może zniknąć w bytecode. Przykładowo, jeśli w infrastrukturze zdefiniowana została publiczna stała typu String. W domenie odwołujemy się do tej stałej. Test weryfikujący, że domena nie powinna zależeć na infrastrukturę, nie wyłapie takiej zależności.

Warto pamiętać o tym, że ArchUnit operuje na bytecode, aby w przyszłości uniknąć nieprzyjemnych niespodzianek jak ta opisana powyżej.

Przekaz na dziś

Dodaj test, jeśli odkryjesz naruszenie jakiejkolwiek reguły projektowej lub architektonicznej. Jeśli reguła została złamana raz, dowodzi to, że może być złamana ponownie.


Autor Tomek Fijałkowski

Tomek jest pasjonatem zwinnych metodyk, TDD i DDD. W swojej karierze pełnił przeróżne role. Był programistą, scrum masterem, liderem, architektem, managerem. Aktualnie wrócił do tego co lubi najbardziej i jest programistą w point72.

Leave a Reply