Dzielenie się testowymi klasami pomocniczymi w Gradle’u

Czasem mamy przypadek, że piszemy test jednostkowy jakiejś klasy i okazuje się, że część kodu testowego może być przydatna w innych częściach naszego projektu. Jak udostępnić ten kod, żeby był widoczny zarówno w testach aktualnego projektu, jak i w testach projektów od niego zależnych? Gradle od wersji 5.6 udostępnia dla tego celu plugin java-test-fixtures.

Jak może wyglądać nasz przypadek użycia? Mamy klasę produkcyjną, na przykład Email oraz jedną lub więcej z poniższych sytuacji:

  • będziemy w testach używać predefiniowanych testowych instancji (klasa TestEmails ze statycznymi polami typu Email)
  • będziemy w testach używać fabryki biorącej istotne pola i uzupełniającej pola mniej ważne (klasa TestEmailFactory)
  • będziemy mieli osobne testy na renderowanie różnego typu maili, udostępnimy klasę bazową (AbstractEmailTest) ułatwiającą pisanie takich testów
  • mamy powtarzalną logikę wołaną w testach, która omija zewnętrzne systemy dostępne tylko na produkcji (MailRenderingTestHelper)
  • dostarczamy „fałszywej” implementacji naszego serwisu, żeby unikać mocków składaniających do pisania słabych testów (klasa FakeMailSender)

Problem pojawia się, gdy klasa Email jest w jednym podprojekcie (załóżmy, że nazywa się core) a inne podprojekty (na przykład podprojekt analytics) używają jej zarówno w kodzie produkcyjnym, jak i do testowania własnych klas:

  • nie możemy klas pomocniczych umieścić w testach projektu core, bo w Gradle’u zależność od projektu nie bierze źródeł testowych – projekt analytics będzie widział klasę Email, ale na pewno nie EmailTest i spółkę
  • możemy umieścić testowe klasy pomocnicze w nowym podprojekcie core-test-utils – to zadziała, ale jeśli klasy, które można dzielić, są nie tylko w core, możemy niemalże podwoić liczbę podprojektów stosując wszędzie to podejście
zależności między podprojektami, gdy wprowadzamy core-test-utils
taki układ działa, ale wprowadza dużo komplikacji

java-test-fixtures

Wygodniejszym rozwiązaniem jest zastosowanie pluginu java-test-fixtures (jest wbudowany w Gradle’a). Tworzy on trzeci source set obok main i test nazwany testFixtures, gdzie będziemy umieszczać klasy, które mają być też widoczne na zewnątrz. Po dodaniu pluginu do podprojektu Idea zauważa dodatkowy source set (polecam auto-import Gradle’a) i jeśli na katalogu podprojektu wybierzemy New » Directory, Idea zasugeruje odpowiednią nazwę.

Okno "New Directory" po dodaniu pluginu java-test-fixtures
dodajemy źródła test fixtures dla podprojektu core

Upraszcza się konfiguracja: wcześniej musieliśmy powiązać core z core-test-utils i to w dwie strony. Teraz plugin załatwia zależność fixtures od głównego kodu core oraz testów core od fixtures i nie musimy tego zapisywać.

Przykład 1

Będziemy dzielić się klasą generującą testowe maile. Klasę umieszczamy w core/src/testFixtures/java/. Source set testFixtures ma własne zależności, odpowiednikiem implementation jest testFixtureImplementation, odpowiednikiem runtimeOnly jest testFixturesRuntimeOnly itp.

core/build.gradle:

plugins {
    id 'java-library'
    id 'java-test-fixtures'
}

dependencies {
    implementation 'javax.mail:javax.mail-api:1.6.2'

    testFixturesImplementation 'com.github.javafaker:javafaker:1.0.1'

    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.6.0'
}

analytics/build.gradle:

plugins {
    id 'java-library'
}

dependencies {
    implementation project(':core')

    testImplementation testFixtures(project(':core'))
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.6.0'
}

Przykład 2

Będziemy dzielić się klasą bazową dla testów. Test fixtures nie widzi zależności testowych, więc żeby móc użyć adnotacji JUnita, trzeba dodać taką zależność.

core/build.gradle:

plugins {
    id 'java-library'
    id 'java-test-fixtures'
}

dependencies {
    implementation 'javax.mail:javax.mail-api:1.6.2'

    testFixturesImplementation 'org.junit.jupiter:junit-jupiter-api:5.6.0'

    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.6.0'
}

Kotlin

Plugin java-test-fixtures wydaje się być świetnym rozwiązaniem dla użytkowników Kotlina, w którym (niestety) jedynym narzędziem do ograniczania widoczności jest problematyczny modyfikator internal.

Wyobraźmy sobie, że mamy w podprojekcie core klasę MailRenderer i nie chcemy, żeby wiedziały o niej inne podprojekty — chcemy mieć nieograniczoną swobodę w zmianie implementacji. Jeśli chodzi o kod „główny”, to MailRenderer jest odpowiednio ukryty i nigdzie nie wycieka, problemem są testy innych podprojektów, bo tam też jest potrzebny. Pomysł jest taki: będziemy go wołać jedynie wewnątrz pomocniczej klasy testowej MailRenderingTestHelper. Moglibyśmy w ten sposób zrobić klasę MailRenderer „wewnętrzną” dla core’a, a MailRenderingTestHelper udostępnić bez ograniczeń testom innych podprojektów.

Nie jesteśmy tego w stanie zaimplementować „tradycyjnym” podejściem. Klasa z modyfikatorem internal jest dostępna tylko w swoim podprojekcie (czyli core), nie będzie więc widziana w podprojekcie core-test-utils, gdzie znajdowałby się MailRenderingTestHelper.

Plugin java-test-fixtures powoduje, że zarówno MailRenderer jak i MailRenderingTestHelper umieszczamy w tym samym podprojekcie (pierwszą klasę w source secie main, drugą w source secie testFixtures) — wydaje się, że wszystko pięknie zadziała. Niestety, obecnie Kotlin odmówi kompilacji. Póki twórcy Kotlina nie naprawią KT-34901, nie mamy możliwości ukrywania widoczności klas wykorzystywanych we współdzielonych testowych klasach pomocniczych. Ułomność Kotlina w zarządzaniu widocznością kodu to zresztą temat, o którym długo można by pisać – może innym razem (aktualizacja: napisałem na ten temat tutaj).