DEV Community

Rahkel Pekins
Rahkel Pekins

Posted on

My Tests Were Green on the Wrong Database — Moving to Real PostgreSQL with Flyway

In Part 1 I ended with a question I was genuinely sitting with: REST controllers first, or persistence first?

Persistence won — in the project's own ROADMAP order. And it turned out to be the right call, because it exposed something uncomfortable: my repository tests were green, but they were green on the wrong database.

The problem: H2 was hiding the truth

Up to this point, the app's tests ran against H2, an in-memory stand-in. That's fine for fast unit tests. It's not fine as proof that your schema works, because the database your app will actually run on is PostgreSQL — different engine, different constraint behavior, different failure modes.

A test passing on H2 only proves the code works in a world that doesn't exist. So Phase 3 had one job: make the persistence layer real, reproducible, and proven on the real engine.

Shipped: four PRs, one evening

PR #13 — ROADMAP close-out, and one docs-vs-code call: my ROADMAP listed a POOR book condition that my enum didn't have, so I changed the code, not the doc. POOR was appended to BookCondition after FAIR.
PR #14 — Flyway plus the baseline migration.
PR #15 — Environment-based database config and documentation.
PR #16 — Repository tests on real PostgreSQL, plus a matching CI service.
48 tests when the evening ended.

Flyway owns the schema now

The baseline migration, V1_initial_schema.sql, creates all five tables and mirrors the JPA entities exactly — including named constraints, so a failure message points at a real, findable thing instead of an anonymous auto-generated name:

CREATE TABLE book_copies (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
barcode VARCHAR(255) NOT NULL,
is_available BOOLEAN NOT NULL,
times_borrowed INTEGER NOT NULL,
condition VARCHAR(255) NOT NULL,
acquired_date DATE NOT NULL,
book_id BIGINT NOT NULL REFERENCES books (id),
owner_id BIGINT NOT NULL REFERENCES library_members (id),
visibility VARCHAR(255) NOT NULL,
CONSTRAINT book_copy_barcode UNIQUE (barcode)
);

That owner_id foreign key is the ownership work from Part 1, now real in the database — a copy cannot exist without a member who owns it. The other tables follow the same pattern: library_members with a named library_member_email unique constraint, books with book_isbn, and borrowings linking book, copy, and borrower.

The key decision is in the profile that runs against it:

spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/library}
spring.datasource.username=${DB_USERNAME:library}
spring.datasource.password=${DB_PASSWORD:}
spring.datasource.driver-class-name=org.postgresql.Driver
spring.flyway.enabled=true
spring.jpa.hibernate.ddl-auto=validate

ddl-auto=validate is the whole philosophy in one line. Hibernate no longer creates or updates the schema — Flyway applies the migration, and Hibernate checks the entities agree with it, refusing to start if they don't. The migration is the source of truth. Never edit an applied migration; schema changes ship as new V2_..., V3_... files.

Config that doesn't leak secrets

Database settings moved to environment variables with a .env.example in the repo, and the README gained a "Database Setup (PostgreSQL)" section so anyone cloning the project can reproduce the setup from scratch. No credentials live in the code.

One honest detail: there's no Docker on my machine, so the usual Testcontainers route was out. PostgreSQL 17 runs natively on my machine instead. Not the fashionable path — the path that actually works in my setup. The CI side still gets the standard treatment: a postgres:17 service with a health check, matching the test setup.

The test that finally proves something

Only one test class moved off H2 — BookCopyRepositoryTest. The other 45 tests stay where they are; this was never about moving everything, it was about proving the repository layer against the real engine.

The gotcha that would have silently undone the whole thing: @DataJpaTest swaps in an embedded database by default. Without one annotation, my "PostgreSQL test" would have been an H2 test wearing a PostgreSQL costume:

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestPropertySource(locations = "classpath:application-pgtest.properties")
class BookCopyRepositoryTest {

Replace.NONE is the line that makes it real. The test profile points at a dedicated library_test database, with Flyway on and ddl-auto=validate — so the test schema is built by the same migration the app uses, not by Hibernate improvising one.

And the test I'm proudest of is a failure. Saving a second copy with a duplicate barcode throws a PersistenceException whose root cause names the real book_copy_barcode unique constraint. That's proof the constraint exists on the real engine — not just in an annotation, not just in H2's approximation of it.

What I learned

  1. An in-memory database is a stand-in, not a substitute. It's great for speed, dangerous as proof.

  2. Named constraints are documentation. When a test fails, it should tell you exactly which rule broke.

  3. validate beats create-drop once a schema exists. Letting the ORM silently rebuild your schema hides drift between your entities and your migrations — the exact drift that breaks you in production.

  4. The unglamorous work is the platform. Nobody demos a migration file. But without it, every feature built on top is sitting on a schema nobody can reproduce.

What's next

Persistence is done: externalized config, reproducible migrations, integration tests on the real database. The next chapter is the REST API — controllers and global exception handling on top of the service layer, so this stops being a library only other code can use and starts becoming a platform members can actually call.

I'm working on that today, and it gets its own post next week — after it's actually finished, not before. That's the rule for this series: shipped first, posted second.

If you've moved a project from H2 (or SQLite, or any in-memory stand-in) to a real database, what broke first — the schema, the tests, or your assumptions? I'd genuinely like to hear it.

Code, if you want to follow along or tell me what you'd do differently:
GitHub - github.com/Pekins1/LibraryManagementSystem

— Rahkel

Top comments (1)

Collapse
 
diazisme profile image
Manuel Diaz •

Really enjoyed this update, Manuel. You’ve highlighted an important lesson: passing tests only means something when they run against the system you’ll actually use.
Moving the repository test to PostgreSQL, letting Flyway own the schema, and keeping Hibernate on validate makes the setup much more trustworthy and reproducible. I especially like the duplicate-barcode test—failure tests can be some of the clearest proof that a constraint is doing its job.
Thanks for sharing the honest details, including the native PostgreSQL setup. Looking forward to the REST API chapter and following along as the project grows. Always happy to connect and cheer each other on in the dev community!

⌂ 换网址