1. What is an ORM bug
When we hear the word “bug,” the brain usually draws a simple picture: “I assigned status = CANCELLED, but for some reason the object has status = NEW.” This is a classic, almost cosy Java error. ORM bugs behave differently: they often show up not as a “wrong value,” but as unexpected SQL, a sudden UPDATE, a flood of queries, or an exception that appears only during real interaction with the database and transaction.
In the Hibernate deep dive, we kept repeating the formula: “code + persistence context + transaction + database = real behavior.” That is why ORM bugs are more often like boundary problems than internal object logic. For example, you can change fields in a managed entity perfectly, but still get “it somehow was not saved” because the real flush happened somewhere else than you expected. Or the opposite — you “did not save anything,” but an UPDATE arrived because dirty checking decided the state had changed.
It helps to keep a simple distinction in mind, without religion or drama: a regular Java bug is usually caught by checking an object’s state in memory, while an ORM bug requires observing what happened around the object — in the persistence context, in SQL, and in the database. That is exactly what a unit test, by definition, tries not to touch (and that is good), while an integration test, on the contrary, is supposed to include it (and that is why we love it, even though it is slower).
To feel the difference, imagine a parachute. A unit test is when you check that the fabric is strong, the lines do not tear, and the carabiner clicks shut. All of that matters. But the question “will it open during the jump?” still remains. The jump is the integration test. ORM is the same kind of “jump”: until you go to the database and live through the transaction, you have not checked the most interesting part.
2. Unit tests and Hibernate reality
A unit test is a great thing, especially for logic that can be separated from infrastructure: calculations, DTO mapping, validators, small domain rules. But Hibernate is a runtime system that behaves differently depending on the context: whether the persistence context is open, whether there is a transaction, what the flush mode is, which database is used, what schema constraints exist, what indexes exist, what execution plan is chosen, and what id strategy is used. Mocks simply do not have this “reality.”
The most typical beginner trap looks like this: “I will mock the repository, and the test will verify everything.” Technically, you will verify that the service calls repository.save(...). But that is not ORM behavior verification — that is verification that you know how to call a method. Hibernate does not even wake up in that moment, SQL is not generated, there is no transaction, dirty checking does not compare snapshots, and flush does not run. You checked the “phone call,” not the “conversation.”
Here is a small example of what “mock reality” looks like. The code is not bad as a unit test; it simply solves a different problem.
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class MockedRepositoryExampleTest {
@Test
void mockedRepositoryReturnsPlainObject() {
// Important: this is a mock, Hibernate is not involved here
ProductRepository repo = mock(ProductRepository.class);
// Return a plain object, not a Hibernate proxy
when(repo.getReferenceById(1L)).thenReturn(new Product()); // not a proxy, just new
Product product = repo.getReferenceById(1L);
// Important: here you did not verify the Hibernate proxy, lazy init, or the link to the persistence context.
// This test checks only mock behavior, not ORM behavior.
}
}
In a real application, getReferenceById() (or EntityManager.getReference()) is a story about a proxy, about binding to the persistence context, and about “field access suddenly turning into SQL.” In mocks, this meaning is not reproduced at all. You can write a thousand when(...) thenReturn(...) calls — you still will not get a proxy from that, no matter how hard you try.
That is why ORM bugs are very rarely caught by unit tests. They are too “flat”: they have no database, no transaction, no SQL, and therefore no environment in which our real problems live: N+1, lazy loading, unexpected flush, merge() with state copying, optimistic lock conflicts, stale persistence context after bulk.
The proper engineering takeaway here is not “unit tests are bad,” but “we must choose the right tool.” Just as it is awkward to cut bread with a hammer (though it is handy for driving nails), it is awkward to catch ORM bugs with mocks (though it is handy for checking that a service does not make extra calls and branches correctly).
3. Two tests — two worlds
This topic is easiest to feel by contrast: one test is “about a Java object,” the other is “about a real JPA/Hibernate context.” Both tests can be honest and useful; they just answer different questions. The first answers: “Does my code assign a value at all?” The second: “Does my code work in the world of Hibernate, proxies, transactions, and SQL?”
Let us start with that cosy unit test. It checks only the behavior of a simple object. There is nothing “bad” about it — it just is not about ORM.
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class PurchaseOrderPojoTest {
@Test
void changesStatusInMemory() {
// Here we work only with a POJO in memory — no database and no Hibernate
PurchaseOrder order = new PurchaseOrder();
// Check the usual assignment logic
order.setStatus(OrderStatus.CANCELLED);
assertEquals(OrderStatus.CANCELLED, order.getStatus());
}
}
This test will pass even if PurchaseOrder is not even an @Entity, if the purchase_order table does not exist, if the status is stored incorrectly in the database, if there is an error in the mapping, if you forgot a column, or if the enum does not match by value. It checks exactly what it checks: that a field changes in memory.
And now — a test “from another world.” Here the point is not to “just get an object,” but that getReferenceById() returns a reference, and the real read happens according to Hibernate rules inside the context.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import static org.junit.jupiter.api.Assertions.assertEquals;
@DataJpaTest
class ProductReferenceTest {
// The repository is started in a real Spring/JPA context
@Autowired ProductRepository productRepository;
@Test
void getReferenceById_resolvesSkuInsideJpaContext() {
// Important: here a proxy is usually returned, not a fully loaded entity
Product product = productRepository.getReferenceById(1L);
// Important: field access can trigger SQL, so you need a JPA context and a transaction
assertEquals("SKU-1", product.getSku()); // accessing data requires ORM context
}
}
This test can no longer be executed “honestly” without a JPA context. And these are the tests we need when we verify ORM behavior. Not because we love heavy tests (we are not masochists), but because Hibernate is not a library “about fields,” it is a system “about interaction with the database.”
If we summarize the contrast briefly: the first test proves that you can assign a value, the second test proves that you can live in the reality of Hibernate. And in the deep dive course, we care specifically about the second reality — because the first one usually breaks much less often (Java is quite honest in that sense).
4. Observable ORM behavior
When we say “integration ORM test,” it is important not to slip into a vague “well, it works with the database, so everything is fine.” It may be “fine” with the database, but fetching may be bad, flush may be unexpected, bulk may leave stale state, merge() may drag extra SELECTs, optimistic locking may not be caught, and soft delete may break list visibility. Therefore, a good integration check should look not only at the “result,” but also at the “tracks in the snow” — that is, at observable persistence behavior.
It is convenient to think of this as two layers of the contract. The first layer is functional: “the order was saved,” “the product was found,” “the stock decreased.” The second layer is persistence-oriented: “there was no N+1,” “after bulk the state did not remain stale,” “getReferenceById() did not explode when the field was accessed,” “after clear() the data is re-read from the database,” “the concurrent conflict manifests as the expected exception.”
Below is a table. It does not replace the details, but it helps explain why unit tests are “blind” to these risks.
| Type of ORM risk | How it appears in reality | What to observe in an integration test | Why a unit test will not catch it |
|---|---|---|---|
| Lazy issues | LazyInitializationException or unexpected queries when a field is accessed | exception when accessed outside the context, or the fact of SQL on access | there is no persistence context or proxy in mocks |
| N+1 | many SELECTs instead of one | number of queries / SQL trace | there is no SQL or statistics in a unit test |
| flush semantics | SQL goes out earlier than you thought | DB-visible state after flush + reread | without a database and a transaction, flush does not exist |
| merge() | a different managed instance is returned, extra reads are possible | difference between source/result, presence of SELECT | in a unit test, merge is “just a method,” not ORM behavior |
| Bulk side effects | data in the database changed, but managed objects stayed old | stale state before clear(), correctness after reread | there is no persistence context in a unit test, so nothing can “go stale” |
| Optimistic locking | version conflict on UPDATE | expected exception on concurrent update | without a real database and a version column, there is no conflict |
| Schema constraints | uniqueness / foreign key checks are caught by the database | exception on flush, not on setSku() | a unit test knows nothing about constraints |
Notice that almost everywhere the words “SQL,” “context,” “transaction,” and “visibility in the database” appear. That is exactly why we cannot “just mock the repository” and call it ORM testing. Hibernate does not work in “one-actor theater” mode: it needs a stage, scenery, and spectators. In our case, the stage is the database.
By the way, one habit from earlier modules helps a lot here: if you did not call clear() and then re-read the data, you are often checking not the database, but the first-level cache. So the test may go green even if the database says something else. In integration testing, this is a classic trap: the test is not lying, but it answers the wrong question.
5. Real database in integration tests
There is a very tricky category of problems: “my tests are green on an in-memory database, but on PostgreSQL — it is on fire.” And that is not because PostgreSQL is being malicious (though sometimes it feels that way). It is simply because the database is part of the specification of your persistence layer. It has its own constraints, transaction semantics, locks, indexes, sequences, type peculiarities, and execution plans.
If you test ORM behavior in a too “smooth” environment, you may accidentally get a false sense of security. This is especially true in a deep-dive course, where we constantly run into real SQL consequences. Schema constraints are not “something a DBA will add in production,” they are part of the contract. Locking is the same — it is not “well, in theory it might happen,” but a reproducible scenario.
To avoid configuration details, I will show only the idea itself: the uniqueness of sku is not a Java rule, but a database rule. You cannot honestly verify it without using the database and flush.
Two products with the same sku will happily survive setSku(). The problem will appear only when the SQL really reaches the database — on flush or commit. That is why the idea itself matters: many rules live below the Java code, and a unit test without a database simply cannot see them.
If you want a short engineering formula, it is this: a unit test says “my code is logically correct,” an integration ORM test says “my code physically works in this database under these rules.”
6. One scenario — one signal
When people first start writing integration tests, there is a temptation to make one “big test for everything”: save the order, load it, check the products, and also make sure there is no N+1, and let a flush happen somewhere in the middle. Such a test will look heroic... right up to the first failure. Because then you will sit there wondering: “what exactly broke?”
An integration test for ORM behavior is not a novel like War and Peace; it is a short legal contract for one specific guarantee. A good test answers one question and checks one observable signal. If you are fixing the semantics of getReferenceById(), check exactly that. If you are fixing the fact that reading an order card does not lead to N+1, then the test should be about the query profile, not about everything at once.
A style that can be called “Given–When–Then, but without fanaticism” helps a lot: first create/prepare minimal data, then do one action, then verify both the functional result and the persistence signal. In ORM tests, this “signal” often looks like “clear the context — reread — verify,” or “expect an exception,” or “look at the number of SQL statements.” The important thing is that this is not decoration, but part of the contract.
And one more practical thing that makes tests maintainable: the test name should be like a bug title in an issue tracker. Not testSave(), but something like getReferenceById_resolvesSkuInsideJpaContext. It sounds long, but three months later you open the test and immediately understand what it protects. ORM regressions like to come back quietly, so the test should shout through its name: “I am here, I am not just standing around.”
7. Typical mistakes in ORM testing
At the beginning, integration tests often end up either too superficial or too monstrous. Neither option brings the effect we write them for in the first place: reproducibility of ORM problems and protection from regressions. Below is a set of mistakes that show up most often (and yes, almost all of us have made them, just not all of us admit it).
Mistake #1: test getter/setter and call it a “Hibernate test.”
Such a test is useful to exactly the same extent as checking that a cup has a handle. Yes, it has a handle. But the tea does not become hot because of that. ORM bugs live not in field assignment, but in how those fields are mapped, when and why they go into SQL, what happens during flush, and what remains in the persistence context.
Mistake #2: mock repositories and think that “integration is verified.”
Mocks are good when you are checking branching logic and interaction between components. But Hibernate does not reveal itself in mocks, because there are no proxies, sessions, SQL, transactions, or the behavior of a specific database there. It is like testing swimming while standing on a yoga mat (the mat is wonderful, but where is the water?).
Mistake #3: check the result through the same managed object and forget about clear()/re-reading.
Hibernate is very caring: it can show you data from the first-level cache, and you will think “yay, saved.” But you checked memory, not the database. As soon as a real application gets another query or another transaction, it may suddenly turn out that the database does not match what you saw in the test. That is why an integration test almost always needs to be able to “switch to the database view,” not the persistence context view.
Mistake #4: try to catch fetching, flush, and locking all in one test.
In the end, the test turns into a detective story: “who killed performance?” And the killer can be anyone. It is better to have three small tests, each fixing one risk, than one giant one that is hard to understand and even harder to fix.
Mistake #5: test data is nondeterministic and “lives its own life.”
If a fixture is created with random values, if the data is shared by the whole module, if the test execution order affects the result — you get flaky behavior. And a flaky ORM test is the worst kind of test: it does not protect, it annoys. A test should be boringly predictable, like an accounting spreadsheet (yes, it sounds unromantic, but it works).
GO TO FULL VERSION