Skip to content

Commit 8a415a0

Browse files
authored
[2025-09-01] Czy wiesz dlaczego nie powinno się stosować adnotacji @transactional w testach integracyjnych z Hibernate? (#270)
1 parent 253ef8d commit 8a415a0

File tree

2 files changed

+217
-0
lines changed

2 files changed

+217
-0
lines changed
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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+
54.1 KB
Loading

0 commit comments

Comments
 (0)