consdata.com
Blog techniczny Blog biznesowy Dział HR
EN
transactions

Czy wiesz dlaczego nie powinno się stosować adnotacji @Transactional w testach integracyjnych z Hibernate?

author Robert Mastalerek
1 września 2025

Testy integracyjne z użyciem Springa i Hibernate mają za zadanie możliwie wiernie odwzorować zachowanie aplikacji na środowisku produkcyjnym. Często, aby uprościć ich tworzenie, sięgamy po adnotację @Transactional, która automatycznie rollbackuje wszystkie zmiany w bazie danych po zakończeniu testu. Brzmi idealnie – nie musimy martwić się o „czystość” bazy, a każdy scenariusz startuje od świeżego punktu.

Jak Spring obsługuje adnotację @Transactional?

Wykorzystane jest w tym celu AoP (Aspect-oriented Programming). W zależności od tego, czy używamy Spring Aspects czy AspectJ, @Transactional zostaje wykryty albo w Spring Beans wyłącznie dla metod publicznych, albo w dowolnym miejscu w kodzie. Następnie wszystkie znalezione metody opakowane zostają w proxy, które rozpoczyna transakcję przed wywołaniem rzeczywistej logiki metody i zatwierdza ją po jej zakończeniu (lub wycofuje w przypadku wyjątku zgłoszonego przez tę metodę). Gdy @Transactional używany jest w testach integracyjnych, automatycznie wycofuje metodę testową po zakończeniu pracy.

Brzmi bardzo wygodnie, prawda? Pozbywamy się boilerplate’ów do zarządzania transakcjami w każdym miejscu. Nie musimy przywracać stanu bazy sprzed testu po każdym zdefiniowanym przypadku itp. Niestety w połączeniu z Hibernate, adnotacja ta może stać się również pułapką.

Jedną z podstawowych cech transakcji bazy danych jest jej zakres. Zakres transakcji decyduje o tym, które fragmenty kodów podlegają której transakcji. Zmiana zakresu transakcji może mieć zatem ogromny wpływ na zachowanie kodu. Jest to szczególnie widoczne podczas korzystania z Hibernate. Hibernate używa Transactions (i instancji Transactional Entity Manager) dla mechanizmu lazy loading. Spójrzmy na poniższy przykład:

Encja
@Entity(name = "user")
public class UserEntity {

    @Id
    @GeneratedValue
    private UUID id;
 
    private String name;
     
    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    List<UserEntity> accounts;
}

Kiedy pobierana jest instancja UserEntity, pole z powiązanymi kontami (accounts) nie zostanie zainicjowane. Będzie to instancja PersistentSet, czyli implementacji biblioteki Hibernate, która przy pierwszym wywołaniu którejkolwiek z metod zbioru pobierze listę kont użytkownika z bazy danych. Gdzie zatem jest haczyk?

Lazy loading (leniwe ładowanie) w Hibernate działa poprawnie tylko wtedy, gdy jesteśmy w zasięgu aktywnej transakcji bazy danych. Gdy tylko spróbujemy leniwie załadować cokolwiek po zakończeniu oryginalnej transakcji, zostanie zaprezentowany wyjątek LazyInitializationException. Zmieniając zakres transakcji możemy zatem wprowadzić do naszej logiki RuntimeException. Rzućmy okiem na kolejny przykład:

Prawidłowy zakres transakcji

// Start transakcji
transactionTemplate.executeWithoutResult(transactionStatus -> {
    // User jest pobierany z bazy danych po nazwie
    User u = userService.getUserByName(name);
    // właściwości lazy-loaded są zaciągane w poprawny sposób
    u.getAccounts().forEach(this::doSomethingWithAccount);
    // Koniec transakcji
});

Nieprawidłowy zakres transakcji

// Start transakcji
User u = transactionTemplate.execute(transactionStatus -> {        
    // User jest pobierany z bazy danych po nazwie
    return userService.getUserByName(name);
    // Koniec transakcji
});
// Próba ładowania właściwości lazy-loaded z opóźnieniem, 
// w wyniku czego wyjątek LazyInitializationException
u.getAccounts().forEach(this::doSomethingWithAccount);

Niepoprawność w powyższym przykładzie widać dość klarownie. Gdy używamy TransactionTemplate dostarczone przez Springa, czyli ręcznie zarządzamy transakcją. Mniej oczywiste jest to w przypadku używania adnotacji @Transactional:

Test integracyjny oznaczony @Transactional

@Test
@Transactional
public void shouldAddUser() throws Exception {
// given:
// Tworzymy nowego użytkownika
createNewUser(getNewUser());

        // when
        // Próbujemy pobrać z bazy użytkownika po nazwie (wraz z wszystkimi właściwościami lazy-loaded)
        MvcResult createdUserResponse = getUserByName(name);
 
        // then
        // W przeciwieństwie do zachowania produkcyjnego nie ma żadnego wyjątku i jesteśmy w stanie odczytać właściwości lazy-loaded
        assertEquals(200, createdUserResponse.getResponse().getStatus());
        UserDto createdUser = getUserFromResponse(createdUserResponse);
        assertEquals(name, createdUser.getName());
        assertEquals(2, createdUser.getAccounts().size());
    }

Test oznaczony adnotacją @Transactional umożliwia użycie “magii” Springa. Przeanalizujemy poniższy przykład:

  1. Tworzymy nową instancję użytkownika w transakcji:
    @Transactional
    @ResponseStatus(HttpStatus.CREATED)
    @PostMapping
    public void createUser(@RequestBody UserDto user) {
     userService.createUser(user);
    }
    
  2. Znajdujemy instancję użytkownika według jego nazwy i przekształcamy w DTO, używając jej leniwie ładowanej właściwości “accounts”:
    @GetMapping("/{name}")
     public UserDto getUserByName(@PathVariable("name") String name) {
     User user = userService.getUserByName(name).orElseThrow(() -> new RuntimeException("User not Found"));
     return new UserDto(user.getName(), user.getAccounts().stream().map(Account::getAccounts).collect(Collectors.toList()));
    }
    

Jak zadziałał test integracyjny?

Wszystko zadziałało poprawnie, utworzony użytkownik został zwrócony przez wywołanie getUserByName(). Nie rzucono żadnego wyjątku. Jesteśmy pewni, że nasz kod działa poprawnie.

Co stanie się na produkcji?

Jak widzimy, logika testu zawiera 2 oddzielne wywołania REST. W takim przypadku transakcja użyta do utworzenia użytkownika zostałaby zakończona przed zwróceniem odpowiedzi HTTP przez Controller. Pobranie użytkownika po jego nazwie zostałoby wykonane poza pierwotną transakcją. Konwersja encji UserEntity w UserDto dałaby wyjątek LazyInitializationException, ponieważ próbowaliśmy leniwie załadować pole adresów użytkownika bez transakcji.

Przyczyna

Kiedy korzystamy z adnotacji @Transactional w testach integracyjnych, Hibernate cache’uje wszystkie encje ze wszystkich transakcji, które wykonywane są w ramach przypadku testowego. Ponieważ na początku wykonywania testu, kiedy wykonana została metoda createNewUser(), użytkownik był “znany” Hibernate, wraz ze swoimi powiązanymi kontami, to Hibernate zapisał je w pamięci podręcznej. Kiedy zatem wywołana została metoda getUserByName(), to kolekcja została pobrana bez żadnego problemu z tejże pamięci. Spring re-używa tej samej sesji Hibernate do każdej transakcji w testach integracyjnych. Jest to logiczne, ponieważ Spring będzie chciał wykonać Rollback po zakończeniu każdego przypadku testowego.

Alternatywy

Alternatyw dla adnotacji @Transactional w testach integracyjnych jest kilka:

  • Wykorzystanie adnotacji @DirtiesContext(classMode = ClassMode.BEFORE_EACH_TEST_METHOD) - to rozwiązanie pozwala nam przed każdym testem na nowo tworzyć kontekst Spring'a. Ma bardzo duży wpływ na wydajność aplikacji i raczej nie powinno być stosowane.
  • Wykorzystanie z adnotacji @SQL i skryptu do czyszczenia bazy - to rozwiązanie pozwala na zdefiniowanie dedykowanego skryptu, który wyczyści pożądaną tabelę lub kilka tabel przed / po każdym przypadku testowym. Minusem tego rozwiązania jest fakt, że trzeba pilnować, by istniał skrypt, który czyści każdą “zabrudzoną” tabelę. Adnotację SQL można dodać na poziomie klasy, lub pojedynczego przypadku testowego: docs.spring.io - Script Execution Phases
  • Dedykowany serwis czyszczący wszystkie tabele w bazie - wydaje się to być najbezpieczniejszym i najmniej obciążającym rozwiązaniem. Polega na tym, że przed lub po każdym przypadku testowym czyścimy bazę danych. Kod wtedy jest mniej zależny od zakresu transakcji.
    class SomeIntegrationTest {
    
      @Autowired
      private DatabaseCleanup databaseCleanup;
     
      // ...
      
      @AfterEach
      void afterEach() {
          databaseCleanup.execute();
      }
     
      //...
    }
    
    @Service
    @ActiveProfiles("test")
    public class DatabaseCleanup implements InitializingBean {
    
      @PersistenceContext
      private EntityManager entityManager;
     
      private List<String> tableNames;
     
      @Override
      public void afterPropertiesSet() {
          tableNames = entityManager.getMetamodel().getEntities().stream()
                  .filter(e -> e.getJavaType().getAnnotation(Entity.class) != null)
                  .map(e -> CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, e.getName()))
                  .collect(Collectors.toList());
      }
     
      @Transactional
      public void execute() {
          entityManager.flush();
          entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();
     
          for (String tableName : tableNames) {
              entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
          }
     
          entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();
      }
    }
    

Przydatne linki:

dev.to - Don’t Use @Transactional in Tests

docs.spring.io - Script Execution Phases

miensol.pl - How to clear database in Spring Boot tests?

Najnowsze wpisy

  • Czy wiesz dlaczego nie powinno się stosować adnotacji @Transactional w testach integracyjnych z Hibernate?
  • Czy wiesz, do czego służy untracked w Angular?
  • Czy wiesz, że w Jest można automatycznie testować dostępność (a11y)?
Dołącz do nas

  • SENIOR FULLSTACK DEVELOPER (JAVA + ANGULAR) Poznań (hybrydowo) lub zdalnie UoP 14 900 - 20 590 PLN brutto
    B2B 19 680 - 27 220 PLN netto
  • REGULAR FULLSTACK DEVELOPER (JAVA + ANGULAR) Poznań (hybrydowo) lub zdalnie UoP 11 300 - 15 900 PLN brutto
    B2B 14 950 - 21 000 PLN netto
  • ZOBACZ WSZYSTKIE OGŁOSZENIA

newsletter

techniczny

Zapisz się

Podobne wpisy

post-image
transactions

Czy wiesz dlaczego nie powinno się stosować adnotacji @Transactional w testach integracyjnych z Hibernate?

author
Robert Mastalerek 1 wrz 2025
post-image
angular

Czy wiesz, do czego służy untracked w Angular?

author
Dorian Mejer 31 lip 2025
post-image
wcag

Czy wiesz, że w Jest można automatycznie testować dostępność (a11y)?

author
Wojciech Stolarski 19 maj 2025
Dołącz do nas

  • SENIOR FULLSTACK DEVELOPER (JAVA + ANGULAR) Poznań (hybrydowo) lub zdalnie UoP 14 900 - 20 590 PLN brutto
    B2B 19 680 - 27 220 PLN netto
  • REGULAR FULLSTACK DEVELOPER (JAVA + ANGULAR) Poznań (hybrydowo) lub zdalnie UoP 11 300 - 15 900 PLN brutto
    B2B 14 950 - 21 000 PLN netto
  • ZOBACZ WSZYSTKIE OGŁOSZENIA

Zapisz się na

newsletter

techniczny

consdata.com
  • Kontakt

    • sales@consdata.com
    • +48 61 41 51 000

  • Biuro

    • K9Office
      Krysiewicza 9/14
      61-825 Poznań
      Polska

  • Rozwiązania

    • Eximee
    • Kouncil
  • Blog Dołącz do nas
Copyright © 2024 Consdata. All rights reserved. Privacy Policy & Cookies
Chcemy używać plików cookie oraz skryptów podmiotów trzecich do polepszania funkcjonowania tej strony Zgadzam się