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" )