1. @DataJpaTest as the main format
@DataJpaTest is not “some annotation for tests,” but a pretty clever compromise between speed and realism. It starts only the JPA part of the application (entity mappings, repositories, EntityManager, transactions), but does not drag along the web layer, security, integrations, and other heavy stuff. For a deep dive into Hibernate, this is almost the ideal lab setup: fast, realistic enough, and not noisy.
Imagine that @SpringBootTest is launching an entire shopping mall just to check whether the door to the storage room closes. And @DataJpaTest is launching only the storage room, but with a real door, a real key, and real signs that say “Do not enter.” For this day, we need exactly the second option.
To avoid confusion, it helps to keep a comparison of formats in mind:
| Format | What we start | What it is good for | What we pay for it |
|---|---|---|---|
| Unit test (without Spring) | plain Java code | business rules, algorithms | does not see ORM/SQL/transactions at all |
| @DataJpaTest | JPA slice: entity + repository + EntityManager + transactions | mapping, repositories, fetching, projections, soft delete, flush semantics | does not start services and “the whole world” |
| @SpringBootTest | almost the whole application | end-to-end integration scenarios | slower, more noise and dependencies |
@DataJpaTest is our default mode for repository-, mapping-, fetching-, projection-, and flush scenarios. But as soon as behavior depends on two independent transactions — for example, in locking or in a race between two service calls — a single JPA slice is no longer enough. There, not only the repository matters, but the orchestration of several units of work, and such tests usually live at the service level in a broader context.
Technically, @DataJpaTest does two key things. First, it creates a narrow ApplicationContext that contains everything needed to work with JPA. Second, it usually runs tests inside a transaction and rolls it back at the end (we will go through this in detail soon). That is exactly why this format is perfect for an ORM regression approach: you can repeatedly create/modify data and not worry that the database will be “dirty” after each test.
A small diagram to visually reinforce the idea of a test slice:
flowchart TD
A[Your test] --> B["@DataJpaTest ApplicationContext"]
B --> C[Entity mappings]
B --> D[Spring Data repositories]
B --> E[EntityManager / Hibernate Session]
B --> F[Transaction manager]
B --> G["(DataSource + DB)"]
B -. DO NOT start .-> H[Controllers / Web]
B -. DO NOT start .-> I[Security]
B -. DO NOT start .-> J[External integrations]
B -. DO NOT start .-> K[Services by default]
And this is where the typical beginner questions begin: “Why can't I autowire ProductService in @DataJpaTest?” The answer is simple: because you asked Spring to start a slice, not the entire cake. If you need a service for a specific test, that is either @SpringBootTest, or you consciously import the needed bean (but you need to do that carefully so you do not blur the slice).
2. Transactions and rollback in @DataJpaTest
The most useful (and at the same time the most “gotcha-like”) thing in @DataJpaTest is that each test method is usually executed in a transaction, and after the test finishes, that transaction is rolled back. This gives excellent isolation: tests do not depend on each other and do not leave “tracks on the carpet.” But there is a nuance: if the transaction rolls back, then a commit does not happen, which means many things in the ORM world do not “happen by themselves” until you ask for them explicitly.
Imagine rehearsing a play, but every time at the end the director claps and says: “Great, now everyone go back to the starting positions as if nothing happened.” That is convenient for repetition, but if you want to check that the door really closes with a lock, you need to pull the handle right during rehearsal, not hope that “it will close by itself at the end.”
In the JPA context, this means: to see the real behavior of the database (unique constraints, FK, trigger-like effects, soft delete SQL), you often need flush(), because without it Hibernate may keep the changes only in the persistence context.
A very common pattern for a schema constraint test looks like this:
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataIntegrityViolationException;
import static org.junit.jupiter.api.Assertions.assertThrows;
@Test
void duplicateSku_isRejectedOnFlush() {
// Save the first product with a specific SKU
productRepository.save(product("SKU-DUP", "Mouse"));
// Important: force SQL into the database so the unique index is really “occupied”
productRepository.flush();
// The second product with the same SKU must “explode” at the database level
assertThrows(DataIntegrityViolationException.class, () -> {
productRepository.save(product("SKU-DUP", "Keyboard"));
// Another flush — so the uniqueness error appears now, not “sometime later”
productRepository.flush();
});
}
This is exactly the case that cannot be honestly caught at the setSku() level: the database sees the duplicate only when SQL reaches it.
Pay attention to the “double flush.” The first one makes the first row really go into the database. The second one makes the second attempt really reach the database level and blow up on the unique index. If you do not flush, the test may “accidentally pass” because the actual uniqueness check has not happened on the database side yet.
Now another nuance: rollback is not a magic “clean everything” button, but just a rollback of a specific transaction. If in a test you somehow enable @Commit or disable rollback, you will suddenly get a “dirty” database and a very interesting attraction called “why does the test fail only after the third run.”
3. A realistic database: PostgreSQL instead of in-memory
In ordinary learning projects, people often take H2 “because it is easier.” And yes, it is easier. So much easier that you risk learning not Hibernate and PostgreSQL, but “Hibernate and the magic world of a database that forgives almost everything.” For a deep-dive course, that is a bad deal: we want to see behavior as close to reality as possible, otherwise we will be fixing the wrong bugs with tests... and the wrong solutions.
In-memory databases have typical “reality substitutions” that are especially critical for ORM:
| Area | What H2 often “smooths over” | Why this is dangerous in a Hibernate deep dive |
|---|---|---|
| SQL dialect | some queries “happen to work” | in production PostgreSQL may behave differently |
| Data types | date/time, precision/scale, json-like things | we get false positives or false failures |
| Constraints | sometimes constraints/indexes work “not quite right” | the test does not catch the real schema problem |
| Locking | locks and timeouts do not look like PostgreSQL | locking scenarios become decorative |
| Execution plans | EXPLAIN and indexes are not the same | you cannot think about performance properly |
Even if you are thinking right now, “I just want to check the repository,” remember: our course is not about “the repository works,” but about predictable runtime behavior of Hibernate. We want tests to be useful like a microscope: showing where Hibernate does unnecessary work, where it tricks us with the persistence context, where schema constraints surface, and where behavior depends on the real database.
Therefore, the rule of the day is very simple: if we write ORM regression tests, the database must be “close to production.” For our course, that is PostgreSQL.
4. @DataJpaTest + PostgreSQL without DB replacement
By default, @DataJpaTest tries to be “convenient” and may replace your DataSource with an embedded database if one is available. That is useful for most projects, but for us it is harmful magic. So we usually do two things: explicitly forbid DataSource replacement and explicitly enable a test profile with PostgreSQL settings.
At the level of test annotations, this often looks like this:
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.test.context.ActiveProfiles;
@ActiveProfiles("test") // Enable the test profile with datasource/flyway/jpa settings
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // Forbid auto-replacement with an embedded DB
@DataJpaTest
class ProductRepositoryTest { }
Here it is important to read this as a human agreement with Spring: “Dear Spring, please do not try to ‘help’ me by swapping the database to H2. I am an adult. I want PostgreSQL. Yes, I am sure.”
Next, we need application-test.yml (or .properties) in src/test/resources, so that tests know where to connect. A minimal sketch:
spring:
datasource:
# In tests, connect to the real PostgreSQL, not to an in-memory database
url: jdbc:postgresql://localhost:5432/commerce_test
username: commerce
password: commerce
In the training environment, this PostgreSQL is usually started via Docker Compose (just like the whole application). And here a good practice is to have a separate database for tests, for example commerce_test, so you do not accidentally wipe your development data and then wonder “why did the tests delete everything I was doing by hand yesterday.”
If you run tests and see that they still try to start H2, the cause is almost always trivial: an H2 dependency slipped into the classpath somewhere (sometimes the IDE template or an old Gradle dependency “helped”). In a deep-dive project, it is better to keep the classpath strict: PostgreSQL means PostgreSQL.
5. Flyway in tests: schema just like in the application
If you want to test the data layer “for real,” then you need to test not only Java annotations, but also the schema that the application actually uses. In our project, the schema is managed only by Flyway, and that rule should continue in tests. Not “Hibernate will create tables by itself,” not ddl-auto=create-drop, but migrations.
Why is this important? Because ORM bugs often have schema built in: unique constraints, FK, indexes, nullable/not-nullable fields, column lengths — all of that is part of the contract. If the tests start a “dream schema” while production lives on the real one, you are testing the wrong thing.
A minimal test profile setup that helps keep discipline usually includes ddl-auto=validate. It does not create tables, but checks: “Does the schema even match what you mapped?”:
spring:
jpa:
hibernate:
# Important: do not create the schema “from thin air,” validate mapping against the migration schema
ddl-auto: validate
flyway:
# Important: migrations must also be applied in tests, otherwise we are not testing the real schema
enabled: true
Yes, migrations in tests may be a little slower than a “blank H2 and create-drop.” But that is a conscious price for the tests becoming reliable evidence rather than “roughly checked, roughly works.”
If at some point you catch a mapping error that only PostgreSQL+Flyway sees, that will be unpleasant exactly once. After that, you will start trusting your tests — and that is priceless in the persistence layer.
6. Fixtures: minimum, determinism, and meaning
A fixture is not “dump as much as possible into the database, maybe it will come in handy.” A fixture is the minimum set of data needed to reproduce a specific scenario. We are not writing an encyclopedia of data, but a small scene: “there is a product with this SKU,” “there is an order with two items,” “there is an inventory item that conflicts by version.” That is all. The less extra stuff in the fixture, the easier the test is to understand (and the less chance the test breaks because of accidental dependencies).
There are two main approaches to fixtures in data tests, and both are useful, just for different purposes:
| Approach | What it looks like | When it is convenient | What to remember |
|---|---|---|---|
| Create through repositories | repository.save(...) | when the ORM path matters: cascades, mapping, flush | do not forget flush() and clear() |
| SQL fixtures | @Sql("/fixtures/...sql") | when exact IDs or tricky schema conditions matter | SQL becomes part of the test and requires discipline |
For a Hibernate deep-dive course, it is often nicer to create a fixture through the repository, because we want to “feel the ORM,” not bypass it. But sometimes SQL is more honest, especially if you want to insert a row with a specific id=1 and check the behavior of getReferenceById(1) on a previously known value.
To keep tests from turning into an endless manual constructor for entities, it is helpful to have small helper methods. For example, for Product:
private Product product(String sku, String name) {
// Clear and transparent helper: only what is needed for the scenario
Product p = new Product();
p.setSku(sku);
p.setName(name);
return p;
}
That is only 6 lines, but they save you dozens of lines of “noise” in every test. The main rule here is simple: the helper must remain transparent. If the helper starts creating Product + ProductDetails + categories + half the domain “just in case,” then it is no longer a helper, but a mini-microservice for producing test confusion.
A separate note on determinism. Values should be “readable”: SKU-DUP-1, ORDER-TEST-1, customer@example.com. Not because it is easier for the computer, but because it is easier for you to read a test failure. ORM and SQL logs are your tool, and fixed values help you quickly see which exact row is involved in the scenario.
7. flush() and clear(): the database, not memory
Now comes a moment that breaks intuition for many beginners. Inside a transaction, Hibernate keeps entities in the persistence context (first-level cache). That means you can “read” an entity and get it not from the database, but from memory — and the test will be happy, but you still have not proven that the database was really updated the way you thought.
That is why data tests have the classic move: flush() + clear() + reread. Flush forces Hibernate to send SQL to the database, clear removes managed objects from the context, reread reads from the database again.
In the minimal form it looks like this:
// 1) Save changes in the persistence context
productRepository.save(product);
// 2) Force SQL to the DB (otherwise it may remain only “in memory”)
productRepository.flush();
// 3) Clear the persistence context so the next read comes from the DB
entityManager.clear();
// 4) Read the entity again, proving the real DB state
Product reloaded = productRepository.findById(product.getId()).orElseThrow();
These are four lines, but they turn a test from “I looked at an object” into “I proved the behavior of the database.” In a deep-dive course, that is almost always what we need.
If you catch yourself thinking, “Why do we need this clear, it works anyway,” remember the goal: we are not writing a test that says “I think it was saved,” but a test that says “I am sure the SQL has already gone out, and I am reading directly from the database.” In the persistence layer, that is a fundamental difference.
8. @DataJpaTest examples in Commerce Lab
Let us put everything together into a few small examples on our domain. They are intentionally short: the idea is not to write the perfect test library, but to see the correct basic moves.
Saved a product and reloaded it from PostgreSQL
This test is useful as a smoke test: it proves that the repository, transaction, and migrations work together, and that we are really reading from the database.
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
@Test
void savesAndReloadsProduct() {
// Arrange: create the minimal entity for the scenario
Product p = product("SKU-TEST-1", "Mouse");
// Act: save (this may still be “in memory” until we call flush)
productRepository.save(p);
// Important: send SQL to the DB within the test transaction
productRepository.flush();
// Important: remove the entity from the persistence context so the read comes from the DB, not the cache
entityManager.clear();
// Assert: read from the DB again and check that the data was really saved
assertEquals("Mouse", productRepository.findById(p.getId()).orElseThrow().getName());
}
Notice: we do not rely on “there will be a commit at the end of the test.” There will not be one; there will be a rollback. That is why flush is intentional.
Unique sku: schema constraint
In the Product.sku domain, the SKU must be unique. This is part of the contract, and we want the test to catch a violation not at the level of “somewhere in code we checked,” but at the level of the real database constraint.
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataIntegrityViolationException;
import static org.junit.jupiter.api.Assertions.assertThrows;
@Test
void rejectsDuplicateSku() {
// First product — commit to the DB (so the unique index is really “occupied”)
productRepository.saveAndFlush(product("SKU-DUP-1", "Mouse"));
// The second product with the same SKU must fail at the database level
assertThrows(DataIntegrityViolationException.class, () -> {
// saveAndFlush: deliberately force the error to appear within this call
productRepository.saveAndFlush(product("SKU-DUP-1", "Keyboard"));
});
}
Here, saveAndFlush() is not “a more correct save,” but simply a convenient way to force SQL to really go out and get a real error from the database.
Soft delete and visibility after clear()
If Product is soft-deleted for us (Hibernate 7.2 @SoftDelete), then deletion is not a physical DELETE, but a flag/state change. Checking this “inside the persistence context” is dangerous: you may see the object in memory and not understand how it will be visible on a fresh read.
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Test
void softDeletedProduct_isHiddenAfterClear() {
// Create and save the entity so that it is definitely in the DB
Product p = productRepository.saveAndFlush(product("SKU-SD-1", "ToBeDeleted"));
// Delete through EntityManager: in soft delete this will be an UPDATE/flag, not a physical DELETE
entityManager.remove(p);
// Important: send SQL to the DB so the “deletion” really happens
entityManager.flush();
// Important: clear the persistence context so the next check reads from the DB
entityManager.clear();
// Check that the entity is no longer visible through the repository after soft delete
assertTrue(productRepository.findById(p.getId()).isEmpty());
}
Notice how much meaning is hidden in these few lines. We delete, force the SQL out (flush), throw away the context (clear), and check visibility through a new query. This is the “right” test model for soft delete: not “I removed the object,” but “I confirm the data visibility rule after deletion.”
9. Typical mistakes in @DataJpaTest
Mistake #1: Treating @DataJpaTest as a “regular repository test” and ignoring the transaction.
A beginner often thinks that save() writes to the database immediately and you can check constraints right away, as in a real application. But in @DataJpaTest there will be a rollback at the end, and there will be no commit. That is why, without flush(), many things do not show up. The correct habit is: when a test checks the database, it must explicitly synchronize the context with the DB via flush() or saveAndFlush().
Mistake #2: Checking the result through the same managed object without doing clear().
This is one of the most insidious forms of self-deception in ORM tests. You changed a field, did save(), then looked at entity.getName() and the test passed. But you checked memory, not the database. In the deep-dive approach, rereading after clear() is almost always needed to prove the real DB state.
Mistake #3: Huge fixtures “for all occasions.”
When test data is prepared once with a big seed, tests become fragile and hard to understand. A failure turns into an investigation: “Which of these 200 rows affected the result?” It is much calmer to live when one test brings exactly the minimum data it needs, and that data is named according to the scenario meaning.
Mistake #4: Use H2 “for speed,” and then be surprised that PostgreSQL behaves differently.
Speed matters, but in a deep-dive course we are buying not speed, but accuracy. H2 can create a false sense of stability and miss real problems with schema, types, dialect, or locking. If the goal is a persistence-layer regression suite, realism is more important than “it runs in 0.2 seconds.”
Mistake #5: Let @DataJpaTest replace DataSource without noticing.
If you forget @AutoConfigureTestDatabase(replace = NONE), you may end up with some tests unexpectedly running on an embedded database. That is especially unpleasant because everything looks “green” from the outside. Therefore the discipline is simple: we explicitly forbid replace and explicitly connect application-test.yml to PostgreSQL.
GO TO FULL VERSION