JUnit 5: DynamicTest i czytelne testy o jasnej strukturze

DynamicTest/@TestFactory

Jedną z najlepszych zmian wprowadzonych przez wersję 5 JUnita są według mnie testy dynamiczne. Można z ich pomocą pisać testy parametryzowane. Jednak DynamicTest/TestFactory to coś więcej niż skopiowanie funkcjonalności znanej z TestNG czy Spocka. Siłą mechanizmu z JUnita jest, że mamy pełną programistyczną kontrolę nad produkowaniem przypadków testowych. Wcale nie trzeba o dynamicznym teście myśleć jako o liście czy tabelce danych. Niżej pokażę, jak ten mechanizm może dać krótszy i łatwiejszy do zrozumienia kod testowy. Jest w stanie pogrupować powiązane testy w lepszy sposób niż zagnieżdżony test @Nested.

Podstawowe użycie

Zanim powstał JUnit 5, dużo osób wybierało alternatywne narzędzia jak TestNG czy Spock, w których łatwo było pisać testy parametryzowane. JUnit w wersji 4 radził sobie z tym kiepsko, tymczasem u konkurencji pisało się coś wyglądającego jak tabelka i otrzymywaliśmy kod, gdzie dane były wyraźnie oddzielone od logiki testu. Dzięki temu zmniejszał się copy-paste: asercje tworzyło się raz a dobrze, a przypadki testowe można było dopisywać w miarę potrzeb, i to skupiając się na danych. Jeśli ktoś chce sobie przypomnieć historię: w TestNG był @DataProvider, w Spocku sekcja where:.

Analogiczny przypadek w JUnicie 5 wygląda tak:

@TestFactory
fun endpointAccessTests(): List<DynamicTest> =
    listOf(
        Triple(ADMIN, GET, 200),
        Triple(ADMIN, POST, 200),
        Triple(REPORTER, GET, 200),
        Triple(REPORTER, POST, 403),
    ).map { (role, method, expectedStatus) ->
        dynamicTest("returns $expectedStatus on $method by $role") {
            val user = setUpUserWithRole(role)
            callEndpoint(user, method)
                .andExpect().status().isEqualTo(expectedStatus)
        }
    }

„Tabelkę” piszemy w czystej Javie/Kotlinie. Przetwarzamy ją na testy sami: w przeciwieństwie do tamtych frameworków, tu nie ma ukrytych mechanizmów podrzucających magicznie parametry do naszej metody testowej. Czyste lambdy.

Możemy też odejść od „tabelkowego” myślenia, nie wyliczać wprost przypadków testowych, tylko generować je naprawdę dynamicznie. O, na przykład tak:

@TestFactory
fun disallowedRoles(): List<DynamicTest> =
    (Role.values().toList() - Role.ADMIN).map { role ->
        dynamicTest("disallows $role to call endpoint") {
            callEndpoint(role)
                .andExpect().status().isForbidden()
        }
    }

Da się w ten sposób pisać testy w duchu Property-Based Testing.

Pułapki

Jeśli wołamy kod, po którym trzeba sprzątać, DynamicTest może zrobić nam kuku. Możemy być przyzwyczajeni do tego, że czyszczenie załatwia nam metoda z adnotacją @BeforeEach:

@BeforeEach
fun setUp() {
    println("cleaning database")
}

@Test
fun `testing something else`() {
    println("inserting something else")
}

@TestFactory
fun tests() =
    (1..3).map {
        dynamicTest("adding only $it") {
            println("inserting $it")
            println("expecting only $it")
        }
    }

Testy dynamiczne zachowują się inaczej. Before/AfterEach są odpalane tylko na poziomie całego TestFactory. Nie dostajemy automatycznego czyszczenia między przypadkami testowymi.

cleaning database
inserting something else
cleaning database
inserting 1
expecting only 1
inserting 2
expecting only 2
inserting 3
expecting only 3

Czyli gdy dynamiczny test modyfikuje stan, trzeba samemu zadbać o czyszczenie. Na przykład ręcznie wołając to, co normalnie robi adnotacja:

        dynamicTest("adding only $it") {
            setUp()
            println("inserting $it")

Trzeba też uważać na używane adnotacje i zwracany typ. Metoda definiująca testy dynamiczne musi mieć adnotację @TestFactory i nie może być void. Co najgorsze, pomyłka kompiluje się i nie powoduje błędu w wykonaniu. Dostajemy martwy kod, który udaje test, ale nigdy nie zostanie odpalony:

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

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

Niestety, nawet IntelliJ nie jest zbyt pomocny w ostrzeganiu przed takimi błędami. Polecam ustawić odpowiednie reguły w ArchUnicie (napisałem o tym osobny post).

DynamicTest na poprawę czytelności

Skoro wyjaśniliśmy sobie podstawy, przejdźmy do głównego tematu artykułu. Testy dynamiczne to więcej niż testy parametryzowane. W tych drugich mamy zmienny zestaw danych, ale stały kod testowy operujący na nich. Do tej pory w przykładach trzymaliśmy się tej zasady. Pora ją złamać.

Tradycyjny, zły test

Zanim weźmiemy się do łamania zasad, warto przyjrzeć się kodowi, który ich przestrzega, ale nie wychodzi na tym dobrze. Załóżmy, że testujemy logikę, która zwraca dane, ale w zależności od roli odpytującego użytkownika ukrywa niektóre pola.

W poniższym przykładzie danymi są tickety. Każdy może zobaczyć kategorię ticketu, ale kilka innych pól ma ograniczoną widoczność, przykładowo rating widzi tylko administrator.

@Test
fun `returns limited fields to Reporter`() {
    ticketRepository.save(ticket(category = "Account"))

    val ticket = ticketService.findAccessible(reporter("joe")).first()
    assertThat(ticket.assignees).isNull()
    assertThat(ticket.priority).isNull()
    assertThat(ticket.rating).isNull()
    assertThat(ticket.category).isEqualTo("Account")
}

@Test
fun `returns limited fields to to Manager of other category`() {
    ticketRepository.save(ticket(priority = 3))

    val ticket = ticketService.findAccessible(manager("Other")).first()
    assertThat(ticket.priority).isEqualTo(30)
    assertThat(ticket.category).isEqualTo("General")
    assertThat(ticket.assignees).isNull()
    assertThat(ticket.rating).isNull()
}

@Test
fun `returns limited fields to to Manager of the category`() {
    ticketRepository.save(
        ticket(category = "General", assignees = listOf("kim"), priority = 5)
    )

    val managerTicket = ticketService.findAccessible(manager("General")).first()
    assertThat(managerTicket.priority).isEqualTo(50)
    assertThat(managerTicket.category).isEqualTo("General")
    assertThat(managerTicket.assignees).containsExactly("kim")
    assertThat(managerTicket.rating).isNull()
}

@Test
fun `returns all fields to Admin`() {
    ticketRepository.save(
        ticket(category = "Account", assignees = listOf("tom"), rating = 4, priority = 2)
    )

    val ticket = ticketService.findAccessible(admin()).first()
    assertThat(ticket.category).isEqualTo("Account")
    assertThat(ticket.priority).isEqualTo(20)
    assertThat(ticket.assignees).containsExactly("tom")
    assertThat(ticket.rating).isEqualTo(4)
}

To nie jest kod pisany przez kogoś, kto nie ma pojęcia o pisaniu testów. Ma przejrzyste asercje, nie jest zaśmiecony nieistotnymi szczegółami. Mimo to, trudno ten kod zrozumieć i utrzymać.

Jeśli nie znam logiki biznesowej i chcę potraktować ten test jako specyfikację, która pozwoli mi wyrobić sobie „big picture” znaczenia ról, muszę wykonać nielichą mentalną gimnastykę

  • Nie widać, że te testy są związane ze sobą, bo wizualnie zbyt się różnią; powyżej i poniżej są pewnie inne metody testowe, skąd mam wiedzieć, że mam czytać od tego do tamtego miejsca?
  • Ciężko mi porównać np. co jest zwracane administratorowi w porównaniu z menedżerem, bo kolejność asercji jest różna.
  • Czy w ogóle dane wejściowe są porównywalne między metodami? Do bazy wpisywane są zupełnie inne wartości, wyglądają inaczej.
  • Czy test roli Reporter nie zapomina czasem sprawdzić pola widocznego tylko dla administratora? Nie wiem, metody są tak daleko, że muszę skakać po pliku, nie mieszczą mi się na raz na ekranie.

Zanim ktoś zacznie protestować, że nie ma nic złego w skakaniu po pliku. Owszem, to zło, bo to kod niekompatybilny z ludzkim mózgiem. Ludzka pamięć podręczna ma małą pojemność i nieporozumieniem jest próba porównywania kodu na ekranie z czymś, co przywołujemy z pamięci, choćby było widziane kilka sekund wcześniej. Polecam poczytanie o swoim wbudowanym narzędziu pracy w Pragmatic thinking and learning (opisywałem krótko) lub innych dobrych źródłach.

„Ale mam zajebiście duży monitor, do tego postawiony pionowo, do tego mała czcionka, widzę pińcet linii kodu na raz”. Co z tego? Nie piszesz kodu dla siebie, ktoś inny w zespole nie ma. „Otworzę sobie split view, albo zmienię na chwilę kolejność metod, wtedy zobaczę koło siebie”. Naprawdę, będziesz stawać na głowie za każdym razem, gdy trzeba zabrać się za czytanie podobnego kawałka kodu? Nie lepiej napisać raz a dobrze i potem zawsze czytać bez problemu?

Inny, pewnie zazwyczaj mało istotny problem, to czas wykonania testu. Nie odpalam żadnego kodu, który by modyfikował bazę danych, ale uparcie przed każdym przypadkiem testowym czyszczę bazę i wstawiam do niej dane wejściowe. Po co? Jeśli mamy powiązane logicznie przypadki testowe, lepiej byłoby raz wstawić do bazy dane, a w pojedynczym przypadku skupić się na wołaniu testowanego kodu i asercjach, bez czyszczenia i testowania danych pomiędzy. Nie jest to zazwyczaj palący problem, bo w typowym teście ogarnianie bazy danych zajmuje czas rzędu kilkunastu milisekund.

Bardziej może boleć czytelność setupu bazy. Kod powyżej jest mocno uproszczony. W prawdziwym życiu ustawienie odpowiednich danych może zajmować kilka linijek, obejmować kilka tabelek. Metody testowe, które wyżej są krótkie i zgrabne, rozrosną się nieprzyjemnie. Jeszcze trudniej będzie znaleźć podobieństwa i różnice w powiązanych ze sobą metodach. Przy rozwoju systemu zmieniają się pola – zmiany trzeba będzie nanosić w kilku miejscach.

Właśnie, podobieństwa i różnice. Jeśli kod testu ma być czytelny, powinien uwypuklać istotne różnice (co jest zwracane różnym rolom), a usuwać różnice nieistotne (dane dla menedżera ustawiamy w jednej linijce, a dla administratora w trzech; u menedżera w bazie czeka priorytet 5, a u administratora 2). Dużo z powyższych problemów by znikło, gdybyśmy ustawili dane raz, a potem skupili się tylko na różnych asercjach dla różnych ról. Ale jak to zapisać?

Grupowanie przez @Nested

Jeśli ktoś czytał user guide JUnita 5 i czyta „powiązane” testy albo „pogrupowane” testy, być może zapala mu się lampka w głowie: grupowane znaczy zagnieżdżone. Robimy nested test i będzie dobrze! Nie do końca, ale po kolei.

Faktycznie, zagniedżony test pozwoli nam uwidocznić logiczne powiązanie kilku metod testowych. Można też ustawić jeden stan bazy dla kilku testów, bez potrzeby powtarzania setupu. Będzie to wyglądać mniej więcej tak:

@SpringBootTest
class TicketServiceNestedTest(
    private val ticketRepository: TicketRepository,
    private val ticketService: TicketService,
) {
    @Nested
    @TestInstance(PER_CLASS)
    inner class RoleAccess {
        @BeforeAll
        fun setUp() {
            ticketRepository.deleteAll()
            ticketRepository.save(
                ticket(category = "General", assignees = listOf("kim"), rating = 4, priority = 2)
            )
        }

        @Test
        fun `returns limited fields to Reporter`() {
            val ticket = ticketService.findAccessible(reporter("joe")).first()
            assertThat(ticket.category).isEqualTo("General")
            assertThat(ticket.priority).isNull()
            assertThat(ticket.assignees).isNull()
            assertThat(ticket.rating).isNull()
        }

Tyle, że nested test jest bardzo inwazyjny. Jeśli mamy inne metody testowe, będziemy chcieli, żeby były niezależne i startowały z czystą bazą i istotnymi dla siebie danymi testowymi. Trzeba jest w takim razie zgrupować w inny nested test, w którym zamiast BeforeAll mamy BeforeEach. Wykonujemy więc niesławne shotgun surgery: chcieliśmy jedynie zrobić małą zmianę, zgrupować parę powiązanych metod, a okazało się, że zmieniamy każdą linijkę pliku, bo trzeba nagle wprowadzać dodatkowe zagnieżdżenie.

@Nested
@TestInstance(PER_CLASS)
inner class RoleAccess {
    @BeforeAll
    fun setUp() {
        cleanDatabase()
        ticketRepository.save(
            ticket(category = "General", assignees = listOf("kim"), rating = 4, priority = 2)
        )
    }

    @Test
    fun `returns limited fields to Reporter`() {
        val ticket = ticketService.findAccessible(reporter("joe")).first()
        assertThat(ticket.category).isEqualTo("General")
        assertThat(ticket.priority).isNull()
        assertThat(ticket.assignees).isNull()
        assertThat(ticket.rating).isNull()
    }

    @Test
    fun `returns limited fields to to Manager of other category`() {
        val ticket = ticketService.findAccessible(manager("Other")).first()
        assertThat(ticket.category).isEqualTo("General")
        assertThat(ticket.priority).isEqualTo(20)
        assertThat(ticket.assignees).isNull()
        assertThat(ticket.rating).isNull()
    }

    @Test
    fun `returns limited fields to to Manager of the category`() {
        val ticket = ticketService.findAccessible(manager("General")).first()
        assertThat(ticket.category).isEqualTo("General")
        assertThat(ticket.priority).isEqualTo(20)
        assertThat(ticket.assignees).containsExactly("kim")
        assertThat(ticket.rating).isNull()
    }

    @Test
    fun `returns all fields to Admin`() {
        val ticket = ticketService.findAccessible(admin()).first()
        assertThat(ticket.category).isEqualTo("General")
        assertThat(ticket.priority).isEqualTo(20)
        assertThat(ticket.assignees).containsExactly("kim")
        assertThat(ticket.rating).isEqualTo(4)
    }
}

@Nested
inner class OtherTests {
    @BeforeEach
    fun setUp() {
        cleanDatabase()
    }

    @Test
    fun `other test`() {
    }
}

private fun cleanDatabase() {
    ticketRepository.deleteAll()
}

Grupowanie przez DynamicTest

Testy dynamiczne pozwalają czerpać te same korzyści z grupowania powiązanych przypadków testowych, jak przy testach @Nested, lecz bez takiej inwazyjności i z bardziej zwięzłą składnią.

@TestFactory
fun roleAccess(): List<DynamicTest> {
    ticketRepository.save(
        ticket(category = "General", assignees = listOf("kim"), rating = 4, priority = 2)
    )

    val reporter = dynamicTest("returns limited fields to Reporter") {
        val ticket = ticketService.findAccessible(reporter("joe")).first()
        assertThat(ticket.category).isEqualTo("General")
        assertThat(ticket.priority).isNull()
        assertThat(ticket.assignees).isNull()
        assertThat(ticket.rating).isNull()
    }
    val otherManager = dynamicTest("returns limited fields to to Manager of other category") {
        val ticket = ticketService.findAccessible(manager("Other")).first()
        assertThat(ticket.category).isEqualTo("General")
        assertThat(ticket.priority).isEqualTo(20)
        assertThat(ticket.assignees).isNull()
        assertThat(ticket.rating).isNull()
    }
    val manager = dynamicTest("returns limited fields to to Manager of the category") {
        val ticket = ticketService.findAccessible(manager("General")).first()
        assertThat(ticket.category).isEqualTo("General")
        assertThat(ticket.priority).isEqualTo(20)
        assertThat(ticket.assignees).containsExactly("kim")
        assertThat(ticket.rating).isNull()
    }
    val admin = dynamicTest("returns all fields to Admin") {
        val ticket = ticketService.findAccessible(admin()).first()
        assertThat(ticket.category).isEqualTo("General")
        assertThat(ticket.priority).isEqualTo(20)
        assertThat(ticket.assignees).containsExactly("kim")
        assertThat(ticket.rating).isEqualTo(4)
    }
    return listOf(reporter, otherManager, manager, admin)
}

Pisałem wcześniej o problemie, że BeforeEach nie jest odpalane między testami dynamicznymi i konieczności ręcznego radzenia sobie z tym. Zachowanie, które przy tamtym kodzie wyglądało jak bug, tu jest dla nas pomocne. To dobrze, że BeforeEach wyczyści nam bazę raz przed wszystkimi powiązanymi testami, ale nigdy pomiędzy nimi.

Kod jest bardzo zwięzły: przypadki testowe nie są przedzielane odstępami i adnotacjami, możemy bardziej skupić się na istotnej logice. Z oryginalnych 47 linii zeszliśmy do 36.

Kolejna korzyść: dużo miejsca oszczędzamy, gdy trzeba coś zapamiętać. Załóżmy, że każdy test musi znać ID ticketu. ID jest generowany przez bazę. Trzeba go zapisać na etapie setupu testu. W testach dynamicznych – nie ma problemu, „extract variable” w IDE, robimy zmienną lokalną. Deklaracja i ustawienie wartości w jednej linijce. W @Nested – trzeba dodać pole, potem w @BeforeAll przypisać mu wartość. 2 razy więcej kodu, utrudnione czytanie, bo w miejscu deklaracji pola nie widać, skąd bierze się wartość.

DynamicTest/@TestFactory

Podsumowanie

Testy dynamiczne w JUnicie to bardzo elastyczny mechanizm. Nie tylko pozwala zaimplementować testy parametryzowane. Umożliwia też zgrupowanie powiązanych testów w sposób mniej inwazyjny niż typowe testy zagnieżdżone (@Nested).

Zgrupowane testy będą zaczynały z tym samym stanem bazy. W każdym przypadku testowym wynikiem będzie co innego, ale wiedząc, że stan wejściowy był za każdym razem ten sam, łatwiej dostrzeżemy różnice i upewnimy się, gdzie są podobieństwa.

Największe korzyści odniesiemy, gdy testujemy kod typu query, nie typu command. Kod typu query nie zmienia nic w stanie, nie potrzeba nam czyszczenia miedzy przypadkami testowymi. Dodanie takiego czyszczenia byłoby kłopotliwe przy testach dynamicznych.

Możemy takim grupowaniem oszczędzić odrobinę czasu wykonania: wyczyścimy bazę i ustawimy ją tylko raz na całą grupę metod testowych.

Nie namawiam do grupowania tak wszystkiego jak popadnie. To podejście użyteczne w określonym zastosowaniu: gdy można przyjąć jeden stan bazy odpowiedni dla całej grupy przypadków testowych i gdy takie grupowanie pozwoli lepiej dostrzec różnice i podobieństwa w przypadkach.