Testcontainers for Postgres & Mongo
A mocked repository lets a broken migration or a duplicate key sail straight through your test suite — a real Postgres and Mongo, booted just for the test, will not.
Every integration test you have written so far for the valuation service has probably mocked `VehicleRepository` or the Mongo audit repository at some layer — and that is the right call for a unit test, but it quietly hides an entire category of bugs. A mock returns exactly what you told it to return. It never rejects a `pricing_rule` insert because you violated a real unique constraint, never fails a query because your JPQL compiles but the generated SQL does not match Postgres's actual dialect, and never surfaces the fact that a Mongo query you wrote against a field that is not actually indexed will silently work in a test but crawl in production. Mocks encode your assumptions about the database. Sometimes your assumptions are wrong, and only the real thing tells you so.
Testcontainers closes that gap by giving your test suite a real, disposable Postgres and a real, disposable MongoDB — each running in an actual Docker container, started fresh (or reused) for the test class, and torn down automatically when the JVM exits. It is not a simulation of Postgres's behavior; it is Postgres, the same binary your production database runs, just scoped to a single test run. The JUnit 5 integration is two annotations: `@Testcontainers` on the test class tells JUnit to manage container lifecycle, and `@Container` marks the field holding the container instance.
In practice you declare the containers as `companion object` fields so they are shared across every test method in the class instead of restarted per test — starting a Postgres or Mongo container takes real wall-clock time (usually one to a few seconds), and paying that cost once per class instead of once per test is the difference between a suite that runs in ten seconds and one that runs in ten minutes. Testcontainers ships a background process called Ryuk that watches for orphaned containers and kills them even if your JVM crashes mid-test, so you never end up with a graveyard of stray Postgres containers idling on your machine.
The part that makes this actually usable inside a Spring test is `@DynamicPropertySource`. A container does not know its host port until Docker assigns one at startup — you cannot hardcode `jdbc:postgresql://localhost:5432/valuation` in `application-test.yml` because that port will not be 5432, and it will be different on every run. `@DynamicPropertySource` is a static method that runs after the containers start but before the Spring context loads, and it lets you read the container's actual mapped JDBC URL or Mongo connection string and inject it as a Spring property at exactly the moment Spring needs it.
Once that is wired up, tests that were previously impossible with a mock become straightforward. You can insert a `pricing_rule` row with rule code `HIGH_MILEAGE_DEDUCTION`, then attempt to insert a second row with the same code, and assert that Spring Data throws a `DataIntegrityViolationException` — because the real unique index on `rule_code` is doing real work, the same work it will do in production the day someone's deploy script tries to seed a rule that already exists. A mock would have happily accepted both inserts and told you nothing was wrong.
The same logic applies to the Mongo side of the audit trail. `valuation_event` documents are written fire-and-forget by a background coroutine, and the queries your dashboards and support tooling run against that collection — find the most recent event for a VIN, find every event in a date range — depend on Mongo's actual query planner and, eventually, on the indexes you define. Running those queries against a real `MongoDBContainer` catches the case where your query is syntactically valid but semantically wrong (wrong operator, wrong field name, a date comparison against a string) long before a support engineer discovers it during an incident.
The honest tradeoff is speed: a Testcontainers-backed test class is slower to start than one built entirely on mocks, because Docker has to pull an image (once, then cached) and boot a real database process. That is exactly why these tests are reserved for the repository and persistence layer specifically — the handful of tests that genuinely need to prove SQL and Mongo queries behave correctly against the real engine — while the much larger body of service-layer and business-logic tests keeps using fast, in-memory mocks. You get real correctness where it matters and a fast feedback loop everywhere else.
@Testcontainers@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)class PricingRuleRepositoryIntegrationTest {companion object {@Container@JvmStaticval postgres: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:16-alpine").withDatabaseName("valuation").withUsername("test").withPassword("test")@Container@JvmStaticval mongo: MongoDBContainer = MongoDBContainer("mongo:7.0")@DynamicPropertySource@JvmStaticfun registerDynamicProperties(registry: DynamicPropertyRegistry) {registry.add("spring.datasource.url", postgres::getJdbcUrl)registry.add("spring.datasource.username", postgres::getUsername)registry.add("spring.datasource.password", postgres::getPassword)registry.add("spring.data.mongodb.uri", mongo::getReplicaSetUrl)}}@Autowiredlateinit var pricingRuleRepository: PricingRuleRepository}
A test class that boots a real Postgres and a real Mongo container once, then wires their dynamic connection details into the Spring test context.
@Testfun `saving two pricing rules with the same rule code violates the unique constraint`() {val original = PricingRuleEntity(ruleCode = "HIGH_MILEAGE_DEDUCTION",thresholdMiles = 100_000,adjustmentPercent = BigDecimal("-0.08"))pricingRuleRepository.saveAndFlush(original)val duplicate = original.copy(id = null)assertThrows<DataIntegrityViolationException> {pricingRuleRepository.saveAndFlush(duplicate)}}
A real unique-constraint violation that only shows up against the actual Postgres engine — a mock would let both inserts succeed.
@Testfun `finds only the valuation event that falls inside the requested date range`() {val vin = "1HGCM82633A004352"valuationEventRepository.save(ValuationEvent(vin = vin, decidedAt = Instant.parse("2026-08-01T00:00:00Z"), outcome = "APPROVE"))valuationEventRepository.save(ValuationEvent(vin = vin, decidedAt = Instant.parse("2026-08-15T00:00:00Z"), outcome = "NO_OFFER"))val results = valuationEventRepository.findByVinAndDecidedAtBetween(vin,Instant.parse("2026-08-10T00:00:00Z"),Instant.parse("2026-08-20T00:00:00Z"))assertEquals(1, results.size)assertEquals("NO_OFFER", results.first().outcome)}
A real Mongo query against the valuation_event audit collection, proving the date-range filter actually matches what the field mapping and index expect.
🧠 Check your understanding
0/1 · 0/1 answered1. Why spin up a real Postgres container for the pricing_rule repository tests instead of pointing the same tests at an in-memory H2 database configured in "Postgres compatibility mode"?