Biblioteka Konsist to nowy gracz na rynku narzędzi dla Kotlina. Umożliwia tworzenie testów architektury, ale zarówno sposób pisania, jak i zasada działania różnią się mocno od najpopularniejszego dotychczas narzędzia w świecie JVM, czyli ArchUnita. W tym artykule pokażę krótko, jak wygląda używanie Konsista w praktyce.
Nie lubię nazwy „testy architektury”, bo kojarzy mi się z architektem-mądralą, który pilnuje, czy warstwy zgadzają się z jego podręcznikiem, albo czymś równie oderwanym od realnych problemów. Jednak innej nazwy nie mamy, więc zostaje mi tylko zapewnienie, że takie testy mogą mieć bardzo konkretne, życiowe użycie, a pisać może je każdy, nawet jeśli nie ma „principal senior lead architect” w tytule zawodowym. To taki linter z regułami, które sami piszemy. Warto umieć je pisać, nawet jeśli jest się juniorem.
Nie będę kopiował strony domowej Konsista. Moje przykłady pochodzą z prawdziwych, zawodowych projektów, jakie tworzę. Są tylko trochę uproszczone.
Ten artykuł ma zamiar być krótkim wprowadzeniem, żebyście lepiej zrozumieli, co bierzecie albo czego będziecie odtąd unikać. Nie będę porównywał Konsista z ArchUnitem. Taki tekst mam w przygotowaniu. Nie będę też omawiał każdej możliwości Konsista. Od tego macie oficjalną dokumentację.
Spis treści
Czym jest Konsist
Po pierwsze, Konsist jest biblioteką napisaną w Kotlinie i analizuje kod w Kotlinie. Nie potrafi analizować kodu w Javie. Testy trzeba pisać w Kotlinie. Za to został stworzony, by pokrywać różne zawiłości składni Kotlina: companion object, extension function etc..
Po drugie, to niezależna, bezpłatna biblioteka, nie stoją za nią twórcy Kotlina. Ale też nie jest to przypadkowy projekt. Ma porządną stronę, dokumentację, roadmap rozwoju. Konsist został szerzej zauważony, o czym świadczy grant Fundacji Kotlina, czyli 6000 USD otrzymanych w roku 2024 na rozwój.
Bezpłatna po prostu, bez żadnych „ale”: nie ma osobnej licencji komercyjnej, wersji premium z dodatkowymi ficzerami.
Po trzecie, Konsist jest projektem przed fazą stabilizacji. Tak tak, ja tu niczego nie sprzedaję, w tekście będę też omawiać wady! Dość długo twórcom zajmuje wyprodukowanie wersji 1.0, na razie nie widać nawet terminu, kiedy to miałoby się stać. Więc bierzemy darmowe, ale też nie wiadomo, ile pożyje, i czy publiczne API nie wywróci się do góry nogami. Wiadomo, tak źle raczej nie będzie, ale jakieś małe a denerwujące łamanie wstecznej kompatybilności pewnie będzie jeszcze się trafiać co jakiś czas.
Jak zacząć pisać
Konsist nie narzuca konkretnego stacka, przez co powinno się go dać wsadzić do chyba każdego projektu napisanego w Kotlinie.
Do startu wystarczy dołożyć zależność od biblioteki i napisać test używając ulubionego frameworku do testów. Dalej w artykule użyję JUnita, ale twórcy dużo pracy włożyli w dobrą obsługę biblioteki Kotest.
dependencies { testImplementation(platform("org.junit:junit-bom:5.10.2")) testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("com.lemonappdev:konsist:0.17.3") } tasks.test { useJUnitPlatform() }
Można wrzucić taki test po prostu do src/test/kotlin/
:
package pl.pkubowicz import com.lemonappdev.konsist.api.Konsist import com.lemonappdev.konsist.api.verify.assertTrue import org.junit.jupiter.api.Test class SillyKonsistTest { @Test fun extensionFunctionsArePrivateOrInternal() { Konsist.scopeFromProject().functions() .filter { it.isExtension } .assertTrue { it.hasPrivateModifier || it.hasInternalModifier } } }
Uruchamiamy test i dostajemy wynik, na przykład:
com.lemonappdev.konsist.core.exception.KoAssertionFailedException: Assert 'extensionFunctionsArePrivateOrInternal' was violated (1 time). Invalid declarations:
└── Function toDto file:///a/b/c/src/main/kotlin/pl/pkubowicz/Customer.kt:5:1
at com.lemonappdev.konsist.core.verify.KoDeclarationAndProviderAssertCoreKt.getResult(KoDeclarationAndProviderAssertCore.kt:224)
at com.lemonappdev.konsist.core.verify.KoDeclarationAndProviderAssertCoreKt.assert(KoDeclarationAndProviderAssertCore.kt:56)
Logika jest bez sensu, ale zwróćmy uwagę na API, które daje łatwy dostęp do cech specyficznych dla Kotlina (isExtension
, hasInternalModifier
).
src/test/kotlin/
jest dobre na start, ale w rozbudowanych, ciężkich projektach warto wybrać izolację, czyli stworzyć w Gradle’u osobny source set na testy Konsista.
Co to scope
Wywołanie Konsist.scopeFromProject()
powoduje, że Konsist próbuje wykryć katalog główny naszego projektu, a następnie przeanalizuje wszystkie klasy Kotlina znalezione wewnątrz.
Wykrywanie działa różnie. W założeniu ma być automatyczne i bezproblemowe, rozpoznawać typowe pliki Gradle’a i Mavena, ale w praktyce ten kawałek kodu jest wciąż jakości wersji alpha. Jeśli uruchamiacie Konsista w projekcie, gdzie nie ma katalogu .git
, zobaczycie błąd
com.lemonappdev.konsist.core.exception.KoInternalException: Project directory not found. Searched in /a/b/c and parent directories
at app//com.lemonappdev.konsist.core.filesystem.PathProvider$rootProjectPath$2.invoke(PathProvider.kt:18)
Jeśli bawicie się lokalnie, polecam jako workaround odpalić git init
i mieć problem z głowy. Jeśli wdrażacie na CI, można użyć innych funkcji z klasy Konsist
.
Zamiast ładować całe repozytorium, można uruchomić analizę jednego modułu: Konsist.scopeFromModule("integration/slack")
, wybrać tylko testy itp. Możliwości jest dużo.
Scope to rzecz kosztowna do stworzenia, więc w prawdziwych testach nie woła się co chwilę Konsist.scopeFromProject()
, tylko przechowuje wynik.
@TestInstance(PER_CLASS) class KonsistTest { private val scope = Konsist.scopeFromProject() @Test fun `first test`() = scope.classes().assertTrue { true } @Test fun `second test`() = scope.objects().assertTrue { true } }
Żadnych importów z podejrzanych pakietów
Wystarczy chwila nieuwagi i IDE wstawi do naszego kodu linijkę typu
import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils
Łatwo taki błąd popełnić, a trudno wykryć: mało kto na code review przepatruje z uwagą listy importów, z kolei IntelliJ domyślnie zwija sekcję importów do jednej linijki i wtedy już nie ma szans na zauważenie takich podejrzanych pakietów.
Konsekwencje takiej prostej pomyłki mogą być zaskakująco poważne. Kilka razy usuwałem nieużywaną bibliotekę, żeby z zaskoczeniem stwierdzić, że kilka albo kilkanaście plików przestało się kompilować – bo kod importował klasę shaded zamiast oryginału. Coś, co miało być usunięciem 1 linijki zamieniało się w:
- dodawanie poprawnej zależności (jak commons-lang3) do build.gradle.kts
- czekanie na zaindeksowanie zależności w IntelliJ
- skakanie po plikach i naprawianie zależności, a przy długim pliku Kotlina IntelliJ potrafi zawiesić się na minutę zanim zaoferuje automatyczne operacje na importach
Lub: podbijam patch version jakieś biblioteki, a zupełnie niezwiązany kod przestaje się kompilować. Bo biblioteka usunęła jakąś wewnętrzną klasę narzędziową, którą ktoś przez pomyłkę zaimportował.
Potencjalnie, może być jeszcze gorzej: biblioteki shaded są często w starej wersji, więc możemy spowodować bug na produkcji przez importowanie niekompatybilnej klasy.
Jak to ogarnąć? Da się ustawić IDE, żeby złych importów nie wstawiało automatycznie (pisałem o tym w artykule Importy nieprzerywające pracy w IntelliJ). Tyle że jest naiwnością zakładać, że w zespole wszyscy będą mieli poprawne ustawione IDE. Żeby problem rozwiązać, musi być coś, co odrzuci kod na CI. Automatycznie, żeby ludzie nie musieli na code review pamiętać o jeszcze jednym szczególe.
Są lintery, które można ustawić na zabranianie określonych importów. Taką opcję ma na przykład CheckStyle, ale to narzędzie z poprzedniej epoki, plus nie działa dla Kotlina. Najpopularniejszy linter dla Kotlina, czyli ktlint, nie ma takiego zabraniania. Więc musimy użyć testów architektury. Poniżej implementacja z wykorzystaniem Konsista:
@Test fun noShadedImports() { val fragments = listOf( ".shaded.", "net.bytebuddy.utility.", "org.assertj.core.internal.", "org.hibernate.validator.internal.", ) scope.imports.assertFalse { import -> fragments.any { import.hasNameContaining(it) } } }
Prymitywne asercje
Kolejny przykład z życia. Ktoś pisze test i robi tak:
assertEquals(true, service.findSupportedModes().containsAll(listOf(READ, READ_WRITE)))
Później test się wywraca i wypisuje lakoniczny komunikat w stylu:
AssertionError: Expected false to be true
Wow. Super. Wszystko wiadomo, co nie?
Nieważne, że w ustaleniach projektu jest, że używamy biblioteki do asercji X (na przykład AssertJ). Że porządna biblioteka drukuje fantastyczne, pomocne opisy błędu. Nad programistami przejmują czasem władzę atawistyczne instynkty, każąc im cofnąć się do asercji z epoki JUnita łupanego: assertEquals
i assertTrue
.
Testy architektury mogą automatycznie zapewnić, że pewnych najgorszych asercji nie będzie dało się użyć.
@Test fun noJUnitLikeAssertions() { val fragments = listOf( "org.junit.jupiter.api.Assertions", "org.springframework.test.util.AssertionErrors", ".assertEquals", ) scope.imports.assertFalse(additionalMessage = "Please use AssertJ assertThat()") { import -> fragments.any { import.hasNameContaining(it) } } }
Wyrażenia regularne na nieznajomość AssertJ
Używać biblioteki nie znaczy od razu używać ze zrozumieniem. Załóżmy, że na code review tracimy czas na ciągłą walkę z kodem typu:
assertThat(findSupportedModes().contains(WRITE)).isTrue()
Takie użycie AssertJ nie tylko daje bezużyteczne komunikaty błędu, ale też jest dłuższe i mniej czytelne niż asercje pisane w sposób zgodny z przeznaczeniem tej biblioteki:
assertThat(findSupportedModes()).contains(WRITE)
Odpowiednio dobrana asercja z drugiego listingu da dużo lepszy komunikat błędu:
Expecting ArrayList:
[READ]
to contain:
[WRITE]
but could not find the following element(s):
[WRITE]
Zamiast tracić czas na code review, warto takie coś zautomatyzować. Ale jak?
Operując na poziomie testów architektury – nie da się napisać dopasowanej reguły. Nie sądzę, żeby jakikolwiek framework, czy to Konsist, czy inny, dawał do tego wygodne API.
Można podejść do problemu inaczej. Da się zły kod wyłapać prostym wyrażeniem regularnym. Nie każdy, ale zgodnie z zasadą Pareto: nie zawsze warto rozwiązywać 100% przypadków.
Wychodzi tu ciekawa cecha Konsista, że daje dostęp do niskopoziomowych danych – pozwala wziąć kod funkcji albo klasy jako string i uruchamiać na nim wyrażenia regularne. Czyli możemy potraktować go też jako framework do pisania linterów za pomocą regexów. Niby nic odkrywczego, ale czegoś takiego nie znalazłem ani do Javy, ani do Kotlina.
val regex = Regex(".*\\.contains.+(isEqualTo|isTrue|isFalse).*") scope.functions() .filter { it.resideInSourceSet("test") } .assertFalse(additionalMessage = "Use assertThat(obj).contains(e)") { regex.containsMatchIn(it.text) }
Nie jest to może najwygodniejszy test – nie drukuje wykrytych linijek, a jedynie nazwę całej funkcji. Lepsze jednak to niż nic.
Od frameworku dostajemy możliwość ignorowania miejsc, gdzie nasze wyrażenie regularne łapie nie to, co trzeba:
@Suppress("konsist.lackOfAssertJContains")
Kod musi pasować do globa
Załóżmy, że dla oszczędności pieniędzy i czasu konfigurujemy CI tak, żeby pewne testy były uruchamiane tylko, gdy zostały zmienione określone pliki.
full-database-test: rules: - if: changes: paths: - module1/src/main/kotlin/**/migration/** - module2/src/main/kotlin/**/migration/**
Kto będzie pamiętał, żeby zaktualizować CI po dodaniu kolejnego modułu? Nikt. Lepiej użyjmy bardziej elastycznego globa:
paths: - '*/src/main/kotlin/**/migration/**'
Wszystko jest pięknie, jesteśmy i oszczędni i bezpieczni. Do momentu, gdy ktoś napisze nowy plik i nie wstawi go tam, gdzie oczekujemy.
Jak sobie z tym radzić? Napiszemy test architektury, który zrobi się czerwony, gdy coś, co wygląda jak migracja, nie będzie umieszczone w */src/main/kotlin/**/migration/**
.
Na pewno znajdzie się ktoś, kto będzie narzekał, że ten test jest nic nie warty, bo mamy copy-paste globa między plikiem CI a testem. Według mnie jest to test good enough. Mamy wiedzę skopiowaną tylko raz. Tylko 2 miejsca, które trzeba aktualizować. Test służy jako dokumentacja, czemu migracje tworzymy w jednym, „magicznym” miejscu. Jest to wiedza zapisana wprost, wersjonowana w repozytorium, a nie zamknięta w głowie jakiegoś seniora, który odrzuca code review pisząc „przesuń migrację w dobre miejsce”.
Podobne problemy z couplingiem kodu i nie-kodu powstają nie tylko, gdy zachodzi prezentowana wyżej zależność „konfiguracja CI kontra katalogi w kodzie”. Różnego rodzaju frameworki wymagają podawania pakietów, które będą skanowane, i te pakiety wpisane do konfiguracji mogą „rozjechać” się z prawdziwym, żyjącym kodem. Mamy pakiety w module-info.java
, w YAML-u Spring Boota itp. itd.
Implementacja dla globu dotyczącego ścieżek plików wygląda tak:
// copied from full-database-test val glob = "*/src/main/kotlin/**/migration/**" val pathMatcher = FileSystems.getDefault().getPathMatcher("glob:$glob") scope.classes(includeNested = false, includeLocal = false) .assertTrue(additionalMessage = "Should be in $glob") { pathMatcher.matches(Paths.get(it.projectPath.removePrefix("/"))) }
Co mnie zdziwiło, biblioteka standardowa Javy ma zaszytą implementację globów, nie trzeba żadnych zależności, nie jest to też nic dodanego przez Kotlina. Od zawsze, czyli od Javy 1.7 – tu dokumentacja getPathMatcher.
Podsumowanie
Konsist jest narzędziem o bogatym API i pozwala na użycie go na wiele różnych sposobów.
Możemy bawić się w architektów i pisać testy wysokopoziomowe: wymagać, by pewne funkcje albo klasy były niepubliczne (na przykład internal
). Liczyć warstwy, ustalać, by klasy o pewnej adnotacji były umieszczone w pakietach o określonej nazwie.
Lub też podejść inaczej, skupić się na automatyzowaniu code review. Zrobić listę importów, których nie wolno używać – wyżej pokazywałem przykład zakazywania asercji z jednej biblioteki, by wymusić używanie asercji z innej.
Właśnie: można zakazać importowania pewnego kodu, ale nie da się tak zakazać jego wołania. O ile konkurencja (ArchUnit) śledzi, jakie metody są wołane, to w Konsiście nie ma dostępu do takich danych.
Można jeszcze użyć Konsista do kontroli, jak są używane niektóre elementy składni Kotlina – zakazać deklarowania publicznych funkcji bez podania wprost typu, zakazać zapisywania funkcji jako expression body (po znaku =). Wymagać, by companion object był deklarowany na końcu (przykład z oficjalnej strony).
Da się zejść jeszcze bardziej nieskopoziomowo, spojrzeć na kod źródłowy jako surowy tekst i pisać reguły operujące jak typowy linter. Pokazywałem przykład z zakazami zapisywanymi wyrażeniem regularnym.
Z moich obserwacji, API Konsista jest dość intuicyjne i szybko da się wymyślić, jak zapisać regułę, którą mamy w głowie. Testy są krótkie i czytelne.
Jak pisałem we wstępie, szykuję artykuł porównujący ArchUnit i Konsist – tam napiszę, jaka jest moja opinia o przydatności obu narzędzi.
Grafika: Nuvola icons, CC BY-SA 3.0.