ArchUnit – praktyczne zastosowanie

W artykule o podstawach ArchUnita przedstawiłem, jak i po co pisać testy architektury. Być może znasz temat, ale wciąż nie jesteś do końca przekonany (-a). Lub takie testy masz, ale nie są rozwijane, a nikt nie wie, co i w jakim celu do nich dodawać.

Poniższy tekst dostarcza konkretne, życiowe przykłady użycia. Są to zastosowania z innej bajki niż to, co podaje jako „use cases” oficjalna strona ArchUnita albo clickbaitowe „tutoriale” w stylu tych na Baeldungu (nie linkuję, nic nowego nie wnoszą).

Pokażę, jak stosuję ArchUnita do wykrywania pomyłek w testach. Oraz do wykluczania kodu, który źle działa w Kotlinie: Optional.orElse oraz adnotacji z jakarta.annotation.

Mam nadzieję, że te przykłady pomogą ci lepiej zrozumieć potencjał testów architektury, oraz dostarczą nowych inspiracji do pisania własnych, dostosowanych do potrzeb twojego projektu. Możesz też podany tu kod po prostu skopiować do siebie (udostępniam go na licencji MIT).

Brak testu JUnita

W sekcji „Wykrywanie problemów JUnita” poprzedniego artykułu z serii podałem gotowy kod wykrywający złe użycie TestFactory.

Teraz pora na coś więcej. Jest błąd użycia popełniany jeszcze częściej: ktoś zapomina dopisać adnotacji.

public class SomeTest {

    @Test
    public void doesSomething() {
        assertEquals(4, 2 + 2);
    }

    public void looksLikeTest() {
        assertEquals(4, 2 + 3);
    }
}

Łatwo wychwycić błąd, gdy metody są tak krótkie jak wyżej. W prawdziwym życiu zdarzają się bardzo zaśmiecone pliki testowe, z długimi metodami i nieczytelnymi instrukcjami. Cognitive load jest tak duży, że czytelnik raczej nie zwróci uwagi na drobny szczegół, że brakuje jednej adnotacji.

Wizualnie różnica niewielka, ale realne konsekwencje są poważne. Wszystkim będzie wydawać się, że test działa i broni przed regresją, a tymczasem przed niczym nie będzie bronił. Albo odwrotnie: test będzie miał błąd w logice, ale nigdy to nie wyjdzie na jaw, bo dany kawałek nigdy nie zostanie uruchomiony.

Poniżej moja propozycja testu architektury. Pomysł jest taki, że w klasach testowych nie ma prawa być metod publicznych bez żadnych adnotacji. Jeśli taka metoda jest, to albo ktoś zapomniał adnotacji typu @Test albo @BeforeEach, albo to helper method, a helpery powinny być prywatne.

private static final DescribedPredicate<JavaAnnotation<?>> junitAnnotation =
        new DescribedPredicate<>("Junit annotation") {

            @Override
            public boolean test(JavaAnnotation<?> javaAnnotation) {
                return javaAnnotation.getType().getName().startsWith("org.junit.jupiter");
            }
        };

@ArchTest
public static final ArchRule noForgottenJUnitAnnotation =
        methods().that()
                .areDeclaredInClassesThat(
                        simpleNameContaining("Test")
                                .and(not(annotatedWith(AnalyzeClasses.class)))
                )
                .and().areNotProtected()
                .and().areNotPrivate()
                .and().haveRawReturnType("void")
                .should().beAnnotatedWith(junitAnnotation)
                .orShould().beAnnotatedWith("org.springframework.beans.factory.annotation.Autowired");

Polecam zawsze przy pisaniu reguły sprowokować błąd i sprawdzić komunikat. Programista, który dostanie taką wiadomość, powinien wiedzieć, co ma zmienić w swoim kodzie, bez pytania kogokolwiek o pomoc. Na razie komunikat jest mętny i przegadany:

Architecture Violation [Priority: MEDIUM] - Rule 'no methods that are declared in classes that simple name containing 'Test' and not annotated with @AnalyzeClasses and have raw return type void should not be protected and should not be private and should not be annotated with Junit annotation and should not be annotated with @Autowired' was violated (1 times):

Pierwszą poprawką jest dodanie powodu i instrukcji, jak postąpić:

                .orShould().beAnnotatedWith("org.springframework.beans.factory.annotation.Autowired")
                .because("it this is a test, annotate it properly, otherwise make it private");

Następnie trzeba całość trochę skrócić. Mało zrozumiałe jest, co to właściwie za sytuacja, że simple name containing 'Test' and not annotated with @AnalyzeClasses. Trzeba zastąpić hacki i wyliczenia wyjątków bardziej deklaratywnym opisem, czyli nazwać predykat używając as():

                .areDeclaredInClassesThat(
                        simpleNameContaining("Test")
                                .and(not(annotatedWith(AnalyzeClasses.class)))
                                .as("look like tests")
                )

Poprawiony komunikat:

Architecture Violation [Priority: MEDIUM] - Rule 'no methods that are declared in classes that look like tests and have raw return type void should not be protected and should not be private and should not be annotated with Junit annotation and should not be annotated with @Autowired, because it this is a test, annotate it properly, otherwise make it private' was violated (1 times):

Optional.orElse w Kotlinie

Bezpośrednie używanie Optional.orElse() w Kotlinie powoduje, że kompilator nie jest w stanie określić nullability.

val javaWay = Optional.of(51).orElse(5)
println(javaWay?.toString())

Sprawdzając w IntelliJ typ zmiennej javaWay (Ctrl+Shift+P), widzimy, że jest to Int!, czyli Kotlin nie jest w stanie stwierdzić, czy może tam być null, czy też nie.

Zamiast używać API z biblioteki standardowej Javy, należy wykorzystać extension methods jak getOrDefault().

import kotlin.jvm.optionals.getOrDefault

val kotlinWay = Optional.of(51).getOrDefault(5)
println(kotlinWay?.toString())

Tutaj zmienna kotlinWay ma jednoznacznie określony typ: to nienulowalny Int. Dostajemy pełne wsparcie kompilatora, na przykład w konsoli ostrzeże, że ?. jest użyte bez sensu:

Unnecessary safe call on a non-null receiver of type Int

Jak zabronić użycia Optional.orElse() w projekcie, gdzie mieszana jest Java i Kotlin? Najpierw trzeba nauczyć się, jak w testach architektury odfiltrować kod napisany w Kotlinie. Ja polecam sprawdzenie, czy klasa ma adnotację @kotlin.Metadata. Potem jest to już prosta reguła ArchUnita.

private val kotlinClass = object: DescribedPredicate<JavaClass>("Kotlin class") {
    override fun test(javaClass: JavaClass): Boolean =
        javaClass.isAnnotatedWith("kotlin.Metadata")
}

@ArchTest
val noOptionalOrElse: ArchRule =
    noClasses().that(are(kotlinClass))
        .should().callMethod(Optional::class.java, "orElse", Object::class.java)
        .because("use getOrNull()")

Złe adnotacje Nullable

Kotlin rozumie nullability, jeśli kod opatrzymy adnotacją @Nullable. Jeśli zawołamy poniższy kod Javy z Kotlina, zwracany typ będzie określony na String?.

@Nullable
String execute(String taskId);

Z jednym zastrzeżeniem: Kotlin nic nie zrozumie, gdy IDE zaimportuje nam Nullable ze złego pakietu.

Obecnie przez irytującą decyzję Oracle’a cały świat Javy przepisuje javax na jakarta. Jeśli ktoś za bardzo rozpędzi się w chęci czyszczenia staroci, zepsuje Kotlina. Bo tak się głupio składa, że Kotlin rozumie adnotacje ze starego javax.annotation, ale już nie z nowego jakarta.annotation (problem zgłoszony 2 lata temu).

Ciężko odróżnić działający kod od niedziałającego: importowany pakiet jest daleko poza polem widzenia. Lepiej to zautomatyzować, niż polegać na człowieku.

@ArchTest
val noJakartaNullable: ArchRule =
    noClasses().should()
        .dependOnClassesThat(
            fullyQualifiedName("jakarta.annotation.Nullable")
                .or(fullyQualifiedName("jakarta.annotation.Nonnull"))
        )
        .because(
            "support is not implemented: https://youtrack.jetbrains.com/issue/KT-54205"
        )
Creative Commons License
Except where otherwise noted, the content by Piotr Kubowicz is licensed under a Creative Commons Attribution 4.0 International License.