Testy jednostkowe w Javie krok po kroku: dobre praktyki, narzędzia i przykłady

0
26
3/5 - (1 vote)

Z tego wpisu dowiesz się:

Dlaczego testy jednostkowe w Javie zmieniają sposób pracy z kodem

Testy jednostkowe a integracyjne i end‑to‑end – krótkie porównanie

Testy jednostkowe w Javie są często wrzucane do jednego worka z integracyjnymi i end‑to‑end, co prowadzi do nieporozumień. Różnica jest prosta: test jednostkowy sprawdza mały, izolowany fragment logiki (najczęściej pojedynczą metodę lub niewielką klasę), podczas gdy test integracyjny dotyka kilku warstw naraz (np. serwis + baza danych), a test end‑to‑end symuluje cały przepływ od interfejsu użytkownika do bazy.

Dobrze obrazuje to przykład: metoda obliczająca rabat klienta na podstawie koszyka i statusu lojalnościowego będzie idealnym kandydatem na test jednostkowy. Wywołanie endpointu REST z całym pipeline’em (kontroler, serwis, repozytorium, prawdziwa baza, autentykacja) to już test integracyjny lub end‑to‑end. W tym drugim przypadku interesuje cię bardziej „czy całość działa”, a w testach jednostkowych – „czy logika w środku podejmuje poprawne decyzje w różnych scenariuszach”.

Plusy testów jednostkowych względem integracyjnych i E2E są wyraźne:

  • Szybkość – setki testów potrafią przejść w kilka sekund.
  • Dokładna lokalizacja problemu – przy awarii łatwo wskazać konkretną metodę.
  • Wysoka powtarzalność – brak zależności od środowiska, sieci, czasu, zewnętrznych usług.

Z kolei testy integracyjne i E2E są wolniejsze, droższe w utrzymaniu, ale potwierdzają, że klocki dobrze współpracują. W praktycznej aplikacji w Javie potrzebne są wszystkie trzy rodzaje, jednak to testy jednostkowe stanowią fundament, który daje stabilność przy codziennym programowaniu.

Odwaga do refaktoryzacji i stabilne tempo rozwoju

Bez testów jednostkowych refaktoryzacja bywa grą w rosyjską ruletkę. Zmieniasz kod, aplikacja się kompiluje, kilka ręcznych kliknięć pokazuje, że „chyba działa”, ale przez kolejne tygodnie użytkownicy znajdują dziwne regresje. Solidny zestaw testów jednostkowych zamienia ten scenariusz w proces kontrolowany: modyfikujesz implementację, uruchamiasz testy, w kilka sekund widzisz, czy zmiana nie złamała kontraktów zachowania.

Testy jednostkowe pełnią tu rolę siatki bezpieczeństwa. Im lepiej są napisane (czyli sprawdzają zachowanie, nie szczegóły implementacji), tym śmielej można zmieniać wewnętrzny kod przy zachowaniu tego samego API. W praktyce pozwala to rozwijać projekt znacznie dłużej bez całkowitego przepisywania modułów, bo refaktoryzacja staje się codziennym, tanim nawykiem, a nie dramatem wykonywanym raz na kilka lat.

Wpływ na tempo rozwoju jest szczególnie widoczny w większych zespołach. Nowa osoba wchodzi w kod, robi zmianę w metodzie naliczającej prowizję, uruchamia testy jednostkowe – jeśli coś pominęła, testy obowiązkowo się wyłożą. Senior nie musi czytać każdej linijki diffu, bo część pracy deleguje na automatyczne sprawdzanie zachowania.

Kiedy testy jednostkowe dają największy zwrot z inwestycji

Nie każdy fragment systemu wymaga takiego samego poziomu testowania jednostkowego. W praktyce największy zysk przynoszą:

  • Nowe moduły – łatwiej od razu projektować kod z myślą o testach niż później „odgniatać” trudną architekturę.
  • Logika biznesowa – wszystko, co wpływa na pieniądze, uprawnienia, reguły procesów, jest warte dokładnego przetestowania.
  • Usługi o długim cyklu życia – moduły, które będą rozwijane latami (np. rozliczenia, billing, integracje płatności).
  • Małe usługi (microservices) z własną domeną – tu łatwo uzyskać dobre pokrycie logiki przy niskim koszcie.

W zastanym monolicie, który nie ma testów, największy sens ma podejście stopniowe: każda nowa funkcjonalność lub poprawka błędu dostaje od razu przynajmniej kilka testów jednostkowych. Z czasem pojawia się coś w rodzaju „testowego pierścienia ochronnego” wokół krytycznego kodu, a starsze fragmenty mogą być stopniowo refaktoryzowane i obejmowane testami.

Mity wokół testów jednostkowych w Javie

Krąży kilka popularnych mitów, które szkodzą świadomemu podejściu do testów jednostkowych:

  • „Testy spowalniają rozwój” – na początku tak to wygląda, bo trzeba się nauczyć narzędzi i zmienić nawyki. Po kilku tygodniach bilans odwraca się: mniej regresji, mniej ręcznego klikania, szybszy code review.
  • „Wystarczy kilka testów UI” – testy UI są najdroższe, najbardziej kruche i często mają długie czasy wykonania. Bez warstwy testów jednostkowych kończy się na długich sesjach klikania po interfejsie po każdej zmianie backendu.
  • „Pokrycie 100% rozwiązuje wszystko” – samo pokrycie to liczba techniczna. Łatwo osiągnąć 100% liniowego pokrycia danym modułem, a dalej nie sprawdzać żadnych sensownych scenariuszy brzegowych. Dużo ważniejsza jest jakość przypadków testowych niż sama statystyka.
Programista piszący kod na laptopie i monitorze w biurze
Źródło: Pexels | Autor: Jakub Zerdzicki

Fundamenty testów jednostkowych w Javie – wspólny język pojęć

Co jest jednostką testową: metoda, klasa czy przypadek użycia

Słowo „unit” bywa interpretowane zbyt dosłownie. W prostych narzędziach i tutorialach jednostką testową jest pojedyncza metoda klasy. W praktyce, zwłaszcza przy architekturach z wyraźnie wydzielonymi warstwami, jednostką może być:

  • pojedyncza metoda bez zależności (np. parsowanie, czysta funkcja),
  • cała klasa reprezentująca fragment logiki (np. kalkulator rabatów),
  • mały fragment przypadku użycia (np. walidacja zamówienia bez faktycznego zapisu do DB).

W projektach w stylu „clean architecture” jednostka często pokrywa się z use case, czyli np. jedną komendą aplikacyjną. Liczy się przede wszystkim to, aby test jednostkowy był szybki, izolowany i deterministyczny, a granice jednostki wynikały z logiki domeny, a nie z przypadkowego podziału klas.

Deterministyczne testy a izolacja od środowiska

Test deterministyczny to taki, który przy tych samych danych wejściowych zawsze daje ten sam wynik. Problemy pojawiają się przy zależnościach od czasu systemowego, sieci, plików czy wątków. Dobry test jednostkowy w Javie powinien minimalizować te zmienne.

Przykłady typowych źródeł niedeterministyczności:

  • metoda używa LocalDateTime.now() – w testach lepiej przekazać Clock lub konkretną datę jako parametr,
  • metoda pobiera dane z zewnętrznego API – zamiast prawdziwego HTTP warto podmienić warstwę komunikacji na mock lub implementację in‑memory,
  • logika opiera się na losowości (Random) – test może wstrzyknąć z góry znane źródło danych.

Jeśli test zaczyna wymagać ustawiania bazy danych, włączania kontenera Dockera czy specjalnej konfiguracji systemu operacyjnego, to często nie jest już test jednostkowy, tylko integracyjny. Świadome rozdzielenie poziomów testowania pozwala utrzymać szybkość unitów i osobno dbać o poprawną współpracę z infrastrukturą.

GIVEN–WHEN–THEN i AAA w praktycznym układzie testu

Dwa popularne sposoby opisywania struktury testu to GIVEN–WHEN–THEN (z BDD) oraz AAA (Arrange–Act–Assert). W praktyce znaczą prawie to samo, różni się raczej nazewnictwo:

  • GIVEN / Arrange – przygotowanie danych wejściowych, obiektów, stubów, mocków.
  • WHEN / Act – pojedyncze wywołanie metody, którą testujesz.
  • THEN / Assert – sprawdzenie oczekiwanego zachowania.

Dobry test jednostkowy w Javie jest czytelny, jeśli te trzy sekcje można wizualnie odróżnić – chociażby pustymi liniami, krótkimi komentarzami albo wydzieleniem pomocniczych metod typu givenValidOrder(). Dzięki temu po kilku miesiącach da się zrozumieć, co dokładnie jest sprawdzane i w jakim kontekście.

Przykładowy układ w stylu AAA:

@Test
void shouldApplyDiscountForVipCustomer() {
    // Arrange
    Cart cart = new Cart(List.of(new Item("BOOK", 100)));
    Customer customer = new Customer(Status.VIP);
    DiscountService service = new DiscountService();

    // Act
    Money result = service.calculateDiscount(cart, customer);

    // Assert
    assertEquals(Money.of(20), result);
}

Taka struktura wymusza dyscyplinę: tylko jedno „Act”, przejrzyste przygotowanie danych testowych i jasne oczekiwania.

Asercje, dane wejściowe i testy negatywne

Serce testu stanowią asercje. W JUnit 5 podstawowy zestaw zapewnia klasa org.junit.jupiter.api.Assertions. Najczęściej używane metody to assertEquals, assertTrue, assertFalse, assertThrows, a także assertAll do grupowania kilku powiązanych asercji.

Dobrze zaprojektowany test jednostkowy operuje na starannie dobranych danych wejściowych: obejmuje nie tylko „standardowy scenariusz sukcesu”, lecz także przypadki brzegowe i testy negatywne. Przykładowo metoda dodająca produkt do koszyka powinna mieć test:

  • dla poprawnego produktu,
  • dla produktu null lub z pustym identyfikatorem (spodziewany wyjątek),
  • dla produktu o ilości równej zero (czy jest akceptowany, czy odrzucany).

Takie dane testowe często powtarzają się w wielu testach, dlatego wygodniej utrzymywać je w postaci fixtures – metod budujących obiekty z domyślnymi, sensownymi wartościami. Minimalizuje to duplikację i ułatwia zmianę struktury danych w jednym miejscu.

JUnit 5 w praktyce – konfiguracja, pierwsze testy i parametryzacja

Dodanie JUnit 5 do projektu i konwencje struktury katalogów

JUnit 5 jest de facto standardem testów jednostkowych w Javie. W projektach opartych o Maven i Gradle jego konfiguracja jest prosta. W Mavenie do pom.xml trafia typowa zależność:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.0</version>
    <scope>test</scope>
</dependency>

W Gradle (Kotlin DSL) konfiguracja może wyglądać następująco:

dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
}

tasks.test {
    useJUnitPlatform()
}

Konwencja katalogów w większości projektów Java jest jasno ustalona: kod produkcyjny ląduje w src/main/java, a testy w src/test/java. Klasy testowe zazwyczaj mają tę samą strukturę pakietów i nazwę oryginału z sufiksem Test, np. DiscountService i DiscountServiceTest. Taki układ ułatwia nawigację między kodem a testami w IDE.

Przykład prostego testu JUnit 5 ze strukturą AAA

Najprostszy test jednostkowy JUnit 5 składa się z metody oznaczonej adnotacją @Test i jednej lub kilku asercji. Poniższy przykład demonstruje test metody dodającej dwa liczby:

class CalculatorTest {

    @Test
    void shouldAddTwoNumbers() {
        // Arrange
        Calculator calculator = new Calculator();

        // Act
        int result = calculator.add(2, 3);

        // Assert
        assertEquals(5, result);
    }
}

Nawet w tak prostym kodzie widać strukturę: przygotowanie (tworzenie kalkulatora), działanie (wywołanie metody) i sprawdzenie. W miarę rozwoju projektu testy będą bardziej złożone, ale ten prosty schemat jest uniwersalny i dobrze sprawdza się jako wzorzec podstawowy.

Cykl życia testów: BeforeEach, AfterEach i spółka

JUnit 5 oferuje adnotacje pozwalające na wykonanie kodu przed/po każdym teście lub przed/po wszystkich testach w klasie. Najważniejsze z nich to:

  • @BeforeEach – uruchamiane przed każdym testem, zwykle do inicjalizacji świeżych obiektów,
  • @AfterEach – sprzątanie po każdym teście,
  • @BeforeAll – uruchamiane raz przed wszystkimi testami (musi być static lub w klasie z @TestInstance(Lifecycle.PER_CLASS)),
  • @AfterAll – odpowiednio po wszystkich testach.

Testy zagnieżdżone i grupowanie przypadków w JUnit 5

Przy klasach o bogatej logice liczba testów szybko rośnie. Zamiast jednej, bardzo długiej klasy testowej z dziesiątkami metod wygodniej jest logicznie je pogrupować. JUnit 5 oferuje adnotację @Nested, która pozwala na tworzenie zagnieżdżonych klas testowych, opisujących różne konteksty lub warianty zachowań.

Przykład podziału testów DiscountService na klienta zwykłego i VIP:

class DiscountServiceTest {

    DiscountService service;

    @BeforeEach
    void setUp() {
        service = new DiscountService();
    }

    @Nested
    class RegularCustomer {

        @Test
        void shouldNotApplyDiscountForSmallAmount() {
            // Arrange
            Cart cart = new Cart(List.of(new Item("BOOK", 50)));
            Customer customer = new Customer(Status.REGULAR);

            // Act
            Money discount = service.calculateDiscount(cart, customer);

            // Assert
            assertEquals(Money.zero(), discount);
        }

        @Test
        void shouldApplySmallDiscountForLargeAmount() {
            // ...
        }
    }

    @Nested
    class VipCustomer {

        @Test
        void shouldApplyHigherDiscountForSameAmount() {
            // ...
        }
    }
}

W porównaniu z płaską listą testów:

  • łatwiej znaleźć powiązane przypadki – wystarczy spojrzeć na klasę wewnętrzną,
  • można od razu zobaczyć, jakie scenariusze są pokryte w danym kontekście (np. różne statusy klienta),
  • umożliwia to lepszy opis nazwy klasy kontekstowej niż dopiski w nazwach metod.

Przy prostych klasach taka struktura może być zbędnym obciążeniem, natomiast przy złożonych komponentach domenowych szybko staje się wygodniejsza niż jedna rozciągnięta lista testów, gdzie nazwy metod próbują przekazać zbyt wiele informacji naraz.

Parametryzacja testów: @ParameterizedTest, @CsvSource, @MethodSource

Wielu programistów powiela bardzo podobne testy, zmieniając jedynie dane wejściowe i oczekiwany wynik. Zamiast kopiować te same 3 linijki asercji można użyć testów parametryzowanych. W JUnit 5 podstawą jest adnotacja @ParameterizedTest, która zastępuje @Test, a dane można dostarczać na różne sposoby.

Dla prostych kombinacji świetnie nadaje się @CsvSource:

@ParameterizedTest
@CsvSource({
        "0, REGULAR, 0",
        "100, REGULAR, 5",
        "100, VIP, 20"
})
void shouldCalculateDiscountForDifferentScenarios(
        int cartTotal,
        String status,
        int expectedDiscount
) {
    // Arrange
    Cart cart = new Cart(List.of(new Item("GENERAL", cartTotal)));
    Customer customer = new Customer(Status.valueOf(status));
    DiscountService service = new DiscountService();

    // Act
    Money result = service.calculateDiscount(cart, customer);

    // Assert
    assertEquals(Money.of(expectedDiscount), result);
}

Przy bardziej złożonych obiektach wygodniejszy bywa @MethodSource, gdzie dane generuje metoda w tej samej klasie:

Do kompletu polecam jeszcze: Bazy danych w chmurze dla aplikacji Java – który model wybrać? — znajdziesz tam dodatkowe wskazówki.

@ParameterizedTest
@MethodSource("invalidOrders")
void shouldRejectInvalidOrders(Order invalidOrder, String reason) {
    // Act & Assert
    var ex = assertThrows(ValidationException.class,
            () -> orderValidator.validate(invalidOrder));

    assertTrue(ex.getMessage().contains(reason));
}

static Stream<Arguments> invalidOrders() {
    return Stream.of(
            Arguments.of(new Order(null, List.of()), "customerId"),
            Arguments.of(new Order("123", List.of()), "empty items"),
            Arguments.of(new Order("123", List.of(new Item(null, 1))), "item code")
    );
}

Porównując te dwa style:

  • @CsvSource sprawdza się przy prostych typach (liczby, stringi, enumy), łatwo je wypisać „w linii”,
  • @MethodSource daje pełną swobodę budowania rozbudowanych obiektów, ale czytelność zależy od tego, jak przejrzyście przygotujemy metody pomocnicze.

Próg wejścia w testy parametryzowane jest odczuwalny: na początku wydają się bardziej skomplikowane niż kilka osobnych metod. Przy większej liczbie kombinacji danych (np. walidacja formularzy, algorytmy z wieloma parametrami) oszczędność miejsca i centralizacja scenariuszy szybko zaczyna przeważać.

Wyjątki i zachowania błędne – assertThrows i asercje na komunikaty

Kod biznesowy w Javie często komunikuje błędy przez wyjątki. Testy jednostkowe muszą sprawdzać nie tylko pozytywne ścieżki, ale także sposób obsługi błędów. JUnit 5 rekomenduje assertThrows jako podstawowy sposób weryfikacji wyjątków:

@Test
void shouldThrowExceptionWhenCustomerIsNull() {
    // Arrange
    DiscountService service = new DiscountService();
    Cart cart = new Cart(List.of(new Item("BOOK", 100)));

    // Act & Assert
    IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
            () -> service.calculateDiscount(cart, null));

    assertEquals("Customer must not be null", ex.getMessage());
}

Sprawdzenie komunikatu bywa kontrowersyjne. Z jednej strony:

  • pozwala upewnić się, że wyjątek został rzucony z konkretnej gałęzi logiki,
  • jest przydatne przy walidacji danych wejściowych, gdzie komunikaty trafiają do użytkownika.

Z drugiej strony zbyt „sztywne” oczekiwania względem pełnej treści powodują kruchość testów – zmiana literówki wymusza poprawki w wielu miejscach. Rozsądnym kompromisem jest:

  • sprawdzanie rodzaju wyjątku zawsze,
  • sprawdzanie kluczowej części komunikatu (np. fragmentu tekstu) tylko w krytycznych miejscach,
  • unikanie asercji na całe, rozbudowane komunikaty, jeśli nie ma to znaczenia biznesowego.

Wyłączanie i tagowanie testów

W większych projektach pewne testy są cięższe, wymagają dodatkowej konfiguracji lub są niestabilne ze względu na zależności zewnętrzne. W JUnit 5 można je tymczasowo wyłączyć adnotacją @Disabled lub oznaczyć tagiem i sterować ich uruchamianiem z poziomu builda.

@Test
@Disabled("Flaky test – needs investigation")
void shouldHandleConcurrentUpdates() {
    // ...
}

Tagi (@Tag("slow"), @Tag("integration")) pozwalają różnicować profile uruchamiania:

@Tag("slow")
@Test
void shouldProcessLargeBatch() {
    // ...
}

Konfigurując Maven/Gradle można zbudować dwa profile:

  • szybki, domyślny (CI na każdym commitcie) – odpala wyłącznie testy bez tagów typu slow,
  • pełny – okresowo lub przed releasem uruchamia wszystkie testy, w tym wolne.

W porównaniu z ręcznym „zakomentowywaniem” testów, podejście z tagami jest przejrzystsze, lepiej udokumentowane i pozwala utrzymać świadomą strategię testową bez bałaganu w kodzie.

Widok struktury plików i kodu w środowisku programistycznym Java
Źródło: Pexels | Autor: Daniil Komov

Mockowanie i stubowanie w testach jednostkowych

Czym różni się mock od stubu i kiedy czego używać

Pojęcia „mock”, „stub”, „fake” czy „spy” bywają używane zamiennie, co utrudnia rozmowę o jakości testów. Praktyczny podział, użyteczny na co dzień:

  • stub – prosta, przygotowana z góry odpowiedź na wywołanie (bez weryfikacji, czy metoda została użyta),
  • mock – obiekt, którego oczekiwania wobec wywołań są częścią asercji (np. sprawdzamy, że metoda została wywołana z konkretnymi parametrami),
  • fake – działająca, ale uproszczona implementacja (np. repozytorium in-memory zamiast bazy danych),
  • spy – obiekt częściowo prawdziwy, częściowo „podsłuchujący” wywołania.

Z perspektywy projektowania:

  • stub jest prostszy i stabilniejszy, bo nie wiąże testu z wewnętrzną implementacją klasy,
  • mock lepiej nadaje się tam, gdzie istotna jest interakcja (np. „powiadom użytkownika dokładnie raz”).

Jeśli większość testów koncentruje się na tym, „co” metoda zwraca, zamiast „jak” rozmawia z zależnościami, kod jest zwykle bardziej odporny na refaktoryzacje. Rozbudowane weryfikacje verify(...) w każdym teście są sygnałem, że granice odpowiedzialności między komponentami mogą być źle ustawione.

Mockito – podstawowy zestaw narzędzi

Mockito to najpopularniejsza biblioteka do mockowania w Javie. Podstawowe konstrukcje to:

  • mock() – tworzy atrapę interfejsu lub klasy,
  • when(...).thenReturn(...) – definiuje zachowanie zależności,
  • verify(...) – sprawdza, czy i jak wywołano metodę.

Prosty przykład serwisu korzystającego z repozytorium:

class UserService {

    private final UserRepository repository;
    private final EmailSender emailSender;

    UserService(UserRepository repository, EmailSender emailSender) {
        this.repository = repository;
        this.emailSender = emailSender;
    }

    void register(String email) {
        if (repository.existsByEmail(email)) {
            throw new IllegalArgumentException("Email already exists");
        }
        User user = repository.save(new User(email));
        emailSender.sendWelcomeEmail(user);
    }
}

Test jednostkowy z użyciem Mockito:

class UserServiceTest {

    private UserRepository repository = mock(UserRepository.class);
    private EmailSender emailSender = mock(EmailSender.class);
    private UserService userService = new UserService(repository, emailSender);

    @Test
    void shouldRegisterNewUserAndSendWelcomeEmail() {
        // Arrange
        String email = "test@example.com";
        when(repository.existsByEmail(email)).thenReturn(false);
        when(repository.save(any(User.class)))
                .thenAnswer(invocation -> invocation.getArgument(0));

        // Act
        userService.register(email);

        // Assert
        verify(repository).existsByEmail(email);
        verify(repository).save(new User(email));
        verify(emailSender).sendWelcomeEmail(new User(email));
    }
}

Powyższy test jest poprawny, ale kruchy: wymaga porównywania pełnych obiektów User i bardzo ściśle wiąże się z sekwencją wywołań. W praktyce częściej stosowany jest luźniejszy wariant:

verify(repository).save(argThat(user -> user.getEmail().equals(email)));
verify(emailSender).sendWelcomeEmail(any(User.class));

lub nawet:

ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);

verify(repository).save(captor.capture());
User saved = captor.getValue();

assertEquals(email, saved.getEmail());

W efekcie test skupia się na tym, jakie dane zostały faktycznie zapisane, zamiast wymagać idealnego dopasowania obiektu (co komplikuje się przy polach technicznych, jak identyfikatory czy znaczniki czasu).

Mocki kontra fakes – dwa sposoby odcinania się od infrastruktury

Przy zależnościach infrastrukturalnych (baza danych, kolejka, system plików) istnieją dwa główne podejścia:

  • intensywne użycie mocków (Mockito) – każda zależność jest zastępowana mockiem i sterowana w teście,
  • proste implementacje in-memory (fakes) – np. InMemoryUserRepository w kolekcji.

Porównanie w praktyce:

  • Mocki:
    • szybkie do napisania dla prostych zależności,
    • łatwo zasymulować błędy (np. wyjątki z bazy),
    • przy wielu metodach w interfejsie rośnie ilość konfiguracji when(...), co obniża czytelność.
  • Fakes:
    • wymagają jednorazowego napisania dodatkowej klasy,
    • zawierają podstawową logikę (np. unikalność emaila, wyszukiwanie),
    • testy stają się bliższe „prawdziwemu” użyciu komponentu.

W projektach domenowych często stosowane jest podejście mieszane: serwis aplikacyjny testowany jest w izolacji z użyciem fake’ów repozytoriów (łatwość refaktoru, czytelność), natomiast w warstwach technicznych (np. klienci HTTP) dominują mocki, bo tam kluczowe jest zachowanie na błędy i nietypowe odpowiedzi, a nie pełna logika.

Typowe pułapki mockowania i jak ich unikać

Najczęściej spotykane problemy przy nadużyciu mocków:

  • Testy wiedzą za dużo o wnętrzu klasy – weryfikowane jest każde wywołanie każdej zależności; drobna zmiana logiki (np. wprowadzenie cache) łamie dziesiątki testów.
  • Mockowanie zbyt niskiego poziomu – zamiast odciąć się na poziomie repozytorium, mockowany jest bezpośrednio klient JDBC lub RestTemplate, przez co test jednostkowy zamienia się w test konfiguracji frameworka.
  • Brak realizmu – konfiguracja mocka pomija pewne scenariusze, które w realnym środowisku są typowe (np. zwracanie pustej listy zamiast null lub odwrotnie).

Bezpieczniejsze podejście to:

Dobrym uzupełnieniem będzie też materiał: Jak projektować API, żeby ułatwić życie frontendowcom i poprawić UX całości — warto go przejrzeć w kontekście powyższych wskazówek.

  • mockować „szersze” interfejsy domenowe (repozytoria, porty), a nie drobne szczegóły techniczne,
  • unikać weryfikacji liczby wywołań, chyba że jest to część wymagań (np. „email ma być wysłany dokładnie raz”),
  • w przypadku wątpliwości preferować testy oparte na rezultacie (assert na stanie) zamiast na interakcjach.

Alternatywy dla Mockito: MockK, EasyMock i podejścia bezframeworkowe

Mockito dominuje w świecie JVM, ale nie jest jedyną opcją.

MockK, EasyMock, podejście „bez frameworka” – kiedy co ma sens

Porównując popularne podejścia do dublowania zależności, widać trzy główne ścieżki:

  • Mockito / EasyMock – klasyczne biblioteki oparte na stylu „given-when-then”,
  • MockK – nowocześniejsze podejście, lepiej zintegrowane z Kotlinem,
  • ręcznie pisane dublery – proste klasy implementujące interfejsy bez dodatkowych frameworków.

EasyMock i Mockito są do siebie zbliżone, jednak Mockito ma dziś wyraźnie większy ekosystem i wsparcie. EasyMock bywa spotykany w starszych projektach, gdzie historycznie został wybrany wcześniej – migracja na Mockito często przynosi prostsze API i lepszą integrację z JUnit 5.

MockK zyskał popularność przede wszystkim w projektach w Kotlinie. Jego atuty to m.in. lepsze wsparcie dla:

  • funkcji rozszerzeń,
  • klas finalnych (bez dodatkowej konfiguracji),
  • coroutines (testowanie funkcji suspendujących).

W czystej Javie przewaga MockK nad Mockito jest mniejsza, choć niektórym bardziej odpowiada jego składnia. W praktyce o wyborze decyduje najczęściej ekosystem: w projektach javowych dominuje Mockito, w kotlinowych – MockK.

Ręcznie pisane dublery (bez frameworków) wygrywają tam, gdzie potrzebna jest bardziej rozbudowana logika pomocnicza. Przykładowo: złożone repozytorium w pamięci z logiką wyszukiwania, prostą walidacją lub statystykami użycia. Taka klasa jest czytelna, może być współdzielona między wieloma testami, a jej zachowanie jest jawne – bez „magii” konfiguracji when(...).

Praktyczny podział:

  • proste zależności infrastrukturalne – szybko postawiony mock z Mockito,
  • bogatsze porty domenowe – dedykowane fake’i in-memory zamiast plątaniny konfiguracji mocków,
  • projekty w Kotlinie z coroutines – MockK, bo wymaga mniej „obejść”.

Kiedy zrezygnować z frameworków mockujących

Im bardziej złożona jest logika testów konfigurujących mocki, tym większe ryzyko, że framework staje się celem samym w sobie. W pewnym momencie prościej napisać małą implementację interfejsu niż utrzymywać rozbudowany zestaw when(...), doThrow(...), ArgumentCaptor itd.

Sygnały, że nadchodzi pora, by ograniczyć frameworki:

  • konfiguracja mocka zajmuje więcej miejsca niż testowana logika,
  • te same scenariusze (np. „użytkownik istnieje / nie istnieje”) powtarzają się w dziesiątkach testów z niemal identycznym when(...),
  • debugowanie testu polega głównie na śledzeniu, co „tak naprawdę” robi konfiguracja Mockito.

Ręczny fake często rozwiązuje te problemy w jednym miejscu. Jako przykład można potraktować repozytorium:

class InMemoryUserRepository implements UserRepository {

    private final Map<String, User> users = new HashMap<>();

    @Override
    public boolean existsByEmail(String email) {
        return users.containsKey(email);
    }

    @Override
    public User save(User user) {
        users.put(user.getEmail(), user);
        return user;
    }

    @Override
    public Optional<User> findByEmail(String email) {
        return Optional.ofNullable(users.get(email));
    }

    void clear() {
        users.clear();
    }
}

Taki fake jest banalny, ale zdejmuje z testów ciężar konfigurowania wielu scenariuszy na mockach. Z drugiej strony, jeśli interfejs zmienia się bardzo dynamicznie, utrzymanie fake’ów może być równie kosztowne jak konfiguracji mocków – przy częstych zmianach API mockowanie może okazać się bardziej elastyczne.

Programista piszący kod Java na laptopie z testami jednostkowymi
Źródło: Pexels | Autor: Lukas Blazek

Styl i czytelność testów jednostkowych

Given–When–Then i jego praktyczne warianty

Większość zespołów korzysta z mniej lub bardziej uświadomionego wzorca Given–When–Then. Nawet jeśli nie jest spisany w konwencji BDD, porządkuje on test i pozwala szybko odróżnić przygotowanie scenariusza od właściwej asercji.

Typowy test można zapisać na kilka sposobów:

@Test
void shouldRegisterNewUser() {
    // given
    String email = "test@example.com";
    when(repository.existsByEmail(email)).thenReturn(false);

    // when
    userService.register(email);

    // then
    verify(repository).save(any(User.class));
}

Alternatywą są metody pomocnicze, które wyraźnie oddzielają etapy:

@Test
void shouldRegisterNewUser() {
    givenNoUserWithEmail("test@example.com");

    whenUserRegistersWith("test@example.com");

    thenUserIsPersistedWithEmail("test@example.com");
    thenWelcomeEmailIsSentTo("test@example.com");
}

Drugi wariant jest dłuższy w kodzie, ale lepiej czyta się go przy pierwszym kontakcie z projektem. Sprawdza się w systemach o rozbudowanej logice, gdzie wiele testów korzysta z tych samych kroków i reguł biznesowych.

Nazewnictwo testów – co naprawdę pomaga w debugowaniu

Dobre nazwy testów spełniają zwykle trzy kryteria:

  • opisują warunek początkowy,
  • wskazują akcję,
  • mówią o oczekiwanym rezultacie.

Porównanie trzech stylów:

  • testRegister() – nie mówi nic o scenariuszu,
  • shouldThrowWhenEmailExists() – już lepiej, ale bez kontekstu (jaki email?),
  • shouldThrowExceptionWhenRegisteringExistingEmail() – pełna informacja w nazwie.

Część zespołów zapisuje nazwy testów w formie zdania z podkreśleniami:

Rozsądniejszym celem jest mieszanka: testy jednostkowe zapewniają gęste sito dla logiki biznesowej, testy integracyjne pilnują współpracy warstw, a kilka dobrze dobranych testów E2E weryfikuje najważniejsze ścieżki użytkownika. Wtedy także bardziej zaawansowane tematy, jak więcej o programowanie z perspektywy architektury systemu, zaczynają się naturalnie składać w spójną całość.

@Test
void should_throw_exception_when_registering_existing_email() {
    // ...
}

Inni preferują nazwy po polsku, gdy domena jest silnie biznesowa i komunikacja w zespole odbywa się głównie w języku ojczystym:

@Test
void powinien_rzucic_wyjatek_przy_rejestracji_istniejacego_emaila() {
    // ...
}

Ważniejsza od samego języka jest konsekwencja w projekcie. Mieszanie angielskich i polskich nazw w jednym module utrudnia nawigację i wyszukiwanie testów.

Assert vs. verify – różne style, różne konsekwencje

Testy można pisać w dwóch dominujących stylach:

  • stanowym – asercje opierają się na stanie obiektów (wartościach zwracanych, zmianach w kolekcjach),
  • interakcyjnym – asercje sprawdzają wywołania między obiektami (verify(...)).

W testach stanowych component A jest czarną skrzynką: podajemy wejście, oczekujemy wyjścia. Ten styl jest bardziej odporny na zmiany implementacji, dopóki API pozostaje stabilne. Testy interakcyjne stają się z kolei ważne tam, gdzie istotna jest sama współpraca obiektów (np. serwis musi powiadomić trzy różne porty i każdy z nich powinien dostać odpowiednio przetworzone dane).

Dobrym kompromisem jest domyślnie preferować testy stanowe, a weryfikacje wywołań traktować jako uzupełnienie, stosowane tam, gdzie interakcja jest elementem kontraktu. Przykład:

// Styl stanowy:
Order order = orderService.placeOrder(command);
assertEquals(OrderStatus.PLACED, order.getStatus());

// Styl interakcyjny:
verify(notificationPort).notifyCustomer(order.getId(), "PLACED");

Gdy podejście interakcyjne zaczyna dominować i w każdym teście pojawia się długa lista verify(...), rośnie ryzyko nadmiernego sprzężenia testów z implementacją.

Unikanie „magicznych” danych testowych

Czytelność testów w dużej mierze zależy od tego, jak opisane są dane wejściowe. Zamiast przypadkowych wartości:

String email = "a@b.c";
int amount = 123;

wygodniej pracuje się z danymi, które coś komunikują:

String existingEmail = "existing@example.com";
String newEmail = "new@example.com";
BigDecimal orderAmount = new BigDecimal("99.99");

Jeśli w projekcie używana jest powtarzalna konfiguracja obiektów (np. standardowe zamówienie, domyślny klient), pomocne są „fabryki testowe” lub „mother objects”:

class OrderMother {

    static Order standardOrder() {
        return new Order("order-1", BigDecimal.TEN, OrderStatus.NEW);
    }

    static Order orderWithAmount(BigDecimal amount) {
        return new Order("order-1", amount, OrderStatus.NEW);
    }
}

Różnica między podejściem z Mother a builderem jest subtelna: Mother dostarcza prekonfigurowane warianty „sensownych” danych, builder bardziej koncentruje się na elastycznej konfiguracji. W praktyce często stosuje się oba – Mother oparty na builderze ułatwia zarządzanie scenariuszami.

Wspólna konfiguracja testów – setup, @BeforeEach i klasy bazowe

Powtarzający się kod przygotowania testów to naturalny kandydat do ekstrakcji. Można to zrobić na kilka sposobów:

  • metoda oznaczona @BeforeEach,
  • pomocnicze metody konfiguracyjne, wołane jawnie w testach,
  • klasy bazowe z gotowymi polami i setupem.

@BeforeEach jest najpopularniejsze, ale bywa nadużywane – zbyt rozbudowany setup ukrywa kluczowe założenia testu. Często lepiej jawnie pokazać konfigurację w samym teście, a wspólne elementy (np. fabryka fake’ów) wynieść do metod pomocniczych.

Klasy bazowe sprawdzają się w dwóch sytuacjach:

  • testy tej samej abstrakcji dla różnych implementacji (np. różne implementacje repozytorium),
  • wspólne reguły testowe, które trudno opisać inaczej (np. logowanie czasu, metryki).

Z kolei w klasy bazowe upychające wszystko – od konfiguracji Springa, przez mocki, po różne helpery – trudno się wgryźć osobom spoza zespołu. Gdy kolejne testy dziedziczą po „testowym God Object”, ukryte zależności i side-effecty są niemal gwarantowane.

Testy jednostkowe w typowych warstwach aplikacji Java

Warstwa domenowa – encje, agregaty, reguły biznesowe

Kod domenowy ma zwykle najmniej zależności zewnętrznych. To idealne miejsce na „czyste” testy jednostkowe bez frameworków i bez Springa. Typowy przykład to:

  • encje z metodami biznesowymi (np. Order.cancel()),
  • agregaty zarządzające niezmiennikami (np. limit kredytowy klienta),
  • value objects (np. Money, Email).

Testowanie warstwy domenowej zwykle sprowadza się do sekwencji:

// given
Order order = OrderMother.standardOrder();

// when
order.cancel();

// then
assertEquals(OrderStatus.CANCELLED, order.getStatus());

Jeśli domena została dobrze odseparowana od infrastruktury, testy działają błyskawicznie i nie wymagają żadnego container frameworka. Przy refaktoryzacji logiki biznesowej (np. zmiana sposobu naliczania rabatów) to właśnie te testy stanowią najpewniejsze zabezpieczenie.

Serwisy aplikacyjne – koordynacja domeny i portów

Serwisy aplikacyjne (lub „use case’y”) łączą domenę z portami zewnętrznymi: repozytoriami, modułami notyfikacji, integracjami z innymi systemami. To w tej warstwie testy są najbardziej podatne na nadużycie mocków.

Istnieją dwa podejścia do testowania serwisów aplikacyjnych:

  • serwis jako czysta jednostka – porty (repozytoria, klienci) są mockowane lub zastępowane fake’ami,
  • serwis w „prawie” integracyjnym kontekście – test obejmuje też konfigurację Springa i infrastrukturę in-memory (np. H2).

W testach jednostkowych serwisu wygodny jest układ: domena jako realne obiekty, porty jako fake’i lub mocki. Przykład z użyciem prostych fake’ów:

class UserRegistrationServiceTest {

    private final InMemoryUserRepository repository = new InMemoryUserRepository();
    private final FakeEmailSender emailSender = new FakeEmailSender();
    private final UserService userService =
            new UserService(repository, emailSender);

    @Test
    void shouldPersistUserAndSendEmail() {
        userService.register("test@example.com");

        assertTrue(repository.existsByEmail("test@example.com"));
        assertEquals(1, emailSender.getSentEmails().size());
    }
}

Taki test sprawdza integralność scenariusza (zapis + email) bez wchodzenia w detale implementacji. W porównaniu z wariantem w całości opartym na mockach łatwiej go przenieść do innego projektu lub zrefaktoryzować razem z kodem produkcyjnym.

Warstwa dostępu do danych – testy jednostkowe, kontraktowe i integracyjne

Repozytoria stanowią granicę między kodem aplikacyjnym a bazą danych. Tu testy jednostkowe mają ograniczoną moc – bez prawdziwej bazy trudno realnie zweryfikować zapytania, mapowania czy locki. Można jednak wyróżnić trzy poziomy testowania:

  • testy jednostkowe adapterów – działają na stubach / fake’ach klienta bazy (lub na abstrakcji typu EntityManager),
  • testy kontraktowe repozytoryj – sprawdzają, czy implementacja spełnia kontrakt zdefiniowany przez port (np. interfejs domenowy UserRepository),
  • testy integracyjne z prawdziwą bazą – weryfikują SQL, indeksy, relacje.

Najczęściej zadawane pytania (FAQ)

Czym różnią się testy jednostkowe w Javie od testów integracyjnych i end‑to‑end?

Test jednostkowy sprawdza mały, izolowany fragment logiki – zwykle jedną metodę lub niewielką klasę. Nie dotyka bazy danych, sieci ani prawdziwych zewnętrznych usług. W praktyce interesuje cię odpowiedź na pytanie: „czy ta logika podejmuje poprawne decyzje dla różnych danych wejściowych?”.

Test integracyjny łączy kilka warstw aplikacji naraz, np. serwis + repozytorium + prawdziwą bazę. Test end‑to‑end idzie jeszcze dalej – symuluje cały przepływ, np. od wywołania REST po zapis w bazie i odpowiedź dla klienta. Te dwa typy pokazują, czy „całość działa razem”, ale są wolniejsze i bardziej wrażliwe na środowisko niż testy jednostkowe.

Po co pisać testy jednostkowe w Javie, skoro mam testy integracyjne i ręczne klikanie?

Testy jednostkowe wychwytują błędy w logice biznesowej dużo szybciej i precyzyjniej niż zestaw kilku scenariuszy integracyjnych czy ręczne testowanie UI. Gdy test jednostkowy się wyłoży, zwykle od razu wiesz, która metoda zawiodła i dla jakich danych – nie musisz odtwarzać całego procesu w aplikacji.

Druga różnica to koszt w czasie. Setki testów jednostkowych uruchamiają się w kilka sekund i można je odpalać przy każdym commicie. Długie testy integracyjne lub ręczne klikanie sprawdzają raczej „happy path”, a regresje w brzegowych scenariuszach wychodzą późno, często już na produkcji.

Kiedy w projekcie Java testy jednostkowe dają największy efekt?

Największy zwrot jest tam, gdzie zmiany są częste, a ryzyko błędu kosztowne. Chodzi szczególnie o nową logikę biznesową (pieniądze, uprawnienia, reguły procesów) oraz moduły, które będą rozwijane latami, np. billing czy integracje płatności. W takich miejscach nawet kilka dobrze przemyślanych testów jednostkowych potrafi oszczędzić wiele godzin debugowania.

W starym monolicie bez testów sensowne jest podejście warstwowe: każda nowa funkcjonalność lub poprawka błędu dostaje od razu zestaw testów jednostkowych. Z czasem powstaje „pierścień ochronny” wokół kluczowych fragmentów kodu, a najczęściej zmieniane miejsca stają się najlepiej przetestowane.

Czy testy jednostkowe spowalniają rozwój oprogramowania w Javie?

Na samym początku praca faktycznie bywa wolniejsza, bo dochodzi nauka narzędzi (JUnit, Mockito itp.) i zmiana nawyków projektowych. Po kilku tygodniach bilans zwykle się odwraca: mniej regresji, szybsze code review i mniejsza potrzeba ręcznego „klikania” po aplikacji po każdej zmianie.

Różnica jest szczególnie widoczna w większych zespołach. Nowa osoba robi zmianę w krytycznej metodzie, odpala testy jednostkowe i od razu widzi, czy złamała istniejące kontrakty. Senior nie musi czytać każdego warunku w kodzie – część pracy przejmują dobrze napisane testy.

Co jest „jednostką” w testach jednostkowych Javy: metoda, klasa czy use case?

W prostych przykładach jednostką bywa pojedyncza metoda bez zależności, np. funkcja parsująca datę. W bardziej rozbudowanych systemach sensowną jednostką jest cała klasa reprezentująca fragment logiki biznesowej albo wydzielony kawałek przypadku użycia, np. sama walidacja zamówienia bez zapisu do bazy.

Kluczowe kryteria są trzy: test ma być szybki, izolowany od infrastruktury i deterministyczny. Granica „unitu” powinna wynikać z logiki domeny (co jest spójnym kawałkiem zachowania), a nie z przypadkowego podziału na klasy techniczne.

Jak uniknąć niedeterministycznych testów jednostkowych w Javie?

Najczęstsze źródła niedeterministyczności to czas (LocalDateTime.now()), sieć (wywołania HTTP), system plików i losowość (Random). Zamiast korzystać z nich bezpośrednio w testowanej logice, lepiej wstrzykiwać abstrakcje, które w testach można podmienić na kontrolowane implementacje, np. Clock z ustawioną stałą datą albo generator losowy zwracający stałą sekwencję.

Jeśli test wymaga prawdziwej bazy, Dockera czy zewnętrznego API, to z definicji przestaje być testem jednostkowym, a staje się integracyjnym. Rozdzielenie tych poziomów pozwala utrzymać unity szybkie i powtarzalne, a jednocześnie nie rezygnować z testów, które sprawdzają realną współpracę z infrastrukturą.

Jak poprawnie strukturyzować testy jednostkowe w Javie (GIVEN–WHEN–THEN vs AAA)?

GIVEN–WHEN–THEN i AAA (Arrange–Act–Assert) opisują to samo w trochę innym języku. Najpierw przygotowujesz dane i obiekty (GIVEN/Arrange), potem wykonujesz jedno konkretne działanie (WHEN/Act), na końcu weryfikujesz rezultat (THEN/Assert). Taki układ sprawia, że po kilku miesiącach nadal łatwo zrozumieć, co dany test sprawdza.

W praktyce pomaga wizualne oddzielenie sekcji, np. pustymi liniami, krótkimi komentarzami albo metodami pomocniczymi typu givenValidOrder(). Dobrze ułożony test jednostkowy da się przeczytać jak mini‑specyfikację: wiesz, w jakim kontekście działa kod, co jest wywoływane i jakie zachowanie jest oczekiwane.