Najlepsze praktyki testowania aplikacji Spring Boot: od testów jednostkowych po integracyjne

0
3
Rate this post

Spis Treści:

Rola testowania w projektach Spring Boot – po co aż tyle rodzajów testów

Miejsce testów w cyklu życia aplikacji biznesowej

Aplikacje Spring Boot zwykle żyją latami: ewoluują, rośnie liczba funkcjonalności, zmieniają się integracje z systemami zewnętrznymi, dochodzą wymagania bezpieczeństwa i audytu. Testy nie są dodatkiem „dla zasady”, ale jednym z głównych narzędzi kontrolowania zmian. Dobrze zaprojektowany zestaw testów pozwala zmieniać kod szybciej, nie wolniej – pod warunkiem, że powstaje świadoma strategia testowania, a nie przypadkowy zbiór klas testowych.

W cyklu życia aplikacji Spring Boot testy pojawiają się już na etapie prototypu (proste testy jednostkowe kluczowych reguł biznesowych), ale ich znaczenie rośnie w momencie pierwszego wdrożenia produkcyjnego. Od tej chwili każdy błąd na środowisku produkcyjnym jest sygnałem, że brakuje konkretnego rodzaju testu lub istniejący test ma zbyt wąski zakres. Strategia testów powinna więc być rozwijana równolegle z systemem, a nie dopisywana po roku intensywnego developmentu.

W praktyce projekty, w których testy są częścią planowania sprintu, mają znacznie mniejszy koszt utrzymania. Każdy większy refaktoring (np. zmiana modelu domenowego, przebudowa warstwy REST) przebiega tam bez „ręcznego” klikania całej aplikacji po wdrożeniu. Zamiast tego uruchamiane są testy jednostkowe, integracyjne oraz wybrane testy end‑to‑end, dające relatywnie szybką odpowiedź, czy kluczowe ścieżki użytkowników zachowały się poprawnie.

Różnica między posiadaniem testów a strategią testowania

Posiadanie testów oznacza wyłącznie to, że istnieją klasy oznaczone jako testowe. Strategia testowania odpowiada natomiast na pytania: co jest testowane, w jaki sposób, na jakim poziomie oraz z jaką częstotliwością. W Spring Boot łatwo dołożyć adnotację @SpringBootTest i uruchamiać pełny kontekst przy każdej drobnej asercji, ale taka praktyka szybko mści się czasem wykonywania testów i niestabilnością środowiska testowego.

Dojrzała strategia testowania dla aplikacji Spring Boot określa m.in.:

  • jaki procent logiki biznesowej ma być objęty testami jednostkowymi bez kontekstu Springa,
  • które komponenty wymagają testów integracyjnych z prawdziwą bazą lub brokerem wiadomości,
  • jakie krytyczne ścieżki użytkownika są pokryte testami systemowymi/end‑to‑end,
  • czy i jak są testowane kontrakty API przy komunikacji między mikroserwisami.

Bez takiej strategii powstają testy nadmiarowe (dublujące asercje na wielu poziomach) albo odwrotnie – brakuje testów integracyjnych kluczowych zależności (np. zapisu danych w bazie czy wywołań REST). W obu przypadkach zespół traci zaufanie do testów: przy awariach pojawia się pytanie „co właściwie te testy sprawdzają?”.

Wpływ testów na refaktoryzację i zmiany technologiczne

Spring Boot, JPA, biblioteki bezpieczeństwa, sterowniki baz danych – wszystko to jest aktualizowane. Migracja z jednej wersji Spring Boot na kolejną lub podniesienie wersji Javy bez solidnego zestawu testów jest ryzykowna. Strategia testowa, która obejmuje zarówno testy jednostkowe reguł biznesowych, jak i testy integracyjne konfiguracji, pozwala wykonywać takie migracje z rozsądnym poziomem bezpieczeństwa.

Przykładowo: zmiana wersji sterownika PostgreSQL może nie wpływać na logikę biznesową, ale zmieniać zachowanie zapytań lub parametry transakcji. Testy typu @DataJpaTest z użyciem prawdziwej bazy (np. przez Testcontainers) szybko wychwytują takie różnice. Z kolei refaktoryzacja serwisu obsługującego proces sprzedaży może być bezpiecznie przeprowadzona, jeśli większość scenariuszy rabatowych, limitów i walidacji jest pokryta testami jednostkowymi działającymi bez Springa.

Szybki feedback lokalnie i pewność na CI/CD

Programiści zwykle oczekują dwóch rzeczy: szybkich testów lokalnie (sekundy, a nie minuty) oraz wysokiej pewności na pipeline’ach CI/CD. Aby pogodzić te oczekiwania, strategia testowa dla Spring Boot rozdziela testy na kilka „pierścieni” uruchamianych z różną częstotliwością:

  • lokalnie – głównie testy jednostkowe oraz wybrane slice’y Springa (np. @WebMvcTest),
  • na CI po każdym PR – pełny zestaw testów jednostkowych i integracyjnych (w tym Testcontainers),
  • na nightly builds – dłuższe testy end‑to‑end, testy kontraktowe z innymi systemami.

Takie podejście zwykle pozwala utrzymać sensowny czas feedbacku. Lokalny cykl „zmiana kodu → uruchomienie testów → poprawka” nie jest blokowany przez uruchamianie pełnego kontekstu Spring Boot przy każdym teście, a jednocześnie pipeline CI wykonuje cięższe testy, których programista nie musi uruchamiać po każdej małej zmianie.

Mapowanie poziomów testów na architekturę Spring Boot

Podstawowe poziomy testów w kontekście Spring Boot

W projektach Spring Boot da się wyróżnić kilka dość typowych poziomów testów. Każdy z nich pełni inną rolę i sprawdza inne ryzyka:

  • Testy jednostkowe – działają bez kontekstu Springa, skupiają się na pojedynczych klasach i metodach (np. serwisy, helpery, klasy domenowe). Używają JUnit 5, AssertJ i zwykle Mockito.
  • Testy integracyjne – uruchamiają kontekst Springa (w całości lub częściowo) i sprawdzają współdziałanie komponentów: serwis + repozytorium, kontroler + warstwa web, integracja z bazą danych lub kolejką.
  • Testy systemowe / end‑to‑end – badają całe przepływy biznesowe z punktu widzenia użytkownika lub zewnętrznego systemu, często z wykorzystaniem prawdziwej bazy i uruchomionej aplikacji.
  • Testy kontraktowe – weryfikują spójność API pomiędzy usługami (np. producer/consumer przy mikroserwisach), minimalizując ryzyko, że jedna strona zmieni kontrakt w sposób niezgodny z oczekiwaniami drugiej.

Dobór proporcji między tymi poziomami jest kluczowy. Zbyt duży nacisk na testy end‑to‑end powoduje wolne pipeline’y i częste flaki (testy niestabilne). Zbyt mało testów integracyjnych wywołań REST czy JPA prowadzi do sytuacji, w której logika jest poprawna, ale integracja z infrastrukturą nie działa.

Powiązanie poziomów testów z warstwami aplikacji

Typowa aplikacja Spring Boot składa się z kilku warstw: kontrolery webowe, serwisy, repozytoria, integracje z zewnętrznymi systemami. Każda warstwa „prosi się” o inny typ testów:

  • Kontrolery – najczęściej testowane przy użyciu @WebMvcTest i MockMvc, co pozwala skupić się na mapowaniu żądań/odpowiedzi, walidacji, statusach HTTP i integracji z Spring MVC.
  • Serwisy – dobrym punktem wyjścia są testy jednostkowe bez Springa (przy pomocy Mockito lub stubów), a dla bardziej złożonych scenariuszy – testy integracyjne z kontekstem (np. przy złożonych transakcjach).
  • Repozytoria – testy integracyjne z wykorzystaniem @DataJpaTest lub pełnego @SpringBootTest oraz realnej bazy (np. przez Testcontainers) w przypadku skomplikowanych zapytań.
  • Integracje z zewnętrznymi usługami – połączenie testów jednostkowych z użyciem mocków (np. RestTemplate, WebClient) oraz testów kontraktowych lub testów zbudowanych wokół stubów serwera.

Takie mapowanie pozwala uniknąć sytuacji, w której każda minimalna reguła biznesowa jest testowana z pełnym uruchomieniem aplikacji. Prosty algorytm liczenia rabatu może być w pełni pokryty testami jednostkowymi, a tylko jeden test integracyjny sprawdza, czy ten algorytm jest faktycznie wywoływany przez odpowiedni serwis Springa w odpowiednim kontekście transakcyjnym.

Piramida testów a realne projekty Spring

Klasyczna piramida testów zakłada dużą liczbę testów jednostkowych, mniej integracyjnych i jeszcze mniej end‑to‑end. W projektach Spring Boot teoria ta często się spłaszcza, ponieważ programistom jest wygodniej pisać testy z @SpringBootTest niż precyzyjnie izolować logikę. W efekcie powstaje „odwrócona piramida”, w której główną masę stanowią wolne, integracyjne testy z pełnym kontekstem.

Co do zasady lepiej trzymać się piramidy, ale z zachowaniem zdrowego rozsądku. Spring oferuje tzw. slice tests (@WebMvcTest, @DataJpaTest, @JsonTest), które pozwalają zbliżyć się do ideału: duża liczba relatywnie szybkich testów na niższych warstwach, mniejsza liczba testów pełnej aplikacji. Kluczowe jest zrozumienie, że test integracyjny może nadal być lekki, jeśli ładuje tylko fragment kontekstu.

Każda biblioteka to potencjalne rozszerzenie kontekstu testowego, dodatkowe skanowanie klas czy niestandardowa konfiguracja Springa. Przy większych monolitycznych aplikacjach różnica między „gołym” spring-boot-starter-test a zestawem kilku dodatkowych bibliotek może wynosić kilkadziesiąt sekund na pipeline. Jeżeli zespół korzysta z platform edukacyjnych lub kursów online takich jak Programista Java – kursy online, blog i praktyczne projekty, często spotyka przykładowe, minimalistyczne konfiguracje testowe, które warto zaadaptować i stopniowo rozwijać.

Kiedy świadomie spłaszczyć piramidę w mikroserwisach

W środowisku mikroserwisowym bywa, że piramida testów jest nieco spłaszczona. Przy niewielkich, wyspecjalizowanych usługach, w których większość logiki biznesowej polega na orkiestracji wywołań i mapowaniu danych, liczba testów jednostkowych może być mniejsza, a większy nacisk kładzie się na integracje i testy kontraktowe API.

Świadome „spłaszczanie” ma sens, jeżeli:

  • mikroserwis jest prosty, a większość logiki to przepływ danych między systemami,
  • istnieje solidny zestaw testów kontraktowych (np. Spring Cloud Contract), które redukują ryzyko integracyjne,
  • czas wykonywania testów integracyjnych nadal mieści się w akceptowalnym oknie czasowym pipeline’u CI.

Nawet w takich projektach warto jednak utrzymać co do zasady przewagę testów jednostkowych nad „pełnymi” testami ze środowiskiem zewnętrznym, choćby z powodów czysto praktycznych: mniejsza podatność na problemy infrastrukturalne i prostsza diagnostyka awarii.

Konfiguracja środowiska testowego w Spring Boot krok po kroku

Podstawowe zależności: JUnit 5, Spring Boot Test, AssertJ, Mockito

Dobry punkt startowy dla testowania Spring Boot to zestaw zależności obejmujący:

  • spring-boot-starter-test – agreguje JUnit 5, AssertJ, Mockito, Hamcrest, Spring Test, przydatne extensiony,
  • sterownik bazy testowej (H2, PostgreSQL, MySQL – w zależności od strategii),
  • ewentualnie testcontainers dla uruchamiania prawdziwych baz czy brokerów w kontenerach.

W nowoczesnych projektach defaultem jest JUnit 5 (Jupiter). Umożliwia on m.in. elastyczne korzystanie z adnotacji @Nested, warunkowego uruchamiania testów, rozszerzeń (Extensions) i integracji z Spring TestContext Framework (@ExtendWith(SpringExtension.class), które w starterze testowym jest już skonfigurowane).

Mockito służy do izolowania zależności w testach jednostkowych, natomiast AssertJ oferuje płynne, czytelne asercje (np. assertThat(result).containsExactlyInAnyOrder(...)). Ten zestaw jest zwykle wystarczający na start, choć w bardziej rozbudowanych projektach pojawiają się dodatkowe biblioteki (np. WireMock do stubowania serwerów HTTP).

Dobór zależności a czas działania testów

Im więcej zależności testowych, tym wyższe ryzyko problemów z czasem uruchamiania i konfliktów konfiguracji. Co do zasady dobrze jest:

  • korzystać z spring-boot-starter-test jako głównego pakietu,
  • unikać dublowania frameworków (np. mieszania JUnit 4 i 5, o ile nie ma ku temu bardzo konkretnej przyczyny),
  • dodatkowe biblioteki (WireMock, RestAssured, Awaitility) dodawać świadomie, tylko tam, gdzie przynoszą realną wartość.

Profile testowe i oddzielenie konfiguracji od produkcji

Spring Boot bardzo ułatwia tworzenie odrębnej konfiguracji dla środowiska testowego. Standardowym podejściem jest utworzenie pliku application-test.yml i uruchamianie testów z profilem test (np. przez @ActiveProfiles("test") lub konfigurację w pom.xml/build.gradle). W tym profilu można nadpisać m.in.:

  • parametry połączenia do bazy danych,
  • konfigurację integracji z systemami zewnętrznymi (np. endpointy stubów zamiast prawdziwych URL‑i),
  • ustawienia logowania (bardziej szczegółowe logi SQL, logi Spring Security itp.).

Izolowanie środowiska testowego od zewnętrznych systemów

Konfiguracja profilu testowego rzadko kończy się na samej bazie danych. Równie istotne jest odizolowanie testów od zewnętrznych API, kolejek czy systemów plików. W przeciwnym razie każde chwilowe spowolnienie lub awaria usługi zewnętrznej będzie destabilizować pipeline CI.

Dobrym punktem wyjścia jest zdefiniowanie interfejsów integracyjnych oraz wstrzykiwanie ich implementacji przez Springa. W testach można wtedy podmienić je na implementacje mockujące lub stuby HTTP. Przykład najprostszego podejścia bazującego na profilu:

@Configuration
@Profile("test")
public class ExternalSystemsTestConfig {

  @Bean
  public PaymentClient paymentClientStub() {
    return new PaymentClientStub();
  }
}

W produkcji używana jest prawdziwa implementacja (np. REST‑owa), a w środowisku testowym – wersja w pamięci, która zwraca przewidywalne dane i nie wymaga uruchamiania dodatkowych usług.

Przy bardziej rozbudowanych integracjach HTTP przydają się rozwiązania dedykowane: WireMock, MockServer albo Spring Cloud Contract Stub Runner. Pozwalają one symulować rzeczywiste zachowanie serwera z uwzględnieniem odpowiedzi błędnych, timeoutów czy niestandardowych nagłówków.

Kontrola danych testowych i migracje schematu

W testach bazodanowych częstym problemem jest stopniowe „zarastanie” bazy danymi testowymi oraz trudność w utrzymaniu spójnego schematu. Co do zasady bezpieczniej jest uruchamiać migracje schematu (Liquibase, Flyway) również w profilach testowych, tak aby struktura bazy jak najwierniej odwzorowywała produkcję.

W prostszych projektach wystarczy plik SQL z inicjalnymi danymi ładowany przez Springa (data.sql) lub adnotacje typu @Sql na testach. Przy większych systemach lepiej sprawdza się jawne przygotowywanie danych per test:

  • fabryki obiektów lub buildery (np. TestOrderFactory),
  • metody pomocnicze zapisujące encje do bazy przed danym scenariuszem,
  • czyszczenie danych po każdym teście (rollback transakcji lub @DirtiesContext stosowane oszczędnie).

Testy stają się przez to bardziej samowystarczalne: nie polegają na globalnym zestawie danych, który po kilku miesiącach trudno zrozumieć i bezpiecznie modyfikować.

Laptop z kodem Spring Boot obok maskotki w jasnym pokoju z roślinami
Źródło: Pexels | Autor: Daniil Komov

Testy jednostkowe logiki biznesowej – „czysty” kod a zależności Springa

Projektowanie serwisów pod testy jednostkowe

Najtańsze w utrzymaniu testy jednostkowe działają bez udziału Springa – zwykła instancja klasy serwisowej, zależności przekazane w konstruktorze, Mockito lub ręczne stuby. Taki styl wymusza konstruktorowe wstrzykiwanie zależności i wyraźne oddzielenie logiki biznesowej od szczegółów technicznych (JPA, HTTP, kolejki).

class DiscountServiceTest {

  private final CustomerRepository customerRepository = mock(CustomerRepository.class);
  private final DiscountPolicy discountPolicy = new PercentageDiscountPolicy();

  private final DiscountService discountService =
      new DiscountService(customerRepository, discountPolicy);

  @Test
  void shouldCalculateDiscountForLoyalCustomer() {
    when(customerRepository.findById(1L))
        .thenReturn(Optional.of(new Customer(1L, CustomerType.LOYAL)));

    BigDecimal discount = discountService.calculateDiscount(1L, new BigDecimal("100.00"));

    assertThat(discount).isEqualByComparingTo("10.00");
  }
}

Brak adnotacji Springa w powyższym przykładzie sprawia, że test uruchamia się bardzo szybko i jest mało podatny na zmiany konfiguracji kontekstu. Jeżeli konieczne staje się wstrzyknięcie elementu związanego z frameworkiem (np. ApplicationEventPublisher), zwykle można go zamockować.

Unikanie „anemicznych” testów jednostkowych

Niekiedy spotyka się testy, które asercją weryfikują jedynie, że wywołano odpowiednią metodę na mocku, bez sprawdzenia efektu biznesowego. Przykładowo: test metodę serwisu, która jedynie deleguje wywołanie do repozytorium. Tego rodzaju testy są mało odporne na refaktoryzację – każde przeniesienie logiki do innej klasy lub zmiana sygnatury metody wymaga ich aktualizacji, choć zachowanie systemu z punktu widzenia użytkownika się nie zmienia.

Lepszym podejściem jest formułowanie testów wokół efektu: czy klient otrzymał poprawny rabat, czy zamówienie ma oczekiwany status, czy na podstawie danych wejściowych została podjęta właściwa decyzja. Detale implementacyjne, takie jak liczba wywołań repozytorium, zwykle są drugorzędne i nie powinny wiązać testu z konkretną strukturą kodu.

Testowanie obiektów domenowych i reguł bez Springa

W modelu DDD znaczna część logiki biznesowej powinna znajdować się w obiektach domenowych (agregatach, encjach). Testowanie takich obiektów nie wymaga Springa, a często nawet nie wymaga Mockito. To zwykłe metody Javy:

class OrderTest {

  @Test
  void shouldNotAllowClosingOrderWithoutItems() {
    Order order = new Order();

    assertThatThrownBy(order::close)
        .isInstanceOf(IllegalStateException.class)
        .hasMessageContaining("no items");
  }
}

Takie testy są bardzo szybkie i stabilne. Zmiana frameworka webowego czy persystencyjnego nie wpływa na ich treść. W praktyce to one stanowią „rdzeń” piramidy testów, a dopiero wokół nich dokłada się testy integracyjne, które sprawdzają klejenie warstw.

Testowanie komponentów Springa z użyciem @SpringBootTest i węższych slice’y

Kiedy sięgnąć po @SpringBootTest

@SpringBootTest uruchamia pełny kontekst aplikacji Spring Boot. To wygodne, lecz stosowane bezrefleksyjnie szybko wydłuża czas pipeline’u. Użycie tej adnotacji zwykle jest zasadne, gdy:

  • scenariusz obejmuje wiele warstw (web + serwis + baza) i zależy od auto‑konfiguracji Springa,
  • testuje się mechanizmy przekrojowe (transakcje, eventy domenowe, bezpieczeństwo),
  • konieczne jest załadowanie całej konfiguracji, bo logika opiera się na zewnętrznych plikach properties czy beanach konfiguracyjnych.

Przykład prostego testu integracyjnego z pełnym kontekstem:

@SpringBootTest
@ActiveProfiles("test")
class OrderFlowIntegrationTest {

  @Autowired
  private MockMvc mockMvc;

  @Test
  void shouldCreateAndFetchOrder() throws Exception {
    mockMvc.perform(post("/orders")
            .contentType(MediaType.APPLICATION_JSON)
            .content("{"productId": 1, "quantity": 2}"))
        .andExpect(status().isCreated());

    mockMvc.perform(get("/orders/1"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.id").value(1));
  }
}

Takie testy dają dużą pewność co do spójności całej aplikacji, ale z reguły powinno być ich względnie niewiele w porównaniu do testów jednostkowych i „slice’ów”.

@WebMvcTest, @DataJpaTest i inne „krojone” testy

Spring Boot udostępnia adnotacje, które uruchamiają wycinek kontekstu – wystarczający do przetestowania jednej warstwy. Pozwala to zachować równowagę między szybkością a realizmem.

  • @WebMvcTest – ładuje warstwę webową (kontrolery, konwertery, web‑related konfiguracje) bez repozytoriów i serwisów, które trzeba podstawić jako mocki.
  • @DataJpaTest – konfiguruje JPA i bazę danych (często w pamięci), wyłączając web, security i inne moduły.
  • @JsonTest – ogranicza się do testowania serializacji/deserializacji JSON (np. obiektów DTO).

Przykład użycia @WebMvcTest dla kontrolera REST:

Jeśli interesują Cię konkrety i przykłady, rzuć okiem na: Jak zoptymalizować koszty uruchamiania aplikacji Java w chmurze?.

@WebMvcTest(OrderController.class)
@ActiveProfiles("test")
class OrderControllerTest {

  @Autowired
  private MockMvc mockMvc;

  @MockBean
  private OrderService orderService;

  @Test
  void shouldReturnOrderDetails() throws Exception {
    when(orderService.getOrder(1L))
        .thenReturn(new OrderDto(1L, "CREATED"));

    mockMvc.perform(get("/orders/1"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.id").value(1))
        .andExpect(jsonPath("$.status").value("CREATED"));
  }
}

Kontekst startuje tu znacznie szybciej niż przy @SpringBootTest, a jednocześnie zachowane są wszystkie filtry i mechanizmy Spring MVC.

Optymalizacja czasu wykonania testów z kontekstem Springa

Czas uruchamiania testów integracyjnych można w istotny sposób ograniczyć, jeżeli kontekst nie jest bez potrzeby odświeżany. Ramowo pomaga:

  • unikanie @DirtiesContext, które zmusza Springa do przeładowania kontekstu po teście,
  • grupowanie testów korzystających z podobnej konfiguracji w jednym module lub pakiecie,
  • świadome dodawanie konfiguracji kontekstów (ograniczanie @ComponentScan w testach, jeżeli nie jest potrzebny pełny scanning).

W praktyce bywa tak, że jedna nieprzemyślana adnotacja lub klasa konfiguracyjna w testach powoduje doładowanie kilkudziesięciu beanów z produkcyjnego kontekstu. Analiza logów startu (lub aktywacja debug loggingu Springa) pomaga zidentyfikować „ciężkie” fragmenty konfiguracji, które należałoby wyłączyć lub zastąpić lżejszymi stubami.

Testy warstwy web – kontrolery REST, walidacja, bezpieczeństwo

Mapowanie żądań i odpowiedzi z użyciem MockMvc

Warstwa webowa to miejsce, w którym drobne pomyłki (np. zły status HTTP, błędny format JSON) generują sporo frustracji po stronie integratorów. MockMvc umożliwia sprawdzenie zachowania kontrolerów bez konieczności uruchamiania serwera na prawdziwym porcie.

@WebMvcTest(CustomerController.class)
class CustomerControllerValidationTest {

  @Autowired
  private MockMvc mockMvc;

  @Test
  void shouldReturn400WhenValidationFails() throws Exception {
    String invalidPayload = "{"email": "not-an-email"}";

    mockMvc.perform(post("/customers")
            .contentType(MediaType.APPLICATION_JSON)
            .content(invalidPayload))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.errors[0].field").value("email"));
  }
}

Oprócz statusu odpowiedzi warto sprawdzać nagłówki (np. Location przy tworzeniu zasobu) oraz strukturę błędów, aby klienci API mieli stabilny kontrakt.

Walidacja danych wejściowych – Bean Validation w testach

Przy walidacji opartej na Bean Validation (@Valid, adnotacje typu @NotNull, @Size) część scenariuszy da się przetestować bez uruchamiania pełnej warstwy webowej. Wystarczy walidator:

class CustomerDtoValidationTest {

  private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();

  @Test
  void shouldFailValidationOnMissingEmail() {
    CustomerDto dto = new CustomerDto(null);

    Set<ConstraintViolation<CustomerDto>> violations = validator.validate(dto);

    assertThat(violations)
        .extracting(ConstraintViolation::getPropertyPath)
        .anyMatch(path -> path.toString().equals("email"));
  }
}

Takie testy są bardzo szybkie i niezależne od Springa, a pozwalają utrzymać spójną logikę walidacji nawet wtedy, gdy DTO wykorzystywane jest w więcej niż jednym kontrolerze.

Testowanie bezpieczeństwa – Spring Security Test

Jeżeli aplikacja korzysta ze Spring Security, testy warstwy web nie powinny tego ignorować. Pakiet spring-security-test (włączony w spring-boot-starter-test) oferuje szereg adnotacji i konfigurowalnych „użytkowników” testowych.

@WebMvcTest(AdminController.class)
@Import(SecurityConfig.class)
class AdminControllerSecurityTest {

  @Autowired
  private MockMvc mockMvc;

  @Test
  @WithMockUser(username = "admin", roles = "ADMIN")
  void shouldAllowAccessForAdminRole() throws Exception {
    mockMvc.perform(get("/admin/config"))
        .andExpect(status().isOk());
  }

  @Test
  @WithMockUser(username = "user", roles = "USER")
  void shouldDenyAccessForNonAdminRole() throws Exception {
    mockMvc.perform(get("/admin/config"))
        .andExpect(status().isForbidden());
  }
}

Tego rodzaju testy pomagają uniknąć drobnych, lecz kosztownych w skutkach pomyłek w konfiguracji uprawnień. Dodatkowo można testować scenariusze oparte na JWT lub OAuth2, przygotowując odpowiednio sfałszowane tokeny i sprawdzając ich obsługę w filtrach.

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

Testowanie warstwy danych z @DataJpaTest i Testcontainers

@DataJpaTest – szybkie testy repozytoriów i encji

@DataJpaTest uruchamia konfigurację JPA z wykorzystaniem wbudowanej bazy (domyślnie H2), wykrywa encje i repozytoria, a także konfiguruje rollback transakcji po każdym teście. Dzięki temu każdy test startuje z „czystą” bazą, a dane nie przeciekają między scenariuszami.

@DataJpaTest
@ActiveProfiles("test")
class OrderRepositoryTest {

  @Autowired
  private OrderRepository orderRepository;

  @Test
  void shouldFindOrdersByStatus() {
    orderRepository.save(new OrderEntity(null, "CREATED"));
    orderRepository.save(new OrderEntity(null, "CLOSED"));

    List<OrderEntity> createdOrders = orderRepository.findByStatus("CREATED");

    assertThat(createdOrders).hasSize(1);
  }
}

Takie testy dobrze sprawdzają się przy customowych zapytaniach JPQL/SQL, kwerendach złożonych (JOIN, subselecty) oraz przy weryfikacji mapowania encji (relacje, kaskady, strategie ładowania).

Różnice między bazą w pamięci a produkcyjną

Bazy w pamięci, takie jak H2, zachowują się inaczej niż typowe bazy produkcyjne (PostgreSQL, MySQL). Dotyczy to zwłaszcza:

Pułapki testów na H2 – dialekt, typy danych i różnice w SQL

Silnik H2 jest wygodny i szybki, ale implementuje własny dialekt SQL i mechanizmy transakcyjne. Przy prostych encjach i zapytaniach zwykle nie ma problemu, jednak przy bardziej złożonych scenariuszach rozjazd z bazą produkcyjną bywa znaczący. Najczęstsze rozbieżności dotyczą:

  • typów danych (np. uuid, jsonb, precyzja numeric/decimal),
  • funkcji wbudowanych (np. now(), funkcje dat, operacje na JSON),
  • zachowania przy blokadach i współbieżności,
  • indeksów częściowych i wyrażeń (np. index tylko dla rekordów ze statusem aktywnym),
  • specyficznych rozszerzeń danego silnika (np. operator ILIKE w PostgreSQL).

Jeżeli test przechodzi na H2, a w środowisku testowym z prawdziwą bazą pojawiają się błędy migracji lub zapytań, źródłem rozjazdu jest właśnie inny dialekt. W takiej sytuacji lepszym rozwiązaniem jest przełączenie się na Testcontainers lub skonfigurowanie H2 w trybie zgodności z wybranym silnikiem (co i tak nie obejmie wszystkich różnic).

Testcontainers – testy na „prawdziwej” bazie w Dockerze

Testcontainers pozwala uruchomić bazę danych w kontenerze Docker na czas testów. Z punktu widzenia aplikacji jest to normalna baza, więc zachowanie transakcji, typów, indeksów czy funkcji jest takie jak w produkcji. Przykład konfiguracji dla PostgreSQL:

@Testcontainers
@DataJpaTest
class PostgresOrderRepositoryTest {

  @Container
  static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
      .withDatabaseName("testdb")
      .withUsername("test")
      .withPassword("test");

  @DynamicPropertySource
  static void overrideProps(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", postgres::getJdbcUrl);
    registry.add("spring.datasource.username", postgres::getUsername);
    registry.add("spring.datasource.password", postgres::getPassword);
  }

  @Autowired
  private OrderRepository orderRepository;

  @Test
  void shouldPersistAndLoadOrderUsingRealPostgres() {
    OrderEntity saved = orderRepository.save(new OrderEntity(null, "CREATED"));

    Optional<OrderEntity> loaded = orderRepository.findById(saved.getId());

    assertThat(loaded).isPresent();
    assertThat(loaded.get().getStatus()).isEqualTo("CREATED");
  }
}

Takie podejście jest wolniejsze niż H2, lecz zbliża testy do rzeczywistych warunków. W praktyce rozsądne bywa połączenie obu technik: szybkie testy repozytoriów i encji na H2 + wybrane, newralgiczne scenariusze (np. złożone migracje Flyway/Liquibase, specyficzne typy danych) na Testcontainers.

Testy migracji bazy danych z Flyway/Liquibase

Migracje schematu bazy (Flyway, Liquibase) są częstym źródłem incydentów w środowiskach wyższych niż deweloperskie. Z punktu widzenia testów Spring Boot można je sprawdzać automatycznie, podnosząc kontekst z Testcontainers i pozwalając migracjom się wykonać.

@SpringBootTest
@Testcontainers
class DatabaseMigrationsTest {

  @Container
  static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");

  @DynamicPropertySource
  static void configure(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", postgres::getJdbcUrl);
    registry.add("spring.datasource.username", postgres::getUsername);
    registry.add("spring.datasource.password", postgres::getPassword);
  }

  @Autowired
  private DataSource dataSource;

  @Test
  void shouldApplyAllMigrationsSuccessfully() throws Exception {
    try (Connection conn = dataSource.getConnection();
         PreparedStatement ps = conn.prepareStatement("SELECT COUNT(*) FROM flyway_schema_history")) {
      ResultSet rs = ps.executeQuery();
      assertThat(rs.next()).isTrue();
      assertThat(rs.getInt(1)).isGreaterThan(0);
    }
  }
}

Ten typ testu nie weryfikuje logiki biznesowej, lecz raczej poprawność procesu „podnoszenia” schematu – czy wszystkie skrypty da się odtworzyć na czystej bazie oraz czy finalny stan jest spójny.

Współbieżność i transakcje – testy przy wielu wątkach

Przy bardziej rozbudowanych systemach kluczowa staje się poprawna obsługa transakcji i izolacji. Testy jednostkowe nie wychwycą sytuacji wyścigowych, dlatego czasem stosuje się testy współbieżne na warstwie danych:

@DataJpaTest
class ConcurrentUpdateTest {

  @Autowired
  private OrderRepository orderRepository;

  @Autowired
  private EntityManager entityManager;

  @Test
  void shouldHandleOptimisticLocking() {
    OrderEntity order = orderRepository.save(new OrderEntity(null, "CREATED"));
    entityManager.flush();
    entityManager.clear();

    OrderEntity o1 = orderRepository.findById(order.getId()).orElseThrow();
    OrderEntity o2 = orderRepository.findById(order.getId()).orElseThrow();

    o1.setStatus("PAID");
    orderRepository.saveAndFlush(o1);

    o2.setStatus("CANCELLED");

    assertThatThrownBy(() -> orderRepository.saveAndFlush(o2))
        .isInstanceOf(ObjectOptimisticLockingFailureException.class);
  }
}

Taki scenariusz nie symuluje pełnej współbieżności wielu wątków, ale pozwala sprawdzić, czy mechanizm optymistycznych blokad (np. kolumna @Version) działa zgodnie z oczekiwaniami. Bardziej złożone przypadki można wymusić przy użyciu kilku wątków i barier synchronizacyjnych, choć w praktyce stosuje się je raczej oszczędnie, ze względu na kruchość i czas wykonania.

Konfiguracja testowego profilu bazy danych

Dobrą praktyką jest utrzymywanie osobnego profilu Springa dla testów, z konfiguracją bazy dopasowaną do kontekstu.

# application-test.properties
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=false
spring.jpa.properties.hibernate.format_sql=false
spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true

Przy takim podejściu testy wymuszają istnienie wszystkich tabel (brak automatycznego tworzenia schematu przez Hibernate), co wymusza spójność między migracjami a encjami. Jeżeli celem jest szybkie prototypowanie, można tymczasowo przełączyć się na create-drop, ale na dłuższą metę wiąże się to z ryzykiem, że skrypty migracyjne nie nadążą za zmianami modelu.

Testowanie logiki domenowej a architektura aplikacji

Oddzielenie „czystej” domeny od Springa

W modelu, w którym logika biznesowa jest ściśle zintegrowana z komponentami Springa (serwisy jako beany, wywołania repozytoriów i eventów domenowych w jednej klasie), testy jednostkowe stają się ciężkie i zależne od frameworka. Alternatywą jest wydzielenie warstwy domenowej, która nie zna Springa:

  • encje domenowe i agregaty jako zwykłe klasy Java (POJO),
  • serwisy domenowe zawierające reguły biznesowe, operujące na interfejsach repozytoriów,
  • adaptery infrastrukturalne (Spring Data, messaging) implementujące interfejsy z domeny.

Test jednostkowy dotyczy wówczas serwisu domenowego, który dostaje proste stuby lub mocki repozytoriów, bez uruchamiania kontekstu. Przykład uproszczonego serwisu:

class OrderDomainService {

  private final OrderRepositoryPort orderRepository;
  private final PaymentGatewayPort paymentGateway;

  OrderDomainService(OrderRepositoryPort orderRepository,
                     PaymentGatewayPort paymentGateway) {
    this.orderRepository = orderRepository;
    this.paymentGateway = paymentGateway;
  }

  Order placeOrder(PlaceOrderCommand cmd) {
    Order order = Order.create(cmd.productId(), cmd.quantity());
    orderRepository.save(order);

    PaymentResult result = paymentGateway.charge(order.totalPrice());
    order.applyPaymentResult(result);

    orderRepository.save(order);
    return order;
  }
}
class OrderDomainServiceTest {

  private final OrderRepositoryPort orderRepository = mock(OrderRepositoryPort.class);
  private final PaymentGatewayPort paymentGateway = mock(PaymentGatewayPort.class);

  private final OrderDomainService service =
      new OrderDomainService(orderRepository, paymentGateway);

  @Test
  void shouldApplyPaymentResultToOrder() {
    when(paymentGateway.charge(any()))
        .thenReturn(PaymentResult.success("tx-123"));

    Order order = service.placeOrder(new PlaceOrderCommand(1L, 2));

    verify(orderRepository, times(2)).save(any(Order.class));
    assertThat(order.getPaymentStatus()).isEqualTo(PaymentStatus.PAID);
  }
}

Tak skonstruowane testy są odporne na zmiany w konfiguracji Springa i uruchamiają się w ułamku sekundy, dzięki czemu większość regresji biznesowych wychwytywana jest bardzo wcześnie.

Testy kontraktów warstwy aplikacyjnej

Między „czystą” domeną a światem zewnętrznym (REST, kolejki, bazy) występuje zwykle warstwa aplikacyjna: fasady, use case’y, serwisy aplikacyjne. Te komponenty często korzystają z adnotacji Springa (transakcje, bezpieczeństwo), ale główna logika ich działania powinna pozostać deterministyczna.

Dla takich elementów sprawdzają się testy na granicy jednostkowych i integracyjnych: uruchamia się fragment kontekstu Springa z realnymi beanami danego modułu i zewnętrznymi zależnościami podmienionymi na mocki lub @TestConfiguration. Przykład:

@SpringBootTest(
    classes = {OrderApplicationService.class, TestConfig.class},
    webEnvironment = SpringBootTest.WebEnvironment.NONE
)
@ActiveProfiles("test")
class OrderApplicationServiceTest {

  @Autowired
  private OrderApplicationService orderApplicationService;

  @Autowired
  private OrderRepositoryPort orderRepository;

  @Autowired
  private PaymentGatewayPort paymentGateway;

  @Test
  void shouldWrapDomainErrorsIntoApplicationException() {
    when(paymentGateway.charge(any()))
        .thenThrow(new PaymentGatewayException("timeout"));

    assertThatThrownBy(() ->
        orderApplicationService.placeOrder(new PlaceOrderDto(1L, 1)))
        .isInstanceOf(OrderPlacementException.class);
  }

  @TestConfiguration
  static class TestConfig {

    @Bean
    public OrderRepositoryPort orderRepository() {
      return mock(OrderRepositoryPort.class);
    }

    @Bean
    public PaymentGatewayPort paymentGateway() {
      return mock(PaymentGatewayPort.class);
    }
  }
}

Tak skonstruowany test pozwala równocześnie weryfikować, że Spring poprawnie wstrzykuje zależności, a serwis aplikacyjny zachowuje się zgodnie z kontraktem (np. opakowuje wyjątki infrastruktury w wyjątki domenowe lub statusy błędów).

Testy integracyjne scenariuszy biznesowych end-to-end

Dla najistotniejszych przypadków biznesowych przydaje się przynajmniej kilka testów przechodzących przez całą ścieżkę: od wywołania REST, przez warstwę aplikacyjną, domenę i bazę danych, aż po komunikację asynchroniczną.

Na koniec warto zerknąć również na: Tworzenie i zarządzanie usługami REST pomocniczymi z terminala — to dobre domknięcie tematu.

Takie testy zwykle:

  • wykorzystują @SpringBootTest z pełnym lub prawie pełnym kontekstem,
  • korzystają z Testcontainers dla bazy danych,
  • czasem uruchamiają również brokera wiadomości (np. Kafka, RabbitMQ) w kontenerze,
  • wykonują kilka kroków po kolei, sprawdzając skutki uboczne (np. zapis do bazy, wysłanie komunikatu).
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderEndToEndTest {

  @Container
  static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");

  @DynamicPropertySource
  static void dbProps(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", postgres::getJdbcUrl);
    registry.add("spring.datasource.username", postgres::getUsername);
    registry.add("spring.datasource.password", postgres::getPassword);
  }

  @Autowired
  private TestRestTemplate restTemplate;

  @Autowired
  private OrderRepository orderRepository;

  @Test
  void shouldCreateOrderAndPersistItInDatabase() {
    ResponseEntity<OrderDto> response = restTemplate.postForEntity(
        "/orders",
        new CreateOrderRequest(1L, 2),
        OrderDto.class
    );

    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
    Long id = response.getBody().id();

    Optional<OrderEntity> stored = orderRepository.findById(id);
    assertThat(stored).isPresent();
    assertThat(stored.get().getStatus()).isEqualTo("CREATED");
  }
}

Liczba takich testów powinna być rozsądnie ograniczona. W praktyce dobrym kryterium jest istotność ścieżki dla biznesu – im większy wpływ funkcji na przychód lub ryzyko, tym silniejsza motywacja, by mieć dla niej kompletny scenariusz end-to-end.

Strategia doboru testów i utrzymanie ich jakości

Piramida testów w kontekście Spring Boot

Klasyczne podejście do piramidy testów w środowisku Spring Boot przekłada się na trzy główne poziomy:

  • obszerny fundament testów jednostkowych „czystej” domeny i prostych serwisów,
  • środkową warstwę testów slice’ów i komponentów (web, JPA, bezpieczeństwo),
  • stosunkowo cienki wierzchołek testów end-to-end z pełnym kontekstem i infrastrukturą.

W miarę rozwoju systemu liczba testów integracyjnych często rośnie szybciej niż jednostkowych, co prowadzi do wydłużenia pipeline’ów i spadku chęci ich uruchamiania lokalnie. Dlatego lepiej od początku narzucić sobie dyscyplinę: każde zachowanie biznesowe ma test jednostkowy, a test integracyjny pojawia się wtedy, gdy wchodzi w grę interakcja z infrastrukturą lub nietrywialna konfiguracja Springa.

Testy w CI/CD – szybka pętla a pełny zestaw

Praktyczne podejście dzieli testy na kilka „kręgów” uruchamianych w różnych momentach:

  • lokalnie i przy każdym commicie – najszybsze testy jednostkowe i lekkie slice’y (bez Testcontainers),
  • na gałęziach feature’owych – pełen zestaw testów z bazą w kontenerze,
  • przy merge’u do głównej gałęzi – dodatkowe testy end-to-end oraz długotrwałe scenariusze (np. testy migracji).

Najważniejsze punkty

  • Testy w aplikacjach Spring Boot są jednym z głównych narzędzi kontrolowania zmian w długim cyklu życia systemu; dobrze zaprojektowany zestaw testów przyspiesza rozwój i refaktoryzację zamiast je spowalniać.
  • Kluczowa jest strategia testowania, a nie samo „posiadanie testów”: trzeba świadomie zdecydować, co testować jednostkowo, co integracyjnie, które ścieżki objąć testami end‑to‑end i jak weryfikować kontrakty API między usługami.
  • Brak przemyślanej strategii prowadzi albo do nadmiarowych, dublujących się testów, albo do luk w krytycznych obszarach (np. zapis do bazy, wywołania REST), co wprost obniża zaufanie zespołu do wyników testów.
  • Stabilne testy jednostkowe logiki biznesowej oraz testy integracyjne konfiguracji umożliwiają bezpieczniejsze migracje technologiczne (np. wersja Spring Boot, sterownik bazy), bo szybko ujawniają zmiany zachowania na poziomie zapytań czy transakcji.
  • Strategia testowa powinna rozdzielać poziomy testów: jednostkowe bez kontekstu Springa, integracyjne z częściowym lub pełnym kontekstem, systemowe/end‑to‑end oraz kontraktowe – każdy z nich adresuje inne ryzyka architektury Spring Boot.
  • Skuteczna organizacja testów zakłada różne „pierścienie” uruchamiania: lokalnie szybkie testy jednostkowe i wybrane slice’y Springa, na CI po każdym PR pełen pakiet jednostkowych i integracyjnych, a w nightly – dłuższe testy end‑to‑end i kontraktowe.