CodeGym /Kursy /Spring Test /Granice warstwy unit i unit-target

Granice warstwy unit i unit-target

Spring Test
Poziom 5, Lekcja 0
Dostępny

1. Warstwa unit przed Spring

Jeśli choć raz uruchamiałeś aplikację Spring Boot na słabym laptopie, znasz tę medytację: „zanim kontekst wstanie, można zdążyć zaparzyć herbatę, zapomnieć, po co się ją parzyło, i zaparzyć ponownie". Testy unit są dokładnym przeciwieństwem: startują w milisekundy i sprawdzają to, co najczęściej się psuje przy zmianach w logice biznesowej. Dlatego w tym kursie warstwa unit zaczyna się przed Spring nie z ideologii, ale z pragmatyki.

Gdy mówimy „podnieść Spring", zazwyczaj mamy na myśli uruchomienie kontenera Spring (ApplicationContext), skanowanie beanów, autokonfigurację, ładowanie konfigów itp. To potężne, ale kosztowne. A reguły biznesowe najczęściej nie muszą zależeć od tego mechanizmu. Jeśli reguła „szkicu nie można publikować bezpośrednio" zależy od kontekstu Spring, to już nie reguła, ale architektoniczna tragedia.

Dlatego w ContentHub zaczynamy od klas, które można sprawdzić jako zwykłą Javę. Ich wartość polega na tym, że dają pewność co do logiki publikacji artykułu niezależnie od bazy danych, HTTP, serializacji, filtrów security i całej reszty infrastruktury.

Ważna myśl: warstwa unit nie jest „lepsza" od innych warstw. Jest po prostu najtańsza. Nie twierdzimy, że można nią udowodnić wszystko. Twierdzimy coś innego: głupio płacić za pełny kontekst, jeśli defekt można złapać na poziomie jednej reguły.

2. Test unit w tym kursie

Słowo „unit" często zamienia się w osobną filozofię: ludzie spierają się, co dokładnie uznawać za „unit" — metodę, klasę, moduł, podsystem czy w ogóle cały wszechświat. W tym kursie będziemy prościej: test unit — to test, który uruchamia kawałek logiki bezpośrednio w pamięci JVM, bez kontekstu Spring, bez prawdziwego I/O i bez zależności infrastrukturalnych. Sprawdza obserwowalny wynik: zwracaną wartość, zmieniony stan obiektu lub sensowną interakcję z kooperantem, jeśli jest to naprawdę ważne.

Żeby nie pozostawało poczucie „i tak nie wiadomo", ustalmy granicę na poziomie codziennym. W teście unit po prostu tworzysz obiekt przez new, jawnie przekazujesz mu zależności (zazwyczaj przez konstruktor), wywołujesz metodę i sprawdzasz wynik. Tyle.

Poniżej — mała tabela, która pomaga szybko zrozumieć, czy nadal jesteś w warstwie unit, czy niezauważalnie pojechałeś w „mini-integrację".

Co dzieje się w teście Czy to warstwa unit? Dlaczego
new PublicationPolicy() i sprawdzenie canMove(...) tak czysta reguła bez infrastruktury
new DefaultSlugService() i sprawdzenie toSlug(...) tak lokalna transformacja łańcucha
mock(ArticleRepository.class) i sprawdzenie, że serwis przekazał właściwy artykuł do save(...) tak (zazwyczaj) repozytorium to zewnętrzny kooperant, serwis można testować w izolacji
Uruchomienie kontekstu Spring, autokonfiguracji i beanów nie to już inna warstwa, inna cena i inna strefa dowodu
Prawdziwa baza danych, migracje Flyway nie to warstwa data/integration
Żądania HTTP, serializacja JSON, kontrolery nie to warstwa web i kontrakt API
Prawdziwy system plików (zapis/odczyt pliku) zazwyczaj nie to I/O i ryzyko integracyjne, będziemy testować osobno

Tutaj ważne jest, żeby nie wpadać w dwie skrajności. Pierwsza — „testem unit można nazwać wszystko, co mi się podoba". Druga — „test unit — to tylko jedna metoda i musi być czystą funkcją bez obiektów". W realnym kodzie backendowym testy unit często sprawdzają właśnie klasę, która koordynuje logikę i używa zależności. Po prostu w teście te zależności muszą być pod twoją kontrolą.

3. Wybór unit-target

Unit-target — to konkretny obiekt testowania: klasa lub reguła, dla której test unit rzeczywiście przynosi korzyść. Brzmi banalnie, ale błędy tutaj są kosztowne. Zbyt szeroki unit-target sprawia, że test jest kłopotliwy, rozmywa sens i zaczyna „prosić o Spring". Zbyt mały i bezsensowny daje zielone testy, które niczego nie dowodzą. Klasyczny przykład — „sprawdzanie gettera".

Dobry unit-target prawie zawsze ma dwie cechy. Po pierwsze, sens biznesowy: reguła, ograniczenie, przejście stanu, normalizacja, walidacja. Po drugie, wąska odpowiedzialność: robi jedną zrozumiałą rzecz, a nie „i artykuły publikuje, i pocztę wysyła, i bazę migruje, i chleb piecze". Im prostsza odpowiedzialność, tym łatwiejszy test i tym bardziej zrozumiała przyczyna padnięcia.

W ContentHub będziemy ciągle zadawać sobie pytanie: „Jaką awarię chcę złapać właśnie tym testem?" Jeśli odpowiedź brzmi „no właściwie wszystko, niech to działa", to nie wybierasz unit-target, ale magiczny amulet. Testy unit nie są o amuletach. Są o konkretnych gwarancjach.

Żeby było prościej, trzymaj w głowie mini-zasadę: jeśli test można wyjaśnić jednym zdaniem bez słowa „Spring", unit-target jest prawdopodobnie wybrany trafnie. Na przykład: „z DRAFT można przejść do IN_REVIEW", „slug normalizuje spacje", „załącznik większy niż limit jest zabroniony". A zdanie „artykuł jest tworzony przez API i zapisywany w bazie" — to już nie warstwa unit.

4. Warstwa unit w ContentHub

Żeby to nie pozostało abstrakcją, przywiążmy definicję warstwy unit do naszego przekrojowego projektu. ContentHub — to backend do publikacji artykułów, gdzie są statusy, role, moderacja, załączniki, publikacja i efekty uboczne. W takim systemie bardzo łatwo z przyzwyczajenia zacząć od kontrolerów i bazy danych. Ale my zaczniemy od tego, co najłatwiej zepsuć — i najłatwiej sprawdzić.

W warstwie unit ContentHub są cztery główne kandydaty, które dziś ustalamy jako obowiązkowe unit-target'y: PublicationPolicy, ArticleWorkflowService, SlugService i AttachmentValidationService. Dlaczego właśnie one? Bo w nich żyją reguły i koordynacja. A reguły i koordynację chce się sprawdzać szybko, lokalnie i bez zależności od infrastruktury.

Dla orientacji — mały schemat, żeby było widać: warstwa unit — to nie „kolejny pakiet", ale strefa odpowiedzialności wewnątrz aplikacji.

flowchart TD
    Controller["api.controller
granica HTTP"] --> Service["service
koordynacja biznesowa"] Service --> Repo["repository
dostęp do danych"] Repo --> DB[(PostgreSQL)] Service --> Integrations["integration.*
moderation / storage / notifications"] Workflow --> Policy Workflow --> Slug
flowchart TD
    subgraph UnitLayer["warstwa unit (bez Spring)"]
        Policy["PublicationPolicy
reguły statusów"] Slug["SlugService
normalizacja slug"] AttachVal["AttachmentValidationService
limity i typy"] Workflow["ArticleWorkflowService
koordynacja działań"] end

Tutaj ważne jest zrozumienie, że ArticleWorkflowService może zależeć od repozytorium i integracji, ale w teście unit nie używamy prawdziwych implementacji. Albo bierzemy proste implementacje zaślepkowe, albo używamy Mockito, żeby trzymać zachowanie pod kontrolą. A PublicationPolicy, SlugService, AttachmentValidationService idealnie są tak lokalne, że często można je testować zupełnie bez mocków.

Poniżej — jeszcze jedna tabela, która utrwala granicę „kto za co odpowiada", żeby nie mieszać logiki między warstwami.

Klasa (unit-target) Co sprawdza Czego nie powinien wiedzieć
PublicationPolicy dopuszczalne/zabronione przejścia statusów o DB, HTTP, bieżącym użytkowniku, czasie
SlugService transformacja tytułu w slug o unikalności w DB, o kontrolerach
AttachmentValidationService limity rozmiaru/typu/liczby załączników (według metadanych) o prawdziwym systemie plików i zapisywaniu pliku
ArticleWorkflowService koordynację: kto jest autorem, jaki slug, jakie daty, jakie przejście statusu o wnętrzach Spring Security, o JPA, o HTTP

Na tym etapie nie musisz znać implementacji wszystkich tych klas do ostatniej linii. Ważne jest co innego: właśnie te cztery punkty — to „serce" warstwy unit w naszym projekcie, i muszą być testowalne jako zwykła Java.

5. Praktyka warstwy unit

Mini-przykład testu unit

Zanim powiemy o błędach, pokażę wzorcowy minimalny test. Jest celowo krótki, prawie „nudny". I dobrze: w warstwie unit nuda — to oznaka zdrowia. Jeśli test przypomina powieść przygodową, znaczy, że przypadkowo wciągasz do niego infrastrukturę.

import org.junit.jupiter.api.Test;

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

class PublicationPolicyTest {

    private final PublicationPolicy policy = new PublicationPolicy();

    @Test
    void draftCanMoveToInReview() {
        boolean allowed = policy.canMove(ArticleStatus.DRAFT, ArticleStatus.IN_REVIEW);

        assertThat(allowed).isTrue();
    }
}

Ten test lepiej niż jakakolwiek definicja pokazuje granicę dzisiejszego tematu. Nie ma tu adnotacji Spring, kontekstu, bazy danych i magii. Sprawdzamy jedną regułę i dostajemy jedną odpowiedź. Z takiego testu potem łatwo wyrasta pełna matryca przejść, ale zasada pozostaje ta sama.

Jeśli dopiero zaczynasz, przydatne jest fizyczne poczucie różnicy: taki test uruchamia się prawie natychmiastowo. I gdy pada, widzisz problem od razu, a nie po pięciu sekundach ładowania kontekstu i dwudziestu liniach logów.

Cechy złego unit-target

U nowicjuszy zdarza się to stale: niby piszesz test unit, ale nagle łapiesz się na myśli: „a jak tu podnieść ApplicationContext… no tylko trochę… mały kontekst… malutki…". To normalna pokusa, szczególnie jeśli przywykłeś do Spring jako do „głównego sposobu życia aplikacji". Ale w warstwie unit taki impuls zazwyczaj sygnalizuje, że albo testujesz nie to, albo kod jest tak zorganizowany, że trudno go testować.

Jeśli do sprawdzenia zachowania potrzebujesz żądania HTTP, serializacji JSON, web binding lub kontrolera — to już testujesz granicę API, a to inna warstwa. Jeśli potrzebujesz bazy danych, migracji, transakcji — testujesz persistence boundary, i to też inna warstwa. Jeśli potrzebujesz prawdziwego SecurityContextHolder, żeby poznać użytkownika — testujesz infrastrukturę security, a nie regułę biznesową.

W warstwie unit dopuszczamy zależności, ale tylko takie, którymi można zarządzać bezpośrednio z testu. Idea jest prosta: test sam ustala świat, w którym żyje klasa. Każdy „globalny świat" — kontekst Spring, zegary systemowe, bieżący użytkownik z globalnego magazynu — sprawia, że test jest albo niedeterministyczny, albo drogi.

Patrz na to tak: warstwa unit — to „sala negocjacyjna", gdzie omawiasz regułę. Gdy tylko wniesiesz do tej sali bazę danych, serwer HTTP, security chain i system plików, negocjacje zamieniają się w festiwal. Wesoły, ale rzeczowo nie zdążysz nic omówić.

Deterministyczność: czas i użytkownik

W każdej logice biznesowej najczęściej „pływają" trzy rzeczy: czas, bieżący użytkownik i serwisowe obliczenia jak slug. Jeśli zostawić je „jak wychodzi", test unit szybko zaczyna zależeć od zewnętrznego świata. Dlatego dla orkestratora takiego jak ArticleWorkflowService robimy je jawnymi zależnościami: czas daje Clock, użytkownika — CurrentUserProvider, a slug przychodzi przez SlugService i w teście może być albo prawdziwym DefaultSlugService, albo kontrolowaną zaślepką. Zasada prosta: to samo wejście musi dawać ten sam wynik, bez niespodzianek ze strony środowiska.

Mockito w warstwie unit

Mockito jest potrzebne tam, gdzie unit-target koordynuje zewnętrznych kooperantów — na przykład repozytorium lub zewnętrzną integrację. Dla czystych reguł jak PublicationPolicy lub deterministycznego DefaultSlugService tylko przeszkadza, bo zaczynasz mockować sam sens testu. Kryterium proste: mockuj to, co wyciąga test w stronę I/O i zewnętrznego świata, a nie łańcuch, enum lub czystą regułę.

Nazwy testów i struktura

Gdy piszesz testy unit, bardzo łatwo zamienić folder src/test/java w wysypisko: „tu testy, tam testy, tu jeszcze testy". Potem potrzebny scenariusz szuka się jak zgubiona skarpetka: gdzieś jest, ale nadzieja opada. Dlatego już w warstwie unit zaczynamy od dyscypliny nazewnictwa i struktury. PublicationPolicyTest lub ArticleWorkflowServiceTest czyta się lepiej niż wszelkie kreatywne nazwy, bo od razu odpowiadają na pytanie: „co dokładnie jest tu pod ochroną?"

6. Typowe błędy przy definiowaniu granic warstwy unit

Błąd nr 1: „Trochę Spring, żeby było wygodniej".
Zazwyczaj wszystko zaczyna się niewinnie: „muszę wyciągnąć bean", „muszę odczytać propertisy", „potrzebuję bieżącego użytkownika". W efekcie test unit staje się półciężki, powolny i rozlewa się po warstwach. Właściwa reakcja — nie podnosić kontekstu, ale zrobić zależność jawną: Clock, provider użytkownika, port/adapter.

Błąd nr 2: zbyt szeroki unit-target.
Jeśli twój „unit" wewnątrz testu jednocześnie sprawdza zmianę statusu, zapis w DB, walidację wejścia i jeszcze, „na wszelki wypadek", serializację JSON, przestajesz rozumieć, co się dokładnie zepsuło. Warstwa unit lubi wąskie cele: jedną regułę, jedną lokalną koordynację, jedno sprawdzenie ograniczeń.

Błąd nr 3: mockowanie prostych i sensownych obiektów.
Czasem ludzie mockują PublicationPolicy, żeby przetestować PublicationPolicy. Brzmi jak anegdota, ale w życiu się zdarza. Jeszcze gorzej — mockować enum statusu lub łańcuch. Jeśli obiekt jest prosty i deterministyczny, należy używać go prawdziwego. Mocki zostawiamy dla zewnętrznych kooperantów.

Błąd nr 4: test sprawdza tylko verify(...) i nic nie mówi o wyniku.
Sprawdzenie interakcji bez sprawdzenia sensu często daje fałszywą pewność. Tak, metoda save(...) się wywołała, ale co dokładnie tam przekazano? Czasem taki test pozostaje zielony nawet wtedy, gdy artykuł zapisywany jest z błędnym autorem lub błędnym statusem. Dlatego w warstwie unit najpierw myślimy o obserwowalnym wyniku, a dopiero potem — o interakcjach.

Błąd nr 5: niedeterministyczny czas i użytkownik.
Instant.now() i „użytkownik z systemu" wewnątrz metody biznesowej — to prawie gwarantowane źródło flaky-testów i dziwnych padnięć. Nawet jeśli test na razie jest zielony, jest zielony „na szczęście". W tym kursie traktujemy czas i użytkownika jako takie same zależności jak repozytorium: musi je ustalać test.

1
Zadanie
Spring Test, poziom 5, lekcja 0
Niedostępne
Lokalna reguła długości tytułu artykułu
Lokalna reguła długości tytułu artykułu
1
Zadanie
Spring Test, poziom 5, lekcja 0
Niedostępne
Lokalna walidacja zestawu tagów artykułu
Lokalna walidacja zestawu tagów artykułu
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION