1. merge(), bulk, and concurrency: risk zones
If there are three places in ORM where developers most often get burned, they are the places where reality differs from intuition. merge() looks like “save the object”, bulk operations look like “quickly update many rows”, and concurrency feels like “something exotic that only big companies deal with.” In reality, all three problems show up in ordinary backoffice code even under fairly small load — they are just hard to see without the right microscope, meaning an integration test.
It is useful to keep a very practical matrix in mind: what the symptom is, why it appears, and what signal in the test captures it. In ORM regression tests, we almost never check “abstract quality”. We capture a concrete behavioral promise: “merge() returns a managed copy”, “bulk does not synchronize the persistence context”, “optimistic locking produces a conflict, not a silent data loss”.
| Scenario | Typical symptom in production | “Why” in Hibernate | What we capture in the regression test |
|---|---|---|---|
| merge() | unexpected SELECTs, duplicate objects, strange graph behavior | merge() copies state into another managed instance | assertNotSame, entityManager.contains, reread after flush/clear |
| Bulk update/delete | “we renamed the products, but in this same service the old names are still there” | bulk works outside the persistence context | before clear() the object is “old”, after clear() reread is “new” |
| Concurrency (locking) | lost update, negative balances, chaos in reservedQty | two threads, two transactions, an update race | deterministic conflict/timeout + invariant check |
This table is not about “beautiful tests”. It is about opening a CI failure a month later and immediately understanding: “aha, that same ORM risk we already caught once is back.”
2. Regression test for merge(): detached and managed copy
When people first hear the word merge(), they mentally translate it as “well, this is kind of save, just smarter.” And then Hibernate does what it does best: it smiles politely and quietly breaks expectations. The real contract of merge() is that it does not revive the same object; instead, it creates or finds a managed instance and copies state into it from the detached object. That is why a test for merge() starts not with merge(), but with creating a detached entity.
From here on, I will use short test-method fragments: I will not repeat the fixture and setup each time, only the part where the detached/managed roles and the result after flush/clear matter.
Detached entity in @DataJpaTest
In @DataJpaTest, each test usually runs inside a transaction. That is convenient, but there is a nuance: everything you load through the repository becomes managed and stays that way until you detach it. So if you want a “real detached flow” in one test, you have two direct paths: entityManager.detach(entity) for a single object or entityManager.clear() for the entire persistence context. Here detach() is usually more precise — we do not want to accidentally detach everything we have already loaded.
Below is a minimal test skeleton. Notice that we are not trying to “test merge as magic”; we are testing the semantics of roles: source (detached) and result (managed).
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.junit.jupiter.api.Assertions.*;
@Test
void detach_makesEntityDetached(@Autowired EntityManager entityManager) {
// Assume the fixture has already created an order with id=1
PurchaseOrder order = entityManager.find(PurchaseOrder.class, 1L);
// Explicitly move the entity from managed state to detached
entityManager.detach(order);
// contains(...) = true means "the entity is under persistence context management"
assertFalse(entityManager.contains(order)); // order is already detached
}
You can think of this code fragment as a “reality check” before the main test. If contains() returned true, then you are still in the managed world, and the rest of the test will lie.
Test: merge() — managed copy
Now let us build a full regression test for PurchaseOrder. We will use the domain entity from Commerce Persistence Lab, because merge() on orders is a classic production trigger (an order is often edited through detached DTO flows).
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.junit.jupiter.api.Assertions.*;
@Test
void merge_returnsManagedCopy(@Autowired EntityManager entityManager) {
// Step 1: take a managed entity and make it detached
PurchaseOrder detached = entityManager.find(PurchaseOrder.class, 1L);
entityManager.detach(detached);
// Change the state of the detached object itself (outside the persistence context)
detached.setStatus(OrderStatus.CANCELLED);
// merge(...) returns a managed copy (not the same Java object)
PurchaseOrder managed = entityManager.merge(detached);
// Key merge() semantics: this is a DIFFERENT instance
assertNotSame(detached, managed);
// And this instance is now under EntityManager management
assertTrue(entityManager.contains(managed));
}
Notice the nature of the asserts. We are not checking “whether it was saved to the database” yet. We are capturing something more basic and more important: merge() returned a different instance, and that instance is under persistence context management. This is the semantics people most often forget — and then they write code that keeps working with the detached object, thinking it has been “revived”.
Test: detached after merge() is not tracked
The next trap is very human. A developer does managed = merge(detached), but then keeps changing detached, because “well, it is the same order...”. Hibernate, of course, is not required to track that: a detached object is out of the game. A good regression test should capture this, otherwise after a couple of refactorings you will get “ghost” bugs where changes are sometimes saved and sometimes not.
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.junit.jupiter.api.Assertions.*;
@Test
void changesInDetachedAfterMerge_areNotTracked(@Autowired EntityManager em) {
// Load the entity and immediately detach it to get a detached instance
PurchaseOrder detached = em.find(PurchaseOrder.class, 1L);
em.detach(detached);
// merge(...) will return a managed copy
em.merge(detached);
// Important: now we change the detached one, that is, "NOT the same object"
detached.setStatus(OrderStatus.SHIPPED); // changing NOT the same object
// flush() sends SQL to the DB (if there are changes in managed objects)
em.flush();
// clear() clears the persistence context so the next read happens "from scratch"
em.clear();
// Reread from the DB and make sure the detached change was NOT saved
assertNotEquals(OrderStatus.SHIPPED, em.find(PurchaseOrder.class, 1L).getStatus());
}
This test deliberately includes flush() and clear(). Without clear(), you risk reading the managed entity from the first-level cache and not understanding what is actually in the database. And what matters to us is precisely that “after merge, detached no longer affects anything”.
Role diagram for merge()
Sometimes it is useful to see this not as code, but as a picture — the brain stops arguing with reality.
flowchart LR
A["detached PurchaseOrder
(not in persistence context)"] -->|"merge()"| B["managed PurchaseOrder
(in persistence context)"]
B -->|dirty checking + flush| C[(PostgreSQL)]
A -. "changes here are not tracked" .-x B
If you remember only one thing from merge() tests, let it be this thought: after merge() you must work with the result.
3. Regression test for bulk update: stale persistence context
Bulk operations are like an excavator in the world of garden tools. It is very effective, but if you try to transplant a violet with it, the results will be... memorable. In Hibernate, bulk update/delete runs directly against the database and bypasses the persistence context, snapshots, and dirty checking. That means the database has already changed, while your managed objects still live in the “old reality”. And the worst part is that the code may keep working “as if everything is normal” until it makes a business decision based on an outdated value.
Repository for a bulk operation
Let us say we have a bulk operation “rename all products to Archived” (an artificial example, but it shows the stale state mechanics well). In a project, this usually lives near the catalog query code, not in the main ProductRepository.
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;
interface ProductBulkRepository extends Repository<Product, Long> {
// @Modifying tells Spring Data that this is not a SELECT, but a modifying query
@Modifying
// Important: this is a bulk update, it runs directly in the DB
@Query("update Product p set p.name = :newName")
int bulkRenameAllTo(@Param("newName") String newName);
}
What matters here is not that the query is beautiful. What matters is that this is a bulk update, meaning it does not call p.setName(...) on managed entities. It changes rows in the table directly.
Test: bulk updates the DB, but not the object
Now we will write a test that first loads Product as managed (meaning it is in the first-level cache), then performs a bulk update, and then shows two realities: “old in memory, new in the database”. That is the stale persistence context that later causes strange bugs.
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.junit.jupiter.api.Assertions.*;
@Test
void bulkUpdate_leavesManagedEntityStale(@Autowired EntityManager em,
@Autowired ProductBulkRepository bulkRepository) {
// Load the managed entity: Product#1 now lives in the persistence context
Product p = em.find(Product.class, 1L);
String oldName = p.getName();
// The bulk operation changes rows in the DB, but does NOT update already loaded managed objects
bulkRepository.bulkRenameAllTo("Archived");
// p is still "old" because it is the same Java object from the first-level cache
assertEquals(oldName, p.getName()); // managed object is "not aware"
}
At this point many people are genuinely surprised for the first time: “but we updated it!”. Yes, we did. But not this object. Hibernate is not telepathic, and a bulk operation is not obliged to synchronize the persistence context.
Test: clear() brings us back to reality
Now let us add the final part — reread after clear(). This is no longer “test magic”; it is the protocol after bulk: if you keep working in the same persistence context after bulk, you must either clear the context, or reread the needed entities, or move the bulk into a separate transaction. Which one exactly is a service-level decision, but the test must capture the requirement.
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.junit.jupiter.api.Assertions.*;
@Test
void bulkUpdate_requiresClearBeforeReload(@Autowired EntityManager em,
@Autowired ProductBulkRepository bulkRepository) {
// Warm up the managed instance: Product#1 definitely exists in the persistence context now
em.find(Product.class, 1L);
// Change it directly in the DB
bulkRepository.bulkRenameAllTo("Archived");
// Clear the persistence context so the next read does NOT return the old managed object
em.clear();
// After clear() EntityManager rereads from the DB and sees the current value
assertEquals("Archived", em.find(Product.class, 1L).getName());
}
This test also serves as documentation: if someone tomorrow asks “why is that clear() after the bulk operation there?”, CI will kindly explain why.
Without clear(), findById() returns stale data
Here is an important point that is easy to miss. You might think: “okay, object p is old, but I will just call a new findById() and get the current value!”. Not necessarily. Inside the persistence context, identity map is at work: one row means one managed object. If Product#1 is already loaded, then findById(1) may return the same Java object without going to the DB. And that is correct: that is how the first-level cache works. You simply created a situation where the cache and the database diverged after the bulk operation.
4. Deterministic concurrency tests without sleep()
Here we are dealing with a different class of tasks. For merge() and bulk, one persistence context was enough, but a locking conflict appears only when two independent transactions actually meet. That is why below you will see InventoryService and a helper for coordinating a parallel start: we need to reproduce two competing units of work, not one JPA slice.
Concurrency tests scare even experienced developers because “parallelism” sounds like “chaos” in your head. And chaos is bad test material: flaky tests are useless. The good news is that a concurrency test can be made deterministic if you stop hoping for random timing and start coordinating the start of the operations. In our project, the ideal target for such a test is InventoryItem, because stock and reservation naturally create conflicts.
What exactly we want to verify with optimistic locking
Let us recall the earlier days of the course: optimistic locking via @Version should protect against lost updates. At the behavior level, this means that two parallel transactions may read the same row version, but when both try to update it, one of them must lose and receive a version conflict. That is a normal outcome, not a “system error”. A regression test should guarantee that the conflict really happens and does not turn into a silent overwrite of data.
Thread.sleep(...) as a source of a flaky test
A very popular “plan” looks like this: launch two threads, and in one of them call sleep(200) so the other one “definitely has time to run”. The problem is that “definitely” depends on the load of your laptop, database speed, CI settings, the Java scheduler, and the moon phase. Such tests reliably break exactly when stability matters most to you, for example before a release.
The right way is to use a synchronization primitive that gives you a controlled “start at the same time” point. The simplest candidate is CountDownLatch.
Mini template: simultaneous start via CountDownLatch
Below is a compact example of a “start barrier”. It is not tied to Hibernate directly; it is just parallel-launch discipline. In a real project you would usually wrap this in a helper (in our case it logically lives in labsupport).
import java.util.concurrent.CountDownLatch;
CountDownLatch ready = new CountDownLatch(2); // how many tasks must say "I am ready"
CountDownLatch start = new CountDownLatch(1); // common start "gun"
// in each task:
ready.countDown(); // "I am ready" (signal to the test that the thread reached the barrier)
start.await(); // "waiting for the common start" (so we start synchronously)
The idea is simple: both tasks say “ready”, the test waits until both are ready, then gives a common start with a single countDown().
Regression test: one reservation fails with an optimistic conflict
Now for something that looks like a real test. I will show it in the style of “there is ConcurrencySupport”, because in Commerce Persistence Lab we keep such utilities in labsupport specifically to avoid scattering multithreaded boilerplate across every test.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.junit.jupiter.api.Assertions.*;
@Test
void reserve_twice_simultaneously_producesOptimisticConflict(@Autowired ConcurrencySupport concurrencySupport,
@Autowired InventoryService inventoryService) {
// Run two operations at the same time (the same InventoryItem)
var result = concurrencySupport.runSimultaneously(
() -> inventoryService.reserve(1L, 3),
() -> inventoryService.reserve(1L, 4)
);
// Contract: out of two competing updates, one must lose by version
assertEquals(1, result.failures().size());
}
This test captures exactly one contract: when reserving the same InventoryItem at the same time, one operation must lose. The important thing is that we do not assert which one loses — that is irrelevant to us. What matters is that the conflict does not turn into silent data loss.
Strengthening the test: check the error type and the stock invariant
A slightly more mature version is not just “one error”, but “an error of the expected class” plus “after the conflict the data is not absurd”. In the Spring world, an optimistic conflict often surfaces as ObjectOptimisticLockingFailureException (a wrapper), but the exact class depends on the level at which you catch the exception. We will check it in the simplest possible way: at least one exception exists, and it is about optimistic locking.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.junit.jupiter.api.Assertions.*;
@Test
void conflict_isOptimisticLocking(@Autowired ConcurrencySupport concurrencySupport,
@Autowired InventoryService inventoryService) {
// Run the concurrent scenario again: two reservations at the same time
var result = concurrencySupport.runSimultaneously(
() -> inventoryService.reserve(1L, 3),
() -> inventoryService.reserve(1L, 4)
);
// Important: exceptions from threads must be collected and available in the result
Throwable failure = result.failures().getFirst();
// Minimal check for a training project: the error is indeed about optimistic locking
assertTrue(failure.getClass().getSimpleName().contains("Optimistic"));
}
Yes, this “check by name” looks a bit student-like. And that is fine for an educational project in the first steps. In your project, of course, you will pin down the exact exception type you expect at the service boundary. But even such a test is better than “it just fails sometimes”.
If you want to add an invariant check, it usually looks like this: after the concurrent scenario finishes, reread InventoryItem and make sure availableQty >= 0 and reservedQty >= 0, and that the sum did not become impossible. That makes the test a little longer, but much more useful: you start checking not only that “there was a conflict”, but also that “the data stayed healthy”.
Asserts: semantics, “DB reality”, and outcome
In these three scenarios, it is easy to write a test that looks smart but actually guarantees nothing. For a test to really be a regression test, it must check what can break during a refactor, and it must check it with an observable signal. For merge(), the signal is often identity/managed status (via assertNotSame and contains) plus reread after flush/clear. For bulk, the signal is “before clear() stale, after clear() current”. For concurrency, the signal is a deterministic outcome: conflict, timeout, or invariant preservation.
A very useful habit: if in a test you are checking “something in the DB”, always ask yourself: “am I really reading from the DB, or from the persistence context?”. Usually the answer is achieved with three classic steps: flush() (to send SQL), clear() (to detach managed objects), and only then reread through the repository. Yes, it looks like “extra movements”. But in essence, this is your test microscope: you turn off first-level cache illusions and look at the real PostgreSQL.
And one more thing that saves nerves: in concurrency tests, do not try to put assertThrows(...) inside threads and assume the test will see everything. An exception thrown in another thread does not automatically crash the main thread. You need either to collect errors into a result structure (as ConcurrencySupport does), or to use Future.get() and catch ExecutionException. Once you accept this fact, multithreaded tests stop feeling like mysticism and become an ordinary engineering task.
5. Typical mistakes
Mistake #1: a test for merge() is written without a real detached state.
The most common trap is to call entityManager.merge(order), where order was just loaded in this same test and you forgot detach()/clear(). As a result, order remains managed, merge() does almost nothing, the test passes, and it creates a false sense of security. A correct test first calls detach() or clear(), and only then checks the merge() contract on a detached object.
Mistake #2: after merge(), developers keep changing the detached object and expect Hibernate to save it.
Sometimes this happens unintentionally: you did PurchaseOrder managed = merge(detached), and then in the code (or in the test) you accidentally kept using the detached variable because “it was the first one”. Hibernate is not required to track a detached instance, and that is exactly what the test captures: detached changes after merge() must not “leak” into the database. If such a test is missing, the bug appears late and is very unpleasant.
Mistake #3: a bulk test checks the result through an already loaded entity and thinks that this is a “DB check”.
A bulk operation changes rows, but not managed objects. If after bulk you do assertEquals("Archived", product.getName()), the test will naturally fail — and that is actually good: it shows the stale state mechanics. The bad case is when you do not call clear() at all and do not reread, but check something indirect, so the test is “accidentally green” and captures nothing. In bulk regression, you almost always need the clear() step and a reread.
Mistake #4: after bulk, you call findById() and wonder why old data came back.
This is not “Hibernate did not go to the database”; it is the first-level cache working like an identity map. If the entity is already in the persistence context, Hibernate returns the same object and considers that correct. That is why the regression must demonstrate two phases: “before clear() everything is stale”, “after clear() reread sees reality”. And based on that test, you establish the service rule: a bulk operation must be accompanied by context clearing or a separate transaction.
Mistake #5: a concurrency test is built on sleep() and becomes flaky.
If a test sometimes passes, sometimes fails, and sometimes “does not catch the conflict”, then you did not write a regression test — you wrote a lottery. A proper concurrency test must have a coordinated start (latch/barrier) and, ideally, a coordinated point where “both have read before anyone commits”. Yes, this requires a helper like ConcurrencySupport, but this is exactly the case where a utility is not abstraction for abstraction’s sake, but a way to make tests reproducible.
GO TO FULL VERSION