CodeGym /Kursy /Spring Test /AssertJ: czytelny styl weryfikacji

AssertJ: czytelny styl weryfikacji

Spring Test
Poziom 3, Lekcja 0
Dostępny

1. Ból podstawowych weryfikacji JUnit 6

Jeśli dopiero zacząłeś pisać testy, to assertEquals, assertTrue i assertFalse wydają się całkowicie wystarczające. Ale gdy tylko sprawdzasz nie „1 + 1 = 2", ale zachowanie realnego kodu biznesowego — nawet całkiem małego — liczba weryfikacji szybko rośnie. I nagle test zaczyna wyglądać nie jak scenariusz, ale jak raport księgowy.

Wyobraźmy sobie bardzo prosty wynik: dostaliśmy slug artykułu (w ContentHub slug jest potrzebny do URL i powinien być czytelny). Niby zwykły łańcuch. Ale nawet tutaj zazwyczaj chce się sprawdzić więcej niż jedną właściwość: że slug równa się oczekiwanej wartości, zaczyna się od potrzebnego prefiksu, nie zawiera spacji i nie jest pusty. Na „gołych" JUnit-assertach szybko zamienia się to w rozsypkę oddzielnych wywołań, w której trudno wzrokiem uchwycić sens.

Oto przykład w stylu „czysty JUnit 6". Nie jest zły — po prostu… głośny:

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

class SlugFormatTest {

    @Test
    void slug_is_readable_for_url() {
        // Faktyczna wartość, którą sprawdzamy (actual)
        String slug = "java-basics";

        // Sprawdzamy oczekiwaną wartość w całości
        Assertions.assertEquals("java-basics", slug);

        // Sprawdzamy osobne właściwości, ale wyglądają jak niezależne stwierdzenia
        Assertions.assertTrue(slug.startsWith("java"));
        Assertions.assertFalse(slug.contains(" "));
    }
}

Problem nie w tym, że kod jest błędny. Problem w tym, że czyta się jak „trzy niezależne stwierdzenia", a nie jak „jedno oczekiwane zachowanie". A gdy taki test padnie, komunikat o błędzie jest często zbyt ogólny: widzisz, że assertTrue padł, ale domyślnie nie zawsze wiadomo, co dokładnie sprawdzałeś i dlaczego to ważne.

I właśnie tutaj pojawia się AssertJ: pozwala pisać weryfikacje jako spójne stwierdzenie o wyniku, a nie jako zestaw oddzielnych „pingów" w stronę Assertions.*.

2. AssertJ jako podstawowy styl kursu

AssertJ — to biblioteka assertions z fluent-API, czyli z „łańcuchami" wywołań. Dzięki temu weryfikacje czytają się prawie jak zdanie w ludzkim języku. „Prawie" — bo to nadal Java, a nie polski. Ale postęp jest zauważalny: test staje się bliższy opisowi zachowania niż zestawowi technicznych sprawdzeń.

Ważna praktyczna uwaga: w typowym projekcie Spring Boot (i w naszym szkolnym ContentHub też) AssertJ jest zazwyczaj już w zależnościach testowych, bo przynosi go spring-boot-starter-test. Czyli nie musisz „ręcznie składać stosu testowego" jak w RPG. Wystarczy po prostu rozumieć, jak go używać.

Dlaczego właśnie AssertJ stanie się „językiem domyślnym" w kursie:

Po pierwsze, łańcuch fluent pomaga utrzymać skupienie na sensie: zaczynasz od faktycznej wartości i dalej dowieszasz oczekiwania. Po drugie, komunikaty o błędach w AssertJ są zazwyczaj bardziej informatywne: biblioteka stara się pokazać faktyczną wartość, oczekiwaną i kontekst. Po trzecie, ten styl dobrze się skaluje: te same zasady działają zarówno dla łańcuchów, jak i dla kolekcji, zagnieżdżonych obiektów i JSON. Nie trzeba za każdym razem przełączać „sposobu pisania".

Od razu zaznaczmy: JUnit-asserty nigdzie nie znikają. Ale w tym kursie ustalamy prostą zasadę: jeśli nie wiesz, czym sprawdzać — zacznij od AssertJ.

3. Punkt wejścia: assertThat(actual)

Gdy ludzie po raz pierwszy widzą AssertJ, często myślą: „Okej, nowa biblioteka — a więc nowy zestaw magicznych metod". Naprawdę kluczowa idea jest bardzo prosta: zawsze zaczynasz od assertThat(...), do którego przekazujesz wartość faktyczną, a dalej opisujesz, jaka powinna być.

I tu ważne jest zapamiętanie jednej „żelaznej" zasady, która ratuje przed głupimi błędami: do assertThat(...) wkładasz actual, czyli to, co wyszło z kodu. Nie expected. Nie „jak powinno być". Ale to, co realnie wyszło po części Act testu.

Przykład z minimalną konfiguracją (pokazuję od razu z importami, żeby nie wydawało się, że assertThat to jakaś telepatia):

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class AssertJIntroTest {

    @Test
    void assert_that_starts_from_actual_value() {
        // Arrange: przygotowaliśmy przykładowy wynik
        String slug = "java-basics";

        // Assert: zaczynamy od actual (slug), a dalej opisujemy oczekiwanie
        assertThat(slug).isEqualTo("java-basics");
    }
}

Czyta się to tak: „Twierdzę, że slug równa się "java-basics"". I dalej na tym można budować łańcuch.

Jeśli kiedykolwiek łapałeś się na tym, że piszesz assertEquals(expected, actual) i stale mylisz kolejność argumentów, to AssertJ jakby mówi: „Pozwól, że tu usunę szansę na pomyłkę". W AssertJ kolejność jest bardziej naturalna: najpierw faktyczna wartość, potem oczekiwania.

4. Łańcuchy fluent dla powiązanych weryfikacji

Najbardziej przyjemna rzecz w AssertJ — możliwość „sklejenia" kilku weryfikacji w jeden łańcuch, jeśli dotyczą jednego obiektu znaczeniowego. Na przykład slug powinien równać się oczekiwanej wartości, zaczynać się od potrzebnego słowa i nie zawierać spacji. To trzy weryfikacje, ale wszystkie dotyczą tego samego: „slug nadaje się do URL i spełnia regułę".

Oto ten sam przykład ze slugiem, ale już w stylu AssertJ:

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class SlugFormatAssertJTest {

    @Test
    void slug_is_readable_for_url() {
        // Actual: wynik, który sprawdzamy
        String slug = "java-basics";

        // Jeden łańcuch = jeden sens: "slug nadaje się do URL"
        assertThat(slug)
                .isEqualTo("java-basics")   // dokładna wartość
                .startsWith("java")         // potrzebny prefiks
                .doesNotContain(" ");       // brak spacji
    }
}

Tutaj test czyta się jak mała historia: „slug równa się…, zaczyna się od…, nie zawiera…". Od razu widać, że wszystkie weryfikacje dotyczą tego samego.

Teraz przykład z liczbami. W ContentHub załączniki mogą mieć ograniczenia: na przykład „nie więcej niż 3 załączniki na artykuł" (dokładna liczba nie jest teraz ważna; ważne, że jest limit). Numeryczne weryfikacje w JUnit często zamieniają się w assertTrue(count <= 3), i brzmi to jak „gdzieś tam porównaliśmy coś z czymś". AssertJ daje bardziej czytelną formę.

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class AttachmentLimitsTest {

    @Test
    void attachment_count_is_within_limit() {
        // Przykładowa faktyczna liczba załączników
        int attachmentCount = 2;

        // Sprawdzamy sens (ograniczenie), a nie „wzór"
        assertThat(attachmentCount)
                .isPositive()               // liczba nieujemna
                .isLessThanOrEqualTo(3);    // nie przekracza limitu
    }
}

Tu już widać, że sprawdzamy nie „wzór", ale sens: liczba jest dodatnia i nie przekracza limitu.

I jeszcze jeden mały przykład — boolean. Czasem w kodzie biznesowym jest flaga i chcesz dosłownie przeczytać test jako „powinno być prawdą". AssertJ pozwala to zrobić bez poczucia, że czytasz matematykę.

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class BooleanAssertionsTest {

    @Test
    void article_can_be_published_flag_is_true() {
        // Actual: flaga z logiki biznesowej (w przykładzie — po prostu stała)
        boolean canBePublished = true;

        // Assert: czyta się prawie jak zdanie
        assertThat(canBePublished).isTrue();
    }
}

Tak, to wygląda trywialnie. Ale gdy takich weryfikacji jest wiele, „ludzki" styl zaczyna oszczędzać uwagę. A uwaga to najdeficytowa waluta programisty (po kawie).

Ważne, żeby nie przesadzić: w jeden łańcuch fluent warto łączyć tylko powiązane znaczeniowo weryfikacje. Jeśli zacząłeś sprawdzać slug, a potem nagle status artykułu, a potem jeszcze liczbę załączników — to już trzy różne sensy i łańcuch zamienia się w kaszę. AssertJ daje wygodę, ale nie znosi zdrowego rozsądku.

5. .as(...) i zrozumiałe padnięcia

Gdy test pada, zazwyczaj widzisz dwie rzeczy: stacktrace i komunikat o błędzie. Stacktrace w testach rzadko cieszy duszę, dlatego „jakość życia" mocno zależy od tego, jak zrozumiały komunikat dostałeś.

AssertJ pozwala dodać opis weryfikacji przez .as(...). To nie zmienia logiki, ale sprawia, że padnięcie jest bardziej konkretne: zamiast „expected X, but was Y" zobaczysz, co dokładnie sprawdzałeś i w jakim kontekście.

Wyobraźmy sobie, że sprawdzamy status nowego artykułu. W ContentHub według scenariusza nowy artykuł tworzony jest jako DRAFT. Nawet jeśli test jest na razie całkowicie zabawkowy, opis sprawia, że jest bliższy dziedzinie.

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class ArticleStatusTest {

    @Test
    void new_article_starts_as_draft() {
        // Actual: status zwrócony przez kod (w przykładzie — po prostu łańcuch)
        String status = "DRAFT";

        // Opis pomaga zrozumieć padnięcie: co dokładnie sprawdzaliśmy
        assertThat(status)
                .as("status nowego artykułu")
                .isEqualTo("DRAFT");
    }
}

Sens .as(...) ujawnia się szczególnie, gdy test staje się dłuższy i pojawia się kilka weryfikacji. Jeśli jedna z nich padnie, od razu zobaczysz w komunikacie „status nowego artykułu", a nie będziesz zgadywać, co to była za zmienna status i dlaczego w ogóle się tu pojawiła.

Jest niuans: opis powinien być dziedzinowy. Jeśli napiszesz "test" lub "check", to nie pomoże. Dobry opis odpowiada na pytanie „co to jest za wartość w domenie": status artykułu, slug do URL, limit załączników, nazwa kategorii i tak dalej.

6. AssertJ na przykładach ContentHub

Język weryfikacji lepiej ćwiczyć na „żywych" przykładach domeny, a nie na bezimiennych foo/bar. Dlatego weźmy trzy typowe rzeczy z ContentHub: slug, status artykułu i sprawdzenie prostego ograniczenia.

Przykład: minimalny SlugService i test

Żeby przykład był samowystarczalny, zróbmy mały serwis. Nie jest „idealny", jest szkolny: zamienia łańcuch na małe litery, obcina krawędzie i zastępuje spacje myślnikami. Najważniejsze, że mamy obiekt zwracający wynik, i ten wynik można sensownie sprawdzać.

class SlugService {

    String toSlug(String title) {
        // Szkolna implementacja: normalizujemy wielkość liter i tworzymy „przyjazny URL" łańcuch
        return title.toLowerCase()
                .trim()
                .replace(" ", "-");
    }
}

A teraz test. Zwróć uwagę: nie ograniczamy się do isEqualTo, ale sprawdzamy „właściwości przydatności do URL". Na tym polega siła łańcuchów fluent: jeden sens — kilka warunków.

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class SlugServiceTest {

    @Test
    void toSlug_makes_lowercase_and_uses_hyphens() {
        // Arrange: tworzymy serwis
        SlugService slugService = new SlugService();

        // Act: dostajemy faktyczny wynik (actual)
        String slug = slugService.toSlug("Java Basics");

        // Assert: sprawdzamy kilka powiązanych wymagań do slug
        assertThat(slug)
                .as("slug artykułu do URL")
                .isEqualTo("java-basics")     // oczekiwany format
                .doesNotContain(" ")          // spacji być nie może
                .doesNotStartWith("-");       // zabezpieczamy się przed dziwnym prefiksem
    }
}

Tutaj robimy już trochę więcej niż proste porównanie na równość. I to jest przydatne: jeśli implementacja serwisu się zmieni (na przykład ktoś zapomni trim()), test złapie problem po sensie, a nie tylko „czy łańcuch się zgadza".

Przykład: status artykułu jako część reguły

Powiedzmy, że mamy enum statusów (w projekcie z pewnością będzie, ale teraz robimy mały szkielet):

enum ArticleStatus {
    // Możliwe stany artykułu w domenie
    DRAFT,
    IN_REVIEW,
    PUBLISHED,
    REJECTED,
    ARCHIVED
}

I powiedzmy, że nowy artykuł domyślnie tworzony jest jako DRAFT. Znowu: przykład szkolny, ale bliski rzeczywistości.

class Article {
    // Przechowujemy status jako część stanu encji
    private final ArticleStatus status;

    Article(ArticleStatus status) {
        // W realnym projekcie tu zazwyczaj są jeszcze niezmienniki i walidacja
        this.status = status;
    }

    ArticleStatus getStatus() {
        // Getter — to, czego użyje test (actual bierzemy stąd)
        return status;
    }
}

Test z AssertJ pozostaje prosty i czytelny:

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class ArticleTest {

    @Test
    void new_article_is_draft() {
        // Arrange/Act: tworzymy artykuł ze znanym statusem
        Article article = new Article(ArticleStatus.DRAFT);

        // Assert: porównujemy z oczekiwaną wartością z domeny
        assertThat(article.getStatus())
                .as("status artykułu przy tworzeniu")
                .isEqualTo(ArticleStatus.DRAFT);
    }
}

Dlaczego to dobry przykład właśnie do lektury o AssertJ? Bo pokazuje kluczową zasadę: assertThat dostaje actual — to, co zwrócił kod — a dalej formułujemy oczekiwanie w terminach domeny, a nie w stylu „gdzieś tam porównaliśmy dwie sztuki".

Przykład: proste ograniczenie jako czytelna weryfikacja

Weźmy ograniczenie rozmiaru pliku, ale bez systemu plików i bez I/O. Niech metoda przyjmuje rozmiar i mówi, czy jest dopuszczalny. Umownie: do 5 MB można.

class AttachmentValidationService {

    boolean isSizeAllowed(long sizeBytes) {
        // Górna granica rozmiaru (5 MB)
        long max = 5 * 1024 * 1024;

        // Reguła: rozmiar nie może być większy niż limit
        return sizeBytes <= max;
    }
}

Test i tu wychodzi czytelniej, jeśli sprawdzamy nie „wzór", ale fakt:

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class AttachmentValidationServiceTest {

    @Test
    void size_is_allowed_when_under_limit() {
        // Arrange
        AttachmentValidationService service = new AttachmentValidationService();

        // Act: sprawdzamy mały rozmiar
        boolean allowed = service.isSizeAllowed(1024);

        // Assert: chcemy dostać "tak"
        assertThat(allowed)
                .as("mały plik powinien być dozwolony")
                .isTrue();
    }
}

Tak, ktoś powie: „No tu można było assertTrue(allowed)". Można. Ale po pierwsze, ćwiczymy jednolity styl. Po drugie, .as(...) sprawia, że padnięcie jest sensowne. I po trzecie, nawyk pisania assertThat wszędzie daje potem równomierny, jednorodny zestaw testów.

7. AssertJ w Arrange–Act–Assert

Czasem wydaje się, że jeśli assertions będą ładne, test automatycznie stanie się dobry. Prawie tak, ale jest niuans: dobry test to nie tylko „co sprawdzamy", ale i „jak do tego doszliśmy". Dlatego przy AAA nadal się trzymamy, a AssertJ używamy jako najlepszej opcji dla trzeciej części — Assert.

Przyjrzyjmy się poniższemu testowi: jest podzielony komentarzami na trzy bloki znaczeniowe. To nie jest konieczne, ale nowicjuszom pomaga się nie pogubić. A łańcuch AssertJ na końcu sprawia, że weryfikacja jest kompaktowa i skupiona.

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class SlugServiceAaaTest {

    @Test
    void toSlug_generates_url_friendly_value() {
        // Arrange
        SlugService slugService = new SlugService();

        // Act
        String slug = slugService.toSlug("  Spring Boot  ");

        // Assert
        assertThat(slug)
                .as("slug powinien nadawać się do URL")
                .isEqualTo("spring-boot")
                .doesNotContain(" ");
    }
}

Co jest tu ważne metodycznie: test czyta się od góry do dołu jak scenariusz. Nie skaczesz wzrokiem między assertTrue, assertFalse i assertEquals, próbując złożyć ogólny obraz. Od razu widzisz, że wynik slug powinien spełniać kilka powiązanych właściwości, i leżą one obok siebie — w jednym łańcuchu.

Nawiasem mówiąc, to jeszcze jedna ukryta zaleta łańcuchów fluent: pomagają zauważyć, że zacząłeś sprawdzać za dużo. Jeśli łańcuch urósł do 12 warunków, to albo sprawdzasz kilka różnych sensów naraz (pora dzielić na kilka assertions), albo sam test stał się zbyt szeroki (pora upraszczać scenariusz).

8. Ściągawka weryfikacji

Gdy zaczynasz używać AssertJ, mózg najpierw prosi „listę wszystkich metod". To normalna reakcja, ale bezużyteczna: biblioteka jest duża i i tak nie zapamiętasz wszystkiego od razu. Dużo bardziej przydatne jest trzymanie pod ręką małego zestawu „codziennych" weryfikacji, które pokrywają 80% przypadków na starcie kursu.

Typ wyniku Przykłady weryfikacji w AssertJ Mini-przykład
Łańcuchy isEqualTo, contains, startsWith, endsWith, doesNotContain, isBlank, isNotBlank assertThat(slug).startsWith("java").doesNotContain(" ");
Liczby isZero, isPositive, isNegative, isLessThan, isLessThanOrEqualTo, isGreaterThan, isBetween assertThat(count).isPositive().isLessThanOrEqualTo(3);
boolean isTrue, isFalse assertThat(allowed).isTrue();
null / not null isNull, isNotNull assertThat(article).isNotNull();

Ta tabela to nie „prawda ostateczna", ale startowy zestaw, z którym już można pisać zrozumiałe testy. Wszystko pozostałe dodawaj stopniowo, w miarę komplikowania weryfikacji, żeby nauka nie zamieniała się w encyklopedię.

9. Typowe błędy przy przejściu na AssertJ

Błąd nr 1: do assertThat(...) wkładają expected zamiast actual.
To klasyczna pomyłka po assertEquals(expected, actual). W AssertJ wszystko zaczyna się od faktu: sprawdzasz to, co wyszło. Jeśli włożyłeś expected do assertThat, test może stać się bezsensowny — lub co gorsza, „zawsze zielony", bo faktycznie sprawdzasz oczekiwanie samo na siebie. Lekarstwo jest proste: powiedz sobie na głos „assertThat — to jest «twierdzę, że ta oto wartość…»".

Błąd nr 2: w jeden łańcuch sklejają niezwiązane znaczeniowo weryfikacje.
Łańcuch fluent nie oznacza „piszemy wszystko po kolei jednym pociągiem". Jeśli sprawdzasz slug, status i limit załączników w jednym łańcuchu, nie robisz testu kompaktowym — robisz go nieczytelnym. Łańcuch jest dobry, gdy wszystkie warunki opisują jeden i ten sam aspekt jakości jednego wyniku. Jeśli sensów jest kilka, lepiej zrobić kilka oddzielnych assertions, każde ze swoim .as(...).

Błąd nr 3: .as(...) używają jako dekoracyjnej naklejki "test".
.as("test") lub .as("check something") nie pomaga. Opis powinien być dziedzinowy: „slug artykułu do URL", „status nowego artykułu", „limit liczby załączników". Wtedy gdy test padnie, zobaczysz w komunikacie właśnie ten sens, który chciałeś chronić. Jest to szczególnie ważne w dużym zestawie testów, gdzie podobnych weryfikacji jest wiele.

Błąd nr 4: AssertJ używają jako „zwykłego zamiennika assertEquals".
Jeśli piszesz assertThat(x).isEqualTo(y) i to wszystko — zrobiłeś już krok do przodu pod względem czytelności, ale nie skorzystałeś z silnej strony biblioteki. AssertJ jest dobry tym, że pozwala sprawdzać właściwości wyniku. Na przykład dla slug przydatne jest sprawdzanie nie tylko równości, ale i braku spacji, prawidłowego formatu, braku dziwnych prefiksów. Czyli utrwalasz zachowanie, a nie tylko konkretną wartość.

Błąd nr 5: test staje się „zbyt sprytny" i zaczyna sprawdzać za dużo.
AssertJ ma bardzo wiele metod i jest pokusa, żeby sprawdzić wszystko: i startsWith, i endsWith, i matches, i długość, i jeszcze dziesięć warunków „na wszelki wypadek". W efekcie test staje się kruchy: zmienia się format wyjścia — i spada połowa zestawu testów, choć realne ryzyko nie wzrosło. Dobra weryfikacja to ta, która chroni scenariusz przed realnym defektem, a nie ta, która jest „maksymalnie rygorystyczna dla rygorystyczności".

1
Zadanie
Spring Test, poziom 3, lekcja 0
Niedostępne
Łańcuch AssertJ dla slug
Łańcuch AssertJ dla slug
1
Zadanie
Spring Test, poziom 3, lekcja 0
Niedostępne
Liczba i boolean przez AssertJ
Liczba i boolean przez AssertJ
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION