CodeGym /Kursy /Spring Test /AAA i czytelne testy w JUnit 6

AAA i czytelne testy w JUnit 6

Spring Test
Poziom 2, Lekcja 1
Dostępny

1. Czytelność testów — kwestia przetrwania

Czytelny test to nie estetyka i nie „lubię, gdy wszystko na swoim miejscu". To inżynierskie ubezpieczenie na wypadek, gdy test padł, deadline goni, a trzeba szybko zrozumieć: naprawdę złapał regresję, czy po prostu jest napisany tak, że sam sobie jest wrogiem. W takim momencie nie chcesz być archeologiem odkopującym test1().

W tworzeniu backendów testy czyta się częściej, niż się je pisze. Czyta się je, gdy coś się zepsuło, gdy trzeba zrozumieć zachowanie bez zagłębiania się w cały kod produkcyjny, gdy trwa review. I tak, czyta się je, gdy przypadkowo zmieniłeś nazwę pola w DTO i nagle posypało się wszystko, co było „trochę związane" z tym polem. Czytelność to szybki dostęp do sensu.

Przyjrzyjmy się mini-przykładowi, który jest formalnie działający, ale jako dokumentacja — no cóż. Nie o całym ContentHub, a o małej regule „czy można wysłać szkic do recenzji" — typowy temat w naszej domenie: draft → submit.

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertTrue;

class SubmitRuleBadTest {

    @Test
    void test1() {
        // Tu z kodu można jeszcze odgadnąć, co się sprawdza,
        // ale nazwa test1() nie mówi w ogóle niczego.
        assertTrue(canBeSubmitted("JUnit basics", "Text"));
    }

    private boolean canBeSubmitted(String title, String body) {
        // Najprostsza reguła: nie można wysłać szkicu z pustymi polami.
        return title != null && !title.isBlank()
                && body != null && !body.isBlank();
    }
}

Przejdzie. Ale za kilka tygodni otworzysz listę testów i zobaczysz test1(). Co on dowodzi? Że „coś działa". Świetnie. Z taką nazwą nie testujesz reguły — testujesz cierpliwość przyszłego siebie.

Główna idea tej lektury jest prosta: test to scenariusz zachowania, i trzeba go pisać tak, żeby czytał się jak scenariusz. Do tego będą nam potrzebne: zrozumiała struktura (Arrange–Act–Assert), dobre nazewnictwo i czasem grupowanie kontekstów przez @Nested.

2. Arrange–Act–Assert: szkielet scenariusza, który ratuje mózg

AAA to bardzo prosta, ale zadziwiająco potężna umowa o strukturze testu. Zamienia test z „kawałka kodu, który niby coś sprawdza" w krótką opowieść: najpierw przygotowaliśmy warunki, potem wykonaliśmy działanie, potem sprawdziliśmy wynik. Nawet jeśli test ma 8 linii, taka kolejność mocno zmniejsza szansę pogubienia się i przypadkowego sprawdzenia zupełnie nie tego, co zamierzałeś.

Jeśli szukać codziennej analogii, AAA to jak przepis. Najpierw wyciągnąłeś produkty (Arrange), potem ugotowałeś (Act), potem spróbowałeś i porównałeś z oczekiwaniem (Assert). Można oczywiście wlać mleko bezpośrednio do piekarnika i liczyć na najlepsze… ale zazwyczaj tak rodzą się błędy i dziwne zdjęcia w internecie.

Mała tabelka utrwalająca sens bloków:

Blok Co się w nim dzieje Na jakie pytanie odpowiada
Arrange Tworzymy dane i obiekt, który testujemy „W jakich warunkach sprawdzamy zachowanie?"
Act Wywołujemy jedną główną metodę/działanie „Co dokładnie robimy?"
Assert Sprawdzamy wynik przez assert... „Co powinno być prawdą po działaniu?"

Teraz przepiszmy regułę wysyłania do recenzji tak, żeby przypominała kawałek logiki ContentHub. Wprowadźmy najprostsze ArticleDraft i ArticleSubmitPolicy. Tak, na razie to mini-klasa zaraz obok testu, bo naszym celem jest teraz nie architektura aplikacji, ale czytelność testu.

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertTrue;

class ArticleSubmitPolicyAaaTest {

    @Test
    void canSubmit_returnsTrue_forCompleteDraft() {
        // Arrange: przygotowaliśmy prawidłowy (kompletny) szkic i regułę weryfikacji
        ArticleDraft draft = new ArticleDraft("JUnit basics", "Readable tests matter");
        ArticleSubmitPolicy policy = new ArticleSubmitPolicy();

        // Act: jedno główne działanie
        boolean result = policy.canSubmit(draft);

        // Assert: jedna główna weryfikacja wyniku
        assertTrue(result);
    }

    // Dane szkicu — to, z czym pracuje reguła (minimalny model domeny dla testu).
    record ArticleDraft(String title, String body) { }

    static class ArticleSubmitPolicy {
        boolean canSubmit(ArticleDraft draft) {
            // Reguła: oba pola muszą być niepuste.
            return draft.title() != null && !draft.title().isBlank()
                    && draft.body() != null && !draft.body().isBlank();
        }
    }
}

Zwróć uwagę na dwie rzeczy. Po pierwsze, blok Act to tutaj dosłownie jedno: policy.canSubmit(draft). To ważna dyscyplina: jeśli w Act robisz trzy wywołania z rzędu, test sprawdza już łańcuch zdarzeń, a nie jedną regułę — i będzie ciężej go naprawić. Po drugie, w Assert nie sprawdzamy „wszystkiego po kolei", ale patrzymy dokładnie na to, dla czego test był pisany: czy można wysłać.

AAA pomaga nawet tam, gdzie bardzo chce się wszystko zwinąć do jednej linii. Czasem rzeczywiście można to zrobić, ale dopóki się uczysz, lepiej trzymać strukturę jawnie. To jak z klamrami klamrowymi w Javie: o stylu można dyskutować, ale jeśli nie chcesz nocować w debugerze, umowy naprawdę pomagają.

3. Nazewnictwo testów: żeby bez telepatii

W nazewnictwie testów jest mały paradoks: gdy piszesz test, wydaje ci się, że wszystko jest oczywiste. Za dwa tygodnie — już nie. A za dwa miesiące patrzysz na swój własny test i myślisz: „Kim jest ten człowiek i dlaczego zostawił mi tę wiadomość?". Nazwa testu to pierwsza rzecz, która albo ratuje, albo dobija.

Zacznijmy od klasy testowej. Najprostszy i powszechnie przyjęty szablon to NazwaKlasyKtórąTestujemyTest. W ContentHub będzie to coś w stylu PublicationPolicyTest, SlugServiceTest, AttachmentValidationServiceTest. Nawet jeśli jeszcze nie znasz tych klas szczegółowo, sens jest już widoczny: klasa testowa grupuje scenariusze wokół jednego zachowania lub jednego obiektu.

Z nazwami metod testowych jest więcej swobody — i pokus też. Są dwa popularne podejścia, które świetnie działają w Javie bez dodatkowych frameworków. Pierwsze — „zachowanie + warunek + oczekiwany wynik", gdy metoda nazywa się na przykład canSubmit_returnsTrue_forCompleteDraft. Drugie — styl „when/then" z podkreśleniami, np. returnsTrue_whenDraftIsComplete. Oba warianty są prawidłowe. Najważniejsze — żeby z nazwy można było odtworzyć sens scenariusza.

Porównajmy „złą" i „dobrą" nazwę na tym samym zachowaniu. Sensy testów są identyczne, ale czyta się je zupełnie różnie.

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertFalse;

class NamingComparisonTest {

    @Test
    void test2() {
        // Nazwa nie komunikuje scenariusza: w raporcie będzie to po prostu "test2".
        assertFalse(isVisible("   "));
    }

    @Test
    void returnsFalse_whenTitleIsBlank() {
        // Nazwa od razu mówi, jakie zachowanie i przy jakim warunku jest sprawdzane.
        assertFalse(isVisible("   "));
    }

    private boolean isVisible(String title) {
        // „Widoczny" tytuł — to nie null i nie pusty łańcuch/spacje.
        return title != null && !title.isBlank();
    }
}

Druga nazwa od razu mówi: „zwraca false, gdy tytuł jest pusty". Nawet nie otwierając ciała testu, już rozumiesz, co jest sprawdzane. W pierwszej nazwie nie ma nic poza numerem, który nie niesie sensu. To nie test, ale numer inwentarzowy w księgowości.

Teraz przypiszmy to do domeny ContentHub. W projekcie stale powtarzają się słowa: draft, submit, approve, reject, archive, published. To nasz słownik. Dobry nawyk — używać tych samych terminów w testach, żeby brzmiały jak dokumentacja domeny, a nie zestaw przypadkowych „doStuff".

Przykład nazewnictwa pod domenę: zamiast „changesStateCorrectly" lepiej napisać „draftCanBeSubmitted".

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertTrue;

class ArticleWorkflowVocabularyTest {

    @Test
    void draftCanBeSubmitted() {
        // Terminy draft/submit w teście pokrywają się z terminami domeny.
        assertTrue(canSubmitDraft("Title", "Body"));
    }

    private boolean canSubmitDraft(String title, String body) {
        // Uproszczona reguła przykładowa: dwa pola muszą być wypełnione.
        return title != null && !title.isBlank()
                && body != null && !body.isBlank();
    }
}

Tak, reguła tutaj uproszczona, ale kluczem są słowa. Jeśli cały projekt mówi „draft" i „submit", a testy — „entity" i „process", w jednym repozytorium pojawiają się dwa różne języki. A to już krok w kierunku bałaganu.

Jeszcze jedna ważna zasada: nie próbuj nazwać testu tak, żeby opisywał cały wszechświat. Test nie musi być powieścią. Musi być uczciwym szyldem. „co sprawdzamy" + „w jakim kontekście" + „czego oczekujemy" — zazwyczaj więcej niż wystarczy.

4. @DisplayName: tekst dla człowieka

Nawet dobra nazwa metody czasem wygląda jak kompromis. Identyfikator Java bez spacji szybko zamienia się w „camelCase-telegram". A w raporcie o testach chce się widzieć normalną frazę — jak w specyfikacji lub dokumentacji. Do tego w JUnit istnieje @DisplayName.

Ważne, żeby pamiętać, że @DisplayName to nie zamiennik dobrej nazwy metody, ale wzmacniacz. Nazwa metody jest nadal potrzebna do nawigacji po kodzie: IDE szuka po canSubmit_returnsTrue_forCompleteDraft, a nie po „Szkic można wysłać do recenzji". Dlatego najlepsze połączenie — technicznie użyteczna nazwa metody plus ludzka fraza w @DisplayName.

Przykład. Ten sam scenariusz, ale z nazwą wyświetlaną:

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertTrue;

class DisplayNameExampleTest {

    @Test
    @DisplayName("Szkic z tytułem i treścią można wysłać do recenzji")
    void canSubmit_returnsTrue_forCompleteDraft() {
        // W raporcie testów będzie widać właśnie tę frazę z @DisplayName.
        assertTrue(canBeSubmitted("JUnit basics", "Text"));
    }

    private boolean canBeSubmitted(String title, String body) {
        // Reguła z przykładu: oba pola są wymagane i nie mogą być puste.
        return title != null && !title.isBlank()
                && body != null && !body.isBlank();
    }
}

Co zyskujesz? Gdy test padnie, w raporcie zobaczysz sens, a nie techniczną nazwę. To szczególnie przydatne, gdy testów jest wiele i patrzysz na listę nieudanych scenariuszy jak na log problemów, a nie spis metod Java.

Ale jest i pułapka: @DisplayName łatwo zamienić w wysypisko ćwiczeń literackich. Jeśli napiszesz coś w stylu „Ten test sprawdza, że przy obecności niepustego tytułu i niepustej treści, które spełniają wszystkie wymagania, artykuł może być wysłany…", czytanie tego będzie ciężkie. Dobry @DisplayName jest zazwyczaj krótki i konkretny.

Jeszcze jedna praktyczna idea: @DisplayName szczególnie przydatny jest, gdy masz kilka podobnych scenariuszy i chcesz, żeby wyglądały jak mała specyfikacja. Na przykład: „Pusty tytuł jest zabroniony", „Pusta treść jest zabroniona", „Kompletny szkic jest dozwolony". Brzmi już jak mini-dokument.

5. @Nested: grupujemy scenariusze według kontekstu

Dopóki testów jest mało, płaska lista metod jest w porządku. Ale gdy pojawia się kilka kontekstów, lista zaczyna przypominać szufladę z skarpetkami: niby wszystko przydatne, ale znalezienie tego, co trzeba, już nie takie proste. @Nested w JUnit rozwiązuje ten problem: pozwala grupować testy we zagnieżdżone klasy, gdzie każda zagnieżdżona klasa to osobny kontekst.

Kluczowe słowo tu — kontekst, a nie „ładna strukturka". @Nested jest potrzebny wtedy, gdy można uczciwie powiedzieć: „w tym zestawie testów rozważamy ten sam obiekt, ale w różnych stanach lub trybach". W ContentHub takich kontekstów jest morze: artykuł może być w statusie DRAFT, IN_REVIEW, PUBLISHED, i reguły się różnią. Ale w tej lekturze nie wchodzimy w prawdziwą macierz biznesową — ćwiczymy na prostej regule wysyłania.

Przykład, gdzie @Nested rozdziela „kompletny szkic" i „niekompletny szkic". Jest też techniczny niuans, o który łatwo się potknąć: klasy z @Nested to zwykłe non-static inner classes. JUnit używa ich właśnie jako kontekstów, a nie jako jakiegoś osobnego rodzaju pliku testowego. Przy okazji pokażemy, że @DisplayName można stawiać też na zagnieżdżonych klasach, żeby drzewo w raporcie czytało się maksymalnie łatwo.

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

@DisplayName("Reguła wysyłania artykułu do recenzji")
class ArticleSubmitPolicyNestedTest {

    // Wspólna zależność dla wszystkich scenariuszy w tej klasie (ten sam obiekt zachowania).
    private final ArticleSubmitPolicy policy = new ArticleSubmitPolicy();

    @Nested
    @DisplayName("Kompletny szkic")
    class CompleteDraft {

        @Test
        @DisplayName("Dozwolony do wysłania")
        void canBeSubmitted() {
            // Kontekst: oba pola są wypełnione.
            assertTrue(policy.canSubmit("Title", "Body"));
        }
    }

    @Nested
    @DisplayName("Niekompletny szkic")
    class IncompleteDraft {

        @Test
        @DisplayName("Zabroniony, jeśli brak tytułu")
        void cannotBeSubmitted_withoutTitle() {
            // Kontekst: tytuł pusty.
            assertFalse(policy.canSubmit("   ", "Body"));
        }

        @Test
        @DisplayName("Zabroniony, jeśli brak treści")
        void cannotBeSubmitted_withoutBody() {
            // Kontekst: treść pusta.
            assertFalse(policy.canSubmit("Title", "   "));
        }
    }

    static class ArticleSubmitPolicy {
        boolean canSubmit(String title, String body) {
            // Ta sama reguła, ale w postaci serwisu/polityki, a nie „narzędzia" wewnątrz testu.
            return title != null && !title.isBlank()
                    && body != null && !body.isBlank();
        }
    }
}

Jeśli uruchomisz te testy, drzewo w raporcie będzie wyglądało mniej więcej tak: najpierw „Reguła wysyłania artykułu do recenzji", wewnątrz — „Kompletny szkic" i „Niekompletny szkic", a tam już konkretne scenariusze. Czyli testy zaczynają się czytać jak mini-specyfikacja: „w kompletnym szkicu można, w niekompletnym — nie można".

Jest bardzo ważne ograniczenie: @Nested nie powinien zamieniać się w matrioszkę na dziesięć poziomów. Jeśli masz „Kontekst → Podkontekst → Jeszcze podkontekst → I w końcu test", przegrałeś. Zagnieżdżenie powinno upraszczać, a nie komplikować. Zazwyczaj 1–2 poziomy — to już przydatne i więcej niż wystarczy.

Dokumentacja zachowania dla ContentHub

Gdy łączysz AAA, dobre nazewnictwo, @DisplayName i @Nested, dostajesz już nie „zestaw weryfikacji", ale całkiem czytelną dokumentację zachowania. Dla ContentHub jest to szczególnie ważne, bo w domenie publikacji artykułów jest wiele reguł „można/nie można" i wiele przejść między stanami. Scenariusze powinny być widoczne bezpośrednio w testach, inaczej pakiet testowy szybko zamienia się w „niby wszystko zielone, ale co dokładnie sprawdzamy — zagadka".

Z punktu widzenia czytelnika idealna sytuacja wygląda tak: otwierasz klasę ArticleSubmitPolicyTest, widzisz konteksty (szkic kompletny/niekompletny), otwierasz potrzebny kontekst, czytasz jeden test — i od razu rozumiesz, co się w nim dzieje. AAA daje porządek wewnątrz metody, @DisplayName sprawia, że raport jest zrozumiały dla człowieka, a @Nested pomaga zbudować testy tak, żeby ich struktura powtarzała strukturę reguł.

Przyjrzyjmy się ostatni raz małemu „wzorcowemu" testowi-metodzie w stylu tej lektury. Jest krótki, ale jest w nim i sens, i struktura, i ludzka nazwa.

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertFalse;

class ArticleTitleRuleTest {

    @Test
    @DisplayName("Pusty tytuł nie jest uznawany za widoczny")
    void isVisible_returnsFalse_whenTitleIsBlank() {
        // Arrange
        String title = "   ";

        // Act
        boolean visible = isVisible(title);

        // Assert
        assertFalse(visible);
    }

    private boolean isVisible(String title) {
        // Reguła widoczności tytułu (minimalny przykład do demonstracji AAA).
        return title != null && !title.isBlank();
    }
}

Zwróć uwagę: test czyta się od góry do dołu jak mała historia, i jest w nim prawie zero „technicznego szumu". Właśnie taki poziom jasności chcemy zamienić w nawyk, zanim pójdziemy dalej — ku nowym narzędziom i bardziej złożonym warstwom aplikacji.

6. Typowe błędy przy AAA, @DisplayName, @Nested i nazewnictwie

Błędy w czytelności testów rzadko wyglądają jak „kompilator narzeka". Objawiają się później: testy są trudne w utrzymaniu, trudno rozumieć przyczynę padnięcia, pojawiają się duplikaty, a część testów zaczyna sprawdzać nie zachowanie, ale przypadkowe szczegóły implementacji. Dlatego ten blok nie o zasadach dla zasad, ale o najczęstszych pułapkach.

Błąd nr 1: AAA jest formalnie, ale w rzeczywistości wszystko wymieszane.
Często piszą komentarze // Arrange, // Act, // Assert, ale w Arrange już wywołują metody, w Act nagle robią dodatkowe przygotowanie, a w Assert dalej coś wyliczają. Same komentarze niczego nie ratują — sens AAA polega na tym, że działanie jest jawnie wyodrębnione i nie ginie w przygotowaniu.

Błąd nr 2: w Act kilka „głównych działań".
Jeśli w jednym teście tworzysz artykuł, potem go wysyłasz do recenzji, potem go publikujesz — to już nie „jeden test o jednej regule". Taki test będzie długi, kruchy i będzie padał z tysiąca przyczyn. Na poziomie podstawowego JUnit lepiej trzymać formułę: jeden test — jedno działanie — jeden oczekiwany wynik.

Błąd nr 3: nazwa testu opisuje implementację, a nie zachowanie.
Nazwy jak callsValidatorAndReturnsTrue() brzmią „technicznie", ale prawie nic nie mówią o domenie. Z czasem zmienisz implementację (na przykład zamiast walidatora pojawi się inny obiekt), a nazwa zacznie kłamać. Dużo trwalej pisać, co powinno się wydarzyć, w terminach domeny: draftCanBeSubmitted, blankTitleIsRejected.

Błąd nr 4: @DisplayName używane jako proteza zamiast porządnych nazw.
Czasem metoda testowa nazywa się x() lub ok(), a cała nadzieja pokłada się w @DisplayName. To złe dla nawigacji: w kodzie i tak będziesz szukał metody, a nie display name. Normalna praktyka — zrobić sensownymi oba poziomy: metodę jako identyfikator, @DisplayName jako ludzką frazę dla raportu.

Błąd nr 5: @Nested używany dla „piękna", a nie dla kontekstu.
Zagnieżdżone klasy dla samych zagnieżdżonych klas szybko zamieniają się w labirynt. @Nested powinien odpowiadać na pytanie „jaki to kontekst?". „Kompletny szkic" — kontekst. „Testy numer 2" — nie kontekst. Jeśli zagnieżdżenie nie dodaje sensu, lepiej zostawić płaskie testy.

1
Zadanie
Spring Test, poziom 2, lekcja 1
Niedostępne
Grupowanie scenariuszy według statusu artykulu
Grupowanie scenariuszy według statusu artykulu
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION