|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +title: "Czy wiesz dlaczego nie powinno się stosować adnotacji @Transactional w testach integracyjnych z Hibernate?" |
| 4 | +description: "" |
| 5 | +date: 2025-09-01T07:00:00+01:00 |
| 6 | +published: true |
| 7 | +didyouknow: true |
| 8 | +lang: pl |
| 9 | +author: rmastalerek |
| 10 | +image: /assets/img/posts/2025-09-01-transactional-w-testach-integracyjnych-hibernate/thumbnail.webp |
| 11 | +tags: |
| 12 | + - transactions |
| 13 | + - spring |
| 14 | + - java |
| 15 | + - transactional |
| 16 | +--- |
| 17 | + |
| 18 | +Testy integracyjne z użyciem `Springa` i `Hibernate` mają za zadanie możliwie wiernie odwzorować zachowanie aplikacji na środowisku produkcyjnym. |
| 19 | +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. |
| 20 | +Brzmi idealnie – nie musimy martwić się o „czystość” bazy, a każdy scenariusz startuje od świeżego punktu. |
| 21 | + |
| 22 | +## Jak Spring obsługuje adnotację @Transactional? |
| 23 | +Wykorzystane jest w tym celu AoP (`Aspect-oriented Programming`). W zależności od tego, czy używamy `Spring Aspects` czy `AspectJ`, `@Transactional` |
| 24 | +zostaje wykryty albo w `Spring Beans` wyłącznie dla metod publicznych, albo w dowolnym miejscu w kodzie. |
| 25 | +Następnie wszystkie znalezione metody opakowane zostają w proxy, które rozpoczyna transakcję przed wywołaniem rzeczywistej logiki metody |
| 26 | +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, |
| 27 | +automatycznie wycofuje metodę testową po zakończeniu pracy. |
| 28 | + |
| 29 | +Brzmi bardzo wygodnie, prawda? Pozbywamy się boilerplate'ów do zarządzania transakcjami w każdym miejscu. |
| 30 | +Nie musimy przywracać stanu bazy sprzed testu po każdym zdefiniowanym przypadku itp. |
| 31 | +Niestety w połączeniu z `Hibernate`, adnotacja ta może stać się również pułapką. |
| 32 | + |
| 33 | +Jedną z podstawowych cech transakcji bazy danych jest jej zakres. Zakres transakcji decyduje o tym, które fragmenty kodów podlegają której transakcji. |
| 34 | +Zmiana zakresu transakcji może mieć zatem ogromny wpływ na zachowanie kodu. Jest to szczególnie widoczne podczas korzystania z `Hibernate`. |
| 35 | +`Hibernate` używa `Transactions` (i instancji `Transactional Entity Manager`) dla mechanizmu lazy loading. Spójrzmy na poniższy przykład: |
| 36 | +```java |
| 37 | +Encja |
| 38 | +@Entity(name = "user") |
| 39 | +public class UserEntity { |
| 40 | + |
| 41 | + @Id |
| 42 | + @GeneratedValue |
| 43 | + private UUID id; |
| 44 | + |
| 45 | + private String name; |
| 46 | + |
| 47 | + @OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = CascadeType.ALL) |
| 48 | + List<UserEntity> accounts; |
| 49 | +} |
| 50 | +``` |
| 51 | +Kiedy pobierana jest instancja `UserEntity`, pole z powiązanymi kontami (accounts) nie zostanie zainicjowane. |
| 52 | +Będzie to instancja `PersistentSet`, czyli implementacji biblioteki `Hibernate`, |
| 53 | +która przy pierwszym wywołaniu którejkolwiek z metod zbioru pobierze listę kont użytkownika z bazy danych. Gdzie zatem jest haczyk? |
| 54 | + |
| 55 | +**`Lazy loading (leniwe ładowanie)`** w `Hibernate` działa poprawnie tylko wtedy, gdy jesteśmy w zasięgu aktywnej transakcji bazy danych. |
| 56 | +Gdy tylko spróbujemy leniwie załadować cokolwiek po zakończeniu oryginalnej transakcji, |
| 57 | +zostanie zaprezentowany wyjątek `LazyInitializationException`. Zmieniając zakres transakcji możemy zatem wprowadzić do naszej logiki `RuntimeException`. |
| 58 | +Rzućmy okiem na kolejny przykład: |
| 59 | + |
| 60 | +### Prawidłowy zakres transakcji |
| 61 | +```java |
| 62 | +// Start transakcji |
| 63 | +transactionTemplate.executeWithoutResult(transactionStatus -> { |
| 64 | + // User jest pobierany z bazy danych po nazwie |
| 65 | + User u = userService.getUserByName(name); |
| 66 | + // właściwości lazy-loaded są zaciągane w poprawny sposób |
| 67 | + u.getAccounts().forEach(this::doSomethingWithAccount); |
| 68 | + // Koniec transakcji |
| 69 | +}); |
| 70 | +``` |
| 71 | + |
| 72 | +### Nieprawidłowy zakres transakcji |
| 73 | +```java |
| 74 | +// Start transakcji |
| 75 | +User u = transactionTemplate.execute(transactionStatus -> { |
| 76 | + // User jest pobierany z bazy danych po nazwie |
| 77 | + return userService.getUserByName(name); |
| 78 | + // Koniec transakcji |
| 79 | +}); |
| 80 | +// Próba ładowania właściwości lazy-loaded z opóźnieniem, |
| 81 | +// w wyniku czego wyjątek LazyInitializationException |
| 82 | +u.getAccounts().forEach(this::doSomethingWithAccount); |
| 83 | +``` |
| 84 | +Niepoprawność w powyższym przykładzie widać dość klarownie. Gdy używamy `TransactionTemplate` dostarczone przez `Springa`, |
| 85 | +czyli ręcznie zarządzamy transakcją. |
| 86 | +Mniej oczywiste jest to w przypadku używania adnotacji `@Transactional`: |
| 87 | + |
| 88 | +## Test integracyjny oznaczony @Transactional |
| 89 | +```java |
| 90 | +@Test |
| 91 | +@Transactional |
| 92 | +public void shouldAddUser() throws Exception { |
| 93 | +// given: |
| 94 | +// Tworzymy nowego użytkownika |
| 95 | +createNewUser(getNewUser()); |
| 96 | + |
| 97 | + // when |
| 98 | + // Próbujemy pobrać z bazy użytkownika po nazwie (wraz z wszystkimi właściwościami lazy-loaded) |
| 99 | + MvcResult createdUserResponse = getUserByName(name); |
| 100 | + |
| 101 | + // then |
| 102 | + // W przeciwieństwie do zachowania produkcyjnego nie ma żadnego wyjątku i jesteśmy w stanie odczytać właściwości lazy-loaded |
| 103 | + assertEquals(200, createdUserResponse.getResponse().getStatus()); |
| 104 | + UserDto createdUser = getUserFromResponse(createdUserResponse); |
| 105 | + assertEquals(name, createdUser.getName()); |
| 106 | + assertEquals(2, createdUser.getAccounts().size()); |
| 107 | + } |
| 108 | +``` |
| 109 | + |
| 110 | +Test oznaczony adnotacją `@Transactional` umożliwia użycie "magii" `Springa`. Przeanalizujemy poniższy przykład: |
| 111 | + |
| 112 | +1. Tworzymy nową instancję użytkownika w transakcji: |
| 113 | +```java |
| 114 | + @Transactional |
| 115 | + @ResponseStatus(HttpStatus.CREATED) |
| 116 | + @PostMapping |
| 117 | + public void createUser(@RequestBody UserDto user) { |
| 118 | + userService.createUser(user); |
| 119 | + } |
| 120 | +``` |
| 121 | + |
| 122 | +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": |
| 123 | +```java |
| 124 | + @GetMapping("/{name}") |
| 125 | + public UserDto getUserByName(@PathVariable("name") String name) { |
| 126 | + User user = userService.getUserByName(name).orElseThrow(() -> new RuntimeException("User not Found")); |
| 127 | + return new UserDto(user.getName(), user.getAccounts().stream().map(Account::getAccounts).collect(Collectors.toList())); |
| 128 | + } |
| 129 | +``` |
| 130 | + |
| 131 | +### Jak zadziałał test integracyjny? |
| 132 | +Wszystko zadziałało poprawnie, utworzony użytkownik został zwrócony przez wywołanie `getUserByName()`. Nie rzucono żadnego wyjątku. |
| 133 | +Jesteśmy pewni, że nasz kod działa poprawnie. |
| 134 | + |
| 135 | +### Co stanie się na produkcji? |
| 136 | +Jak widzimy, logika testu zawiera 2 oddzielne wywołania `REST`. |
| 137 | +W takim przypadku transakcja użyta do utworzenia użytkownika zostałaby zakończona przed zwróceniem odpowiedzi `HTTP` przez `Controller`. |
| 138 | +Pobranie użytkownika po jego nazwie zostałoby wykonane poza pierwotną transakcją. |
| 139 | +Konwersja encji `UserEntity` w `UserDto` dałaby wyjątek `LazyInitializationException`, |
| 140 | +ponieważ próbowaliśmy leniwie załadować pole adresów użytkownika bez transakcji. |
| 141 | + |
| 142 | +### Przyczyna |
| 143 | +Kiedy korzystamy z adnotacji `@Transactional` w testach integracyjnych, `Hibernate` cache'uje wszystkie encje ze wszystkich transakcji, |
| 144 | +które wykonywane są w ramach przypadku testowego. Ponieważ na początku wykonywania testu, kiedy wykonana została metoda `createNewUser()`, |
| 145 | +użytkownik był "znany" `Hibernate`, wraz ze swoimi powiązanymi kontami, to `Hibernate` zapisał je w pamięci podręcznej. |
| 146 | +Kiedy zatem wywołana została metoda `getUserByName()`, to kolekcja została pobrana bez żadnego problemu z tejże pamięci. |
| 147 | +Spring re-używa tej samej sesji `Hibernate` do każdej transakcji w testach integracyjnych. |
| 148 | +Jest to logiczne, ponieważ `Spring` będzie chciał wykonać `Rollback` po zakończeniu każdego przypadku testowego. |
| 149 | + |
| 150 | +### Alternatywy |
| 151 | +Alternatyw dla adnotacji `@Transactional` w testach integracyjnych jest kilka: |
| 152 | + |
| 153 | +- Wykorzystanie adnotacji **`@DirtiesContext(classMode = ClassMode.BEFORE_EACH_TEST_METHOD)`** - to rozwiązanie pozwala nam |
| 154 | +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. |
| 155 | +- Wykorzystanie z adnotacji `@SQL` i skryptu do czyszczenia bazy - to rozwiązanie pozwala na zdefiniowanie dedykowanego skryptu, |
| 156 | +który wyczyści pożądaną tabelę lub kilka tabel przed / po każdym przypadku testowym. |
| 157 | +Minusem tego rozwiązania jest fakt, że trzeba pilnować, by istniał skrypt, który czyści każdą "zabrudzoną" tabelę. |
| 158 | +Adnotację `SQL` można dodać na poziomie klasy, lub pojedynczego przypadku testowego: |
| 159 | +[docs.spring.io - Script Execution Phases](https://docs.spring.io/spring-framework/reference/testing/testcontext-framework/executing-sql.html#testcontext-executing-sql-declaratively-script-execution-phases) |
| 160 | +- Dedykowany serwis czyszczący wszystkie tabele w bazie - wydaje się to być najbezpieczniejszym i najmniej obciążającym rozwiązaniem. |
| 161 | +Polega na tym, że przed lub po każdym przypadku testowym czyścimy bazę danych. Kod wtedy jest mniej zależny od zakresu transakcji. |
| 162 | +```java |
| 163 | +class SomeIntegrationTest { |
| 164 | + |
| 165 | + @Autowired |
| 166 | + private DatabaseCleanup databaseCleanup; |
| 167 | + |
| 168 | + // ... |
| 169 | + |
| 170 | + @AfterEach |
| 171 | + void afterEach() { |
| 172 | + databaseCleanup.execute(); |
| 173 | + } |
| 174 | + |
| 175 | + //... |
| 176 | +} |
| 177 | +``` |
| 178 | +```java |
| 179 | +@Service |
| 180 | +@ActiveProfiles("test") |
| 181 | +public class DatabaseCleanup implements InitializingBean { |
| 182 | + |
| 183 | + @PersistenceContext |
| 184 | + private EntityManager entityManager; |
| 185 | + |
| 186 | + private List<String> tableNames; |
| 187 | + |
| 188 | + @Override |
| 189 | + public void afterPropertiesSet() { |
| 190 | + tableNames = entityManager.getMetamodel().getEntities().stream() |
| 191 | + .filter(e -> e.getJavaType().getAnnotation(Entity.class) != null) |
| 192 | + .map(e -> CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, e.getName())) |
| 193 | + .collect(Collectors.toList()); |
| 194 | + } |
| 195 | + |
| 196 | + @Transactional |
| 197 | + public void execute() { |
| 198 | + entityManager.flush(); |
| 199 | + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate(); |
| 200 | + |
| 201 | + for (String tableName : tableNames) { |
| 202 | + entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate(); |
| 203 | + } |
| 204 | + |
| 205 | + entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate(); |
| 206 | + } |
| 207 | +} |
| 208 | +``` |
| 209 | + |
| 210 | +## Przydatne linki: |
| 211 | + |
| 212 | +[dev.to - Don’t Use @Transactional in Tests](https://dev.to/henrykeys/don-t-use-transactional-in-tests-40eb) |
| 213 | + |
| 214 | +[docs.spring.io - Script Execution Phases](https://docs.spring.io/spring-framework/reference/testing/testcontext-framework/executing-sql.html#testcontext-executing-sql-declaratively-script-execution-phases) |
| 215 | + |
| 216 | +[miensol.pl - How to clear database in Spring Boot tests?](https://miensol.pl/clear-database-in-spring-boot-tests/) |
| 217 | + |
0 commit comments