ArchUnit: zapomnij o architekturze, to elastyczny i inteligentny linter

ArchUnit staje się coraz częściej polecanym narzędziem. Tyle że moim zdaniem ludzie skupiają się na zbyt wąskim zastosowaniu. Winę ponosi jak myślę oficjalna strona, która upiera się, że to biblioteka do sprawdzania architektury systemów napisanych w Javie/JVM i straszy diagramami UML. Plus nazwa jest jaka jest, skojarzenia są nieuniknione. Stąd artykuły uczące ArchUnita tłuką do znudzenia przykłady typu wykrywanie cykli zależności i liczenie warstw. Nie mówię, że to zupełnie nieważne rzeczy, ale są bardziej życiowe problemy – a ArchUnit potrafi wykryć znacznie więcej, niż pokazują typowe tutoriale. Warto go poznać, nawet jeśli nie interesuje cię „testowanie architektury”.

ArchUnit pozwala za pomocą fluent API pisać narzucane na kod reguły, które działają szybko i dają jasne komunikaty błędu. Rozumie typy, co jest jego przewagą nad prostymi linterami typu CheckStyle lub ktlint. Operuje na kodzie bajtowym, więc może działać zarówno z Javą, jak i Kotlinem czy Scalą.

Można w ten sposób narzucać spójny sposób pisania („każdy kontroler musi mieć słowo Controller w nazwie”). Być może ważniejsza jest możliwość zabezpieczania się przed niepoprawnym korzystaniem z zewnętrznych bibliotek. Przykladowo, złe użycie adnotacji frameworku JUnit spowoduje, że kod testu zamiast chronić przed regresją nie będzie nigdy odpalony. Podobne pułapki czyhają w wielu bibliotekach i warto się przed nimi zabezpieczyć.

This article is also available in English.

Jak zacząć

ArchUnit nie jest ciężką biblioteką. Wystarczy jedna zależność:

testImplementation("com.tngtech.archunit:archunit-junit5:0.22.0")

Nie zaciąga ze sobą zbyt dużo:

./gradlew dependencies --configuration testComClass
...
\--- com.tngtech.archunit:archunit-junit5:0.22.0
     \--- com.tngtech.archunit:archunit-junit5-api:0.22.0
          \--- com.tngtech.archunit:archunit:0.22.0
               \--- org.slf4j:slf4j-api:1.7.30 -> 1.7.32

Testy używające ArchUnita są zwięzłe i czytelne:

@AnalyzeClasses(packages = ["mój.pakiet"])
class ArchitectureTest {
    @ArchTest
    val absurdRule = noClasses().should().beAnonymousClasses()
}

Odpalane są wraz ze zwykłymi testami jednostkowymi JUnita, nie trzeba niczego konfigurować ani uczyć się od nowa. Test jak test, tylko DSL inny.

Testy ArchUnita zabierają większość czasu na wystartowanie, kiedy to jest analizowany bytecode. Uruchamianie zapisanych przez nas reguł jest za to szybkie. W „poważnym” kodzie, tworzonym przez kilkuosobowy zespół, start zabiera 2-6 sekund, uruchomienie reguły kilkadziesiąt milisekund.

Jeśli ktoś chce zrozumieć więcej, oficjalna dokumentacja nie jest długa i dobrze tłumaczy, co trzeba.

Wykrywanie problemów JUnita

W artykule o dynamicznych testach JUnita podawałem przykład kodu źle używającego adnotacji:

@Test
fun wrongAnnotation() =
    Role.values().map { dynamicTest("test $it") { assertCorrectFor(it) } }

@TestFactory
fun forgottenReturn() {
    Role.values().map { dynamicTest("test $it") { assertCorrectFor(it) } }
}

Dla niewprawnego oka mamy tu dwie metody testowe. Tak naprawdę jest to martwy kod, który nigdy nie wyłapie błędu. IntelliJ pokazuje ostrzeżenie dla pierwszej z metod – miło, ale można to przegapić albo zignorować. Lepiej mieć coś automatycznego, co zatrzyma build i ogłosi alarm.

Błędy można wyłapać prostymi regułami ArchUnita:

@ArchTest
val testMustBeVoid = noMethods().should(
    not(haveRawReturnType("void"))
        .and(beAnnotatedWith(Test::class.java))
)

@ArchTest
val testFactoryMustReturnType = noMethods().should(
    haveRawReturnType("void")
        .and(beAnnotatedWith(TestFactory::class.java))
)

Dostajemy jasny komunikat błędu:

Architecture Violation [Priority: MEDIUM] - Rule 'no methods should not have raw return type void and be annotated with @Test' was violated (1 times):
Method <pl.pkubowicz.dynamictest.ControllerTest.wrongAnnotation()> does not have raw return type void in (ControllerTest.kt:11) and Method <pl.pkubowicz.dynamictest.ControllerTest.wrongAnnotation()> is annotated with @Test in (ControllerTest.kt:11)

Tak naprawdę napisana przeze mnie wyżej reguła dotycząca TestFactory jest nieprecyzyjna: zgodnie z dokumentacją tak adnotowana metoda musi zwracać jeden ze ściśle określonych typów, na przykład List<DynamicTest> lub Stream<DynamicTest>. Rozwiązanie zostawiam na zadanie domowe, ale najpierw polecam doczytać artykuł do końca. Spoiler alert: okaże się, że nie będziemy w stanie wymagać, żeby w liście czy streamie znajdowały się instancje DynamicTest.

Niebezpieczne metody w bibliotekach

Czasem w bibliotece są mroczne zakamarki. Kilka metod, których wywołanie może się źle skończyć. Biblioteka jako całość może być w porządku: jasno napisana, dobrze otestowana, regularnie wypuszczana. Tylko te drobne szczegóły. Biblioteka zostaje, bo przynosi dużo więcej korzyści niż strat.

Można liczyć, że w zespole się dogadamy, by tych złych rzeczy nie używać. Będziemy pilnować na code review. Może nawet mroczne zakamarki są oznaczone przez autorów biblioteki jako @Deprecated. Wciąż, zostawia to ryzyko. Ludzie przeoczą na review. Ktoś zignoruje ostrzeżenie w IDE. Nowej osobie w zespole nikt nie powie, czego nie używać. Nie lepiej takie rzeczy mieć zapisane? Jeszcze lepiej: zapisane i automatyczne egzekwowane?

Tu świetną pomocą jest ArchUnit. Bardzo łatwo zapisać, że nie dopuszczamy wołania jakiejś metody. Albo bardziej szczegółowo: nie dopuszczamy wołania danej metody w wersji, gdzie ostatnim parametrem jest String zamiast Instanta.

Zwykły linter nie pozwala na takie rzeczy. Zakazanie użycia metody: ok, to da się zrobić linterem. Ale zakazanie tylko jednej z przeciążonych wersji? Nie w CheckStyle’u.

Wymuszanie unikalności

Czasem rzeczy muszą być niepowtarzalne, ale nie jest łatwo się o tym upewnić. We frameworku Spring jest o tyle dobrze, że dostaniemy wyjątek przy ładowaniu kontekstu, w którym są zadeklarowane dwa beany o tym samej nazwie. Inne biblioteki mogą nie mieć takiego dobrego developer experience.

Przykładowo, używam narzędzia do migracji, gdzie migracjom mogę przypisać numer porządkowy kontrolujący kolejność wykonania. Wszystko pięknie, ale nic nie broni mi użycia tego samego numerka dwa razy. Całość odpali się, żadnych wyjątków, żadnych ostrzeżeń w logach. Łatwy do przeoczenia szkopuł: kolejność wykonania migracji jest wtedy niedeterministyczna. Ciężko napisać zwykły test, który zaświecił by się na czerwono w takim wypadku.

Dlatego mam w kodzie regułę ArchUnita, która zabrania używania dwa razy tego samego numerka.

@ArchTest
fun migrationsMustHaveDistinctOrder(classes: JavaClasses) {
    val migrationsDuplicatingOrder = classes
        .filter { it.isAnnotatedWith(Migration::class.java) }
        .groupBy { it.getAnnotationOfType(Migration::class.java).order }
        .filter { (_, classes) -> classes.size > 1 }
    org.assertj.core.api.Assertions.assertThat(migrationsDuplicatingOrder)
        .isEmpty()
}

Wcześniej w artykule reguły były bardziej deklaratywne. To dzięki temu, że używałem wysokopoziomowego API ArchUnita, zwanego „Lang API”. Wysokopoziomowe API nie dostarcza jednak narzędzi do badania unikalności wartości pewnej adnotacji. Sięgam więc po niskopoziomowe „Core API”, przez co muszę samodzielnie zająć się przetwarzaniem listy klas. ArchUnit załatwia zaczytanie kodu, ja muszę zająć się logiką tego, co chcę sprawdzić. Za to mogę wymusić takie reguły, jakie tylko sobie wymyślę.

Niedogodności

ArchUnit działa na kodzie bajtowym, czyli czymś, co przeszło type erasure. W konsekwencji nie jesteśmy w stanie napisać reguły typu: „żadna metoda zadeklarowana w kontrolerze nie może zwracać SecretKey ani Mono<SecretKey> ani Flux<SecretKey>”. ArchUnit w kodzie zobaczy jedynie Mono<Object> i Flux<Object>. Nie dowiemy się, co siedzi w środku Mono albo Fluxa. Przypomina o tym API ArchUnita: mamy metodę haveRawReturnType, ale nie ma metody haveReturnType.

Podsumowanie

ArchUnit to nie tylko pilnowanie architektury i pilnowanie stosowania odpowiednich konwencji w naszym kodzie. Pozwala też naprawiać błędy twórców bibliotek.

Czasem biblioteka w niewystarczający sposób waliduje, czy robimy sensowne rzeczy (np. co nam przyszło do głowy, że w JUnicie piszemy voidowe @TestFactory). W takim wypadku taką walidację piszemy my, jako regułę ArchUnita.

Czasem część kodu biblioteki jest myląca i niebezpieczna, ale dla wstecznej kompatybilności twórcy nie usuwają jej, tylko oznaczają jako @Deprecated. Możemy to naprawić: reguła ArchUnita przerwie build, gdy użyjemy zakazanej klasy czy metody.

Jeśli w twoim systemie kod musi przestrzegać reguł trudnych do wymuszenia kompilatorem, ArchUnit powinien być w stanie ich przypilnować. Klasy numerowane w ściśle rosnącym porządku – nie ma problemu. Mamy swoje adnotacje, które trzeba ustawiać przy pisaniu pewnego rodzaju klas – też da się zrobić.

Creative Commons License
Except where otherwise noted, the content by Piotr Kubowicz is licensed under a Creative Commons Attribution 4.0 International License.