CodeGym /Kursy /Spring Test /Hamcrest: matchers i kiedy jest stosowny

Hamcrest: matchers i kiedy jest stosowny

Spring Test
Poziom 3, Lekcja 3
Dostępny

1. Rola Hamcrest obok AssertJ

Gdy już zaprzyjaźniłeś się z AssertJ, łatwo pomyśleć: „Po co w ogóle kolejna biblioteka weryfikacji? Już umiem assertThat(...).isEqualTo(...)". To normalna reakcja. Ale w ekosystemie testowania Java jest historyczna warstwa, która nigdzie nie zniknęła: stare testy, stare przykłady w internecie i fragmenty API testowego Spring do dziś „rozmawiają" Hamcrestem.

Hamcrest — to nie „bezpośredni konkurent AssertJ", ale inny pogląd na formułowanie weryfikacji. Jeśli AssertJ — to zazwyczaj łańcuch warunków na faktycznej wartości, to Hamcrest — obiekt-warunek (matcher), który opisuje oczekiwanie. A główna praktyczna konsekwencja jest taka: matcher można przekazać do innego API. Dlatego Hamcrest jest szczególnie wygodny tam, gdzie framework prosi „daj mi warunek weryfikacji", a nie „napisz weryfikację sam".

W ramach kursu robimy AssertJ stylem domyślnym, bo łatwiej czyta się go nowicjuszom i lepiej nadaje do większości weryfikacji unit. Ale Hamcrest i tak ci się przytrafi, i naszym celem jest to, żebyś w tym momencie nie patrzył na test jak na starożytny manuskrypt po łacinie.

2. Idea Hamcrest: matcher jako warunek

Zanim napiszemy kod, przydatne jest dosłownie powiedzieć na głos: w Hamcrest weryfikacja składa się z faktycznej wartości i matcher'a, który opisuje oczekiwanie. To podobne do sytuacji, gdy przynosisz do laboratorium próbkę i mówisz: „Sprawdźcie, czy przechodzi test na…". W AssertJ częściej sam „robisz test" krok po kroku. W Hamcrest częściej „przekazujesz kryterium".

Podstawowa forma wygląda zazwyczaj tak:

jest actual — to, co wyszło,
jest matcher — to, czego chcemy,
jest assertThat(actual, matcher).

Pokażmy to na bardzo prostej rzeczy z domeny ContentHub — na slug. Slug powinien nadawać się do URL: bez spacji, z myślnikami i w ogóle czytelny.

import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;

class SlugHamcrestTest {

    @Test
    void slug_contains_dash() {
        String slug = "java-basics";

        // Jawnie używamy MatcherAssert.assertThat(...), żeby nie mylić z assertThat z AssertJ.
        // (W kursie JUnit używany jest w wersji 6, styl testu ten sam.)
        MatcherAssert.assertThat(slug, Matchers.containsString("-")); // slug powinien zawierać myślnik
    }
}

Tutaj containsString("-") — to matcher, który można traktować jako „warunek: łańcuch zawiera myślnik". Ważne zauważyć, że piszemy MatcherAssert.assertThat(...), a nie po prostu assertThat(...): tak z góry ratujemy się od pomyłki z AssertJ, gdzie też jest assertThat.

Hamcrest często wygląda bardziej „funkcjonalnie": nie „dowieszasz" warunków łańcuchem, ale składasz oczekiwanie jako obiekt i stosujesz do wartości.

3. Jak czytać kod Hamcrest: „fakt → matcher → sens"

Czytanie Hamcrest wielu wydaje się trudne dokładnie do momentu, gdy wyrobisz nawyk czytania go według jednego i tego samego schematu. Schemat jest bardzo prosty: najpierw patrzymy, co sprawdzamy, potem — jakim kryterium, i w głowie budujemy zdanie: „actual powinien odpowiadać matcher'owi".

Żeby to utrwalić, przydatne jest zobaczenie tego nawet jako mini-diagramu:

flowchart LR
    A[wartość actual] --> B[Matcher: warunek]
    B --> C[assertThat]
    C --> D{OK?}
    D -->|tak| E[test zielony]
    D -->|nie| F[zrozumiały komunikat o błędzie]

W realnym projekcie ContentHub takich weryfikacji będzie dużo. Na przykład mamy listę statusów publikacji (lub dowolną inną kolekcję) i ważne jest dla nas, że jest we właściwej kolejności.

import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;

import java.util.List;

class StatusOrderHamcrestTest {

    @Test
    void statuses_have_expected_order() {
        List<String> statuses = List.of("DRAFT", "IN_REVIEW", "PUBLISHED");

        // contains(...) w Hamcrest dla kolekcji — to weryfikacja zarówno składu, jak i KOLEJNOŚCI elementów.
        MatcherAssert.assertThat(statuses, Matchers.contains("DRAFT", "IN_REVIEW", "PUBLISHED"));
    }
}

Tutaj matcher contains("DRAFT", "IN_REVIEW", "PUBLISHED") w Hamcrest oznacza „kolekcja zawiera elementy dokładnie w takiej kolejności". To ważny szczegół: wielu myli to z „contains gdzieś" z potocznego języka. W Hamcrest contains — to o kolejności. Jeśli kolejność nie jest ważna, są inne matchers (trochę później).

Jeśli nauczysz się tak czytać kod — najpierw „co sprawdzamy", potem „jaki warunek" — Hamcrest przestanie być straszny.

4. Potrzebne minimum matcherów

Bardzo chce się zamienić tę sekcję w encyklopedię: „a oto jeszcze matcher na to, a jeszcze na tamto". Ale nie budujemy kursu jako katalogu API. Nasze zadanie — dać ci mały i praktyczny zestaw, który pokrywa 80% zadań: łańcuchy, liczby, null i kolekcje. Resztę spokojnie dociągniesz z dokumentacji, gdy pojawi się realna potrzeba.

Poniżej — tabela-rozmównik, gdzie pokazano, jak wygląda warunek i jak się czyta:

Sens weryfikacji Matcher Hamcrest Jak to się czyta
Równość Matchers.equalTo(x) lub
Matchers.is(x)
„równa się x"
Nierówność Matchers.not(x) (zazwyczaj razem z
is(...)
)
„nie równa się x"
Podłańcuch Matchers.containsString("...") „zawiera fragment"
Początek łańcucha Matchers.startsWith("...") „zaczyna się od …"
Koniec łańcucha Matchers.endsWith("...") „kończy się na …"
null Matchers.nullValue() „wartość null"
Nie null Matchers.notNullValue() „wartość nie null"
Rozmiar kolekcji Matchers.hasSize(n) „rozmiar n"
Wszystkie elementy odpowiadają Matchers.everyItem(matcher) „każdy element odpowiada"
Liczba większa/mniejsza Matchers.greaterThan(n),
Matchers.lessThanOrEqualTo(n)
„większa od n", „nie większa od n"

A teraz pokażmy parę krótkich, „żywych" przykładów. Na przykład limit załączników: powiedzmy, że w naszej aplikacji na artykuł można załadować do 3 załączników.

import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;

class AttachmentLimitHamcrestTest {

    @Test
    void attachment_count_is_within_limit() {
        int attachmentCount = 2;

        // Reguła biznesowa: nie więcej niż 3 załączniki.
        MatcherAssert.assertThat(attachmentCount, Matchers.lessThanOrEqualTo(3));
    }
}

Jeszcze jeden przykład — sprawdzanie na null/nie null. W domenie ContentHub są sytuacje, gdy rejectionReason musi być wypełniony (na przykład jeśli artykuł jest odrzucony). Na razie nie budujemy tu pełnoprawnego modelu, ale sam styl weryfikacji warto zobaczyć:

import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;

class RejectionReasonHamcrestTest {

    @Test
    void rejection_reason_is_not_null() {
        String rejectionReason = "too-short";

        // Jeśli powód odrzucenia jest obowiązkowy — sprawdzamy, że jest rzeczywiście zadany.
        MatcherAssert.assertThat(rejectionReason, Matchers.notNullValue());
    }
}

Tak, w AssertJ wyglądałoby to jeszcze prościej, ale chodzi o coś innego: przyzwyczajasz się do matcherów jako do „warunków".

5. Kompozycja matcherów: allOf, anyOf, not

Hamcrest staje się ciekawszy, gdy zaczynasz łączyć warunki. To jedna z jego mocnych stron: matcher — obiekt, a obiekty można łączyć. Ale tu też pojawia się pułapka: jeśli bez miary wkładać matcher w matcher, kod zamienia się w matrioszkę z matcherów, którą trudno czytać nawet autorowi… szczególnie autorowi za dwa tygodnie.

Zacznijmy od przydatnego i prostego: allOf(...) oznacza „spełnione są wszystkie warunki". Wyobraźmy sobie, że chcemy sprawdzić, że slug:

zaczyna się od "java",
zawiera "-",
nie zawiera spacji.

import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;

class SlugRulesHamcrestTest {

    @Test
    void slug_is_readable_for_url() {
        String slug = "java-basics";

        // allOf(...) = muszą być spełnione WSZYSTKIE warunki.
        // not(...) tutaj — inwersja warunku (np. „nie zawiera spacji").
        MatcherAssert.assertThat(slug, Matchers.allOf(
                Matchers.startsWith("java"),
                Matchers.containsString("-"),
                Matchers.not(Matchers.containsString(" "))
        ));
    }
}

To już granica, gdzie Hamcrest zaczyna wyglądać trochę ciężej niż łańcuch AssertJ. Ale sens nadal się czyta: jeśli iść według schematu „wartość powinna odpowiadać matcher'owi", w głowie wychodzi proste zdanie — „slug powinien zaczynać się od java, zawierać myślnik i nie zawierać spacji".

anyOf(...) — to „przynajmniej jeden warunek". W domenie ContentHub może to być na przykład dopuszczalny stan do edycji (umownie: edytować można DRAFT lub REJECTED, ale nie PUBLISHED). Na razie nie testujemy realnych serwisów, tylko pokazujemy styl.

import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;

class EditableStatusHamcrestTest {

    @Test
    void status_is_editable() {
        String status = "DRAFT";

        // anyOf(...) = wystarczy, żeby zgadzał się CHOĆBY JEDEN z wymienionych warunków.
        MatcherAssert.assertThat(status, Matchers.anyOf(
                Matchers.is("DRAFT"),
                Matchers.is("REJECTED")
        ));
    }
}

Teraz o dyscyplinie. Kompozycja jest dobra, dopóki nie zaczynasz budować struktur drzewiastych na 5 poziomach zagnieżdżenia. Gdy matcher „rośnie", lepiej zatrzymać się i albo uprościć weryfikację, albo podzielić ją na kilka sensownych. Test powinien być zrozumiały, a nie „imponujący" (imponujący bywa zazwyczaj błąd, a nie test).

6. Hamcrest w Spring i w kursie

Hamcrest w testach Spring

W zwykłym teście unit, gdzie sprawdzasz zwracaną wartość metody, Hamcrest prawie zawsze można zastąpić AssertJ i nawet sprawić, żeby kod był przyjemniejszy. Ale Hamcrest ma konkretną „strefę bytowania", gdzie nie jest po prostu „może być", ale jest często już wbudowany: część API testowego Spring historycznie przyjmuje matchers.

Nie wchodzimy teraz głęboko w testowanie MVC (to osobny duży blok kursu), ale ważne jest wcześniejsze zrozumienie: później spotkasz sytuacje, gdzie metoda oczekuje „warunku" w postaci matcher'a. Na przykład w niektórych weryfikacjach pól JSON przez jsonPath(...) lub w weryfikacjach treści łańcuchowej odpowiedzi. I tam Hamcrest — to nie „o stylu zespołu", ale „o formie argumentu, której prosi API".

Żeby pokazać to na poziomie „rozpoznasz, gdy zobaczysz", oto bardzo mały fragment tego, jak Hamcrest zwykle wygląda wewnątrz testów Spring:

import org.hamcrest.Matchers;

class SpringStyleSnippet {

    void example() {
        // Ważne: to NIE jest pełnoprawny test, ale ilustracja formy matcher'a.
        // Później zobaczysz takie matchery w weryfikacjach JSON / warstwy web.
        // Kluczowa idea: matcher jest przekazywany do API jako argument-warunek.
        Matchers.is("DRAFT");
    }
}

Sens tego przykładu nie w tym, żeby teraz rozumieć testy Spring, ale w tym, żeby zobaczyć najważniejsze: w środowisku Spring często nie wywołujesz MatcherAssert.assertThat(...), ale po prostu przekazujesz matcher (is(...), containsString(...), hasSize(...)) do innej metody.

Czyli Hamcrest we współczesnym projekcie Spring — to często „język dla argumentów", a nie „główny język twoich weryfikacji".

Hamcrest nie jako styl domyślny

Uczciwie mówiąc, Hamcrest nie jest zły. Po prostu rozwiązuje trochę inne zadanie i przeciętnie jest cięższy do czytania przez osoby uczące się. Jeśli uprościć, to AssertJ wygrywa w większości codziennych testów dzięki „liniowemu czytaniu": assertThat(slug).startsWith("java").doesNotContain(" "). To prawie zdanie w ludzkim języku, i mózg „ślizga" się po nim bez trudu.

Hamcrest natomiast często prowokuje zagnieżdżenie: assertThat(slug, allOf(startsWith("a"), endsWith("b"))). W prostych przypadkach jest to normalne, ale przy komplikacji (szczególnie z kolekcjami obiektów) kod łatwo zamienia się w konstrukcję trudną w utrzymaniu. A nasz kurs jest orientowany na poziom junior, gdzie główną wartością jest czytanie testów szybko i pewnie, a nie umiejętność zbudowania najbardziej elegancką kompozycję matcherów.

Drugi powód — jednorodność zestawu testów. Budujemy kurs tak, żeby w głowie był jeden podstawowy styl. Inaczej typowy plik testowy zamienia się w sałatkę z AssertJ, Hamcrest i jeszcze kilku DSL. Sałatka jest oczywiście smaczna, ale czytać ją wzrokiem jest ciężko.

Trzeci powód jest całkowicie praktyczny i trochę komiczny: w świecie Java dwa różne assertThat w jednym projekcie — to klasyczna pułapka. I gdy dopiero się uczysz, nie potrzebujesz tracić sił na walkę z importami i konfliktami nazw. Niech mózg zajmuje się sensem testu, a nie rozgrzebywaniem „dlaczego kompilator jest obrażony".

7. Łączenie AssertJ i Hamcrest bez kaszy

Dobra wiadomość: nie musisz wybierać „albo-albo". Możesz trzymać AssertJ jako styl główny, a Hamcrest używać tam, gdzie jest to naprawdę wygodne lub wymagane przez API. Najważniejsze — robić to z dyscypliną, żeby testy nie wyglądały jak korespondencja dwóch szkół filozoficznych.

Najprostszy i prawie uniwersalny zabieg: nie importować Hamcrest assertThat statycznie. Dla AssertJ zazwyczaj robisz import static org.assertj.core.api.Assertions.assertThat;, i to jest wygodne. A dla Hamcrest zostaw jawne wywołanie MatcherAssert.assertThat(...). Wtedy wzrokiem widzisz, gdzie jaki styl, i kompilator nie każe ci grać w „zgadnij import".

Oto przykład „współistnienia" w jednej klasie, gdzie AssertJ pozostaje głównym, a Hamcrest używany jest punktowo:

import org.hamcrest.MatcherAssert;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;

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

class MixedStylesTest {

    @Test
    void keep_assertj_as_default() {
        String slug = "java-basics";

        // AssertJ — styl domyślny: czyta się liniowo, jak zdanie.
        assertThat(slug).startsWith("java").doesNotContain(" ");
    }

    @Test
    void use_hamcrest_when_matcher_is_needed() {
        String slug = "java-basics";

        // Hamcrest używamy punktowo: gdy potrzebny jest matcher jako obiekt/argument.
        MatcherAssert.assertThat(slug, Matchers.containsString("-"));
    }
}

Zwróć uwagę: nawet tutaj lepiej nie mieszać stylów w jednej krótkiej metodzie testowej bez potrzeby. Niech jedna metoda „mówi" jednym językiem. Inaczej test zaczyna wyglądać postrzępiony: najpierw łańcuch fluent, potem drzewo matcherów, potem znowu łańcuch fluent.

Jeszcze jeden przydatny zabieg — pamiętaj „tłumacza" między stylami. Gdy widzisz weryfikację Hamcrest, możesz w głowie przepisać ją na AssertJ, żeby szybciej zrozumieć sens. Na przykład:

containsString("basics") ≈ .contains("basics")
hasSize(3) ≈ .hasSize(3)
allOf(startsWith("a"), endsWith("b")) ≈ .startsWith("a").endsWith("b")

To nie dlatego, że trzeba pilnie przepisywać wszystko na AssertJ, ale dlatego że to dobra mentalna kula do czytania cudzych testów.

8. Typowe błędy przy pracy z Hamcrest

Błąd nr 1: importują „nie ten assertThat" i dostają konflikt stylów.
Najczęstszy scenariusz — robisz import static org.assertj.core.api.Assertions.assertThat; (co jest normalne), a potem przez przyzwyczajenie lub autocomplete IDE dodajesz import static org.hamcrest.MatcherAssert.assertThat;. W efekcie kod albo się nie kompiluje z powodu konfliktu, albo zaczniesz przypadkowo używać nie tego assertThat i dziwić się, że metody „zniknęły". Najspokojniejsza droga — dla Hamcrest pisać MatcherAssert.assertThat(...) jawnie, bez statycznego importu.

Błąd nr 2: zaczynają budować zbyt głęboko zagnieżdżone matchery.
Hamcrest pozwala na kompozycję i to super, ale kompozycja ma cenę: zagnieżdżenie zabija czytelność szybciej niż się wydaje. Gdy masz allOf(anyOf(...), not(allOf(...))), test przypomina rebusa. W tym momencie lepiej zatrzymać się i albo podzielić weryfikację na 2–3 prostsze, albo przyznać, że łańcuch fluent AssertJ będzie bardziej zrozumiały.

Błąd nr 3: robią z Hamcrest „główny styl" tylko dlatego, że trafił w przykładzie z internetu.
W sieci jest dużo przykładów testów Spring pisanych Hamcrestem, bo tak było przyjęte w starszych wersjach i bo niektóre API rzeczywiście przyjmują matchery. Ale to nie znaczy, że cały projekt trzeba pisać w tym stylu. W kursie ważne jest utrzymanie jednego podstawowego języka — AssertJ — i podłączanie Hamcrest punktowo.

Błąd nr 4: mylą sens matcherów dla kolekcji i dostają fałszywe oczekiwania.
Na przykład Matchers.contains(...) dla kolekcji sprawdza kolejność, a containsInAnyOrder(...) — nie. Jeśli nie rozróżniasz tych dwóch sensów, test albo będzie padał „nie wiadomo dlaczego", albo okaże się zbyt słaby i nie złapie realnej regresji. Przed wyborem matchera warto uczciwie odpowiedzieć: kolejność jest częścią zachowania czy nie?

Błąd nr 5: mieszają Hamcrest i AssertJ wewnątrz jednego krótkiego scenariusza bez powodu.
Technicznie jest to możliwe, ale test staje się wizualnie postrzępiony: część czyta się jak łańcuch fluent, część — jak drzewo matcherów. Gdy test pada, jest jeszcze trudniej „przeskanować" go wzrokiem i zrozumieć, co było sprawdzane. Lepiej trzymać jeden dominujący styl na metodę, a drugi podłączać tylko tam, gdzie jest naprawdę potrzebny.

1
Zadanie
Spring Test, poziom 3, lekcja 3
Niedostępne
Hamcrest dla stringa slug
Hamcrest dla stringa slug
1
Zadanie
Spring Test, poziom 3, lekcja 3
Niedostępne
Hamcrest dla listy statusów
Hamcrest dla listy statusów
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION