Quick answer
Spring integration testing should select the smallest environment that can prove a specific contract, then use a production-like boundary when a mock would hide the risk. MVC slices efficiently prove request mapping, validation, and HTTP error contracts. A full @SpringBootTest proves wiring across controllers, security, services, transactions, configuration, and persistence. Testcontainers PostgreSQL is valuable when SQL dialect, constraints, migrations, transaction behavior, locking, or JDBC configuration matter; H2 is not a substitute for PostgreSQL fidelity.
The order service needs all three levels. Unit tests make policy and branching easy to review. Slice tests ensure invalid JSON does not call checkout and that authentication failures have stable status codes. Full-context tests with a real PostgreSQL container prove a unique idempotency constraint, migration-created indexes, committed transaction behavior, tenant scoping, and HTTP/security integration. None of these tests proves the exact production network, data volume, deploy sequence, browser, payment provider, or Cloudflare path.
The mainline in this article is Java 17 and Spring Boot 4.1. Spring Boot 3.5 projects can use the same test strategy but must verify their supported Testcontainers integration, JUnit setup, property wiring, migration tool version, and security defaults. “The test passed locally” is evidence about that pinned build and container image, not a compatibility declaration for a framework upgrade.
Shared order-service incident stage
The flash-sale incident revealed that the order service test suite had green controller tests but no proof that the production-style reservation statement and unique idempotency constraint behaved correctly under PostgreSQL. An H2-only test accepted a query pattern that differed in locking and schema behavior. At the same time, an endpoint test bypassed the real security chain, so it did not expose a broad authorization rule before the incident.
Responders used the outage as a test-design lesson, not a reason to claim that containers simulate production. The next test increment starts with the customer command, passes through the real HTTP contract and security filter chain, invokes the short transaction, applies the migration-created schema to PostgreSQL, and asserts durable rows after commit. A separate concurrency test coordinates two attempts for the final unit and asserts that committed reservations never exceed inventory. It measures an invariant, not an implementation detail such as which exact SQL exception happens to appear.
Failure injection is equally important. A delayed database operation, an unavailable database, a migration checksum mismatch, and a downstream payment timeout exercise different boundaries. The local order transaction must not masquerade as a remote distributed transaction. The test can prove recovery of a durable outbox record after local commit; it cannot prove a third-party provider’s settlement semantics. Production rehearsal and monitoring remain necessary evidence.
Core mechanism and evidence boundary
A Spring test slice loads only a focused part of the application. For example, @WebMvcTest is appropriate for controller mapping, serialization, validation, MVC exception translation, and a deliberately imported security configuration. Mock collaborators so the HTTP layer remains the subject. It is not evidence that repositories, transactions, migrations, background workers, or production configuration wire correctly, because most of those components are not present.
@SpringBootTest loads a full application context and can exercise the web layer using a mock server or an actual random port. It is the right tool when behavior crosses auto-configuration, controllers, security, application services, database access, and transaction management. It costs more and can become slow or brittle if every test starts a fresh context, so reserve it for integration contracts with a real boundary to verify. A full context with a mocked database is still not a database integration test.
Testcontainers runs a real dependency in a container managed by the test lifecycle. PostgreSQL fidelity matters for column types, SQL functions, indexes, query planner behavior, constraints, isolation, locks, JSON behavior, sequences, and error codes. Use a pinned compatible image strategy and make the test environment reproducible. The container is not production: its storage, CPU, data distribution, network path, TLS, credentials, backups, replicas, and operational limits can differ significantly.
Migrations are executable schema contracts. Apply the same migration mechanism that production uses to an empty container, then assert expected behavior through the application rather than issuing ad hoc test-only DDL. A migration test catches a broken SQL script, incompatible type, missing index, or invalid order before deployment. It does not prove an upgrade against every historical production schema; test representative upgrade paths separately when the release changes existing data.
Transaction tests need an explicit commit boundary. A test method wrapped in a test-managed transaction may roll back automatically after assertions, which is useful for isolation but cannot prove what a separate connection sees after the application commits. To prove idempotency or outbox durability, invoke the application transaction, then query with a fresh transaction or separate connection and assert committed state. Avoid relying on persistence-context visibility; a cached entity can hide that SQL was never flushed or committed.
Configuration is an integration contract too. Supply test properties through one visible mechanism, record versions of the database image and migration tool, and fail if a required production-like property is silently replaced by a test default. For example, a test that drops connection validation, disables statement timeouts, or substitutes an in-memory queue may be appropriate for a narrow unit test but must not be described as full-path checkout evidence. Keep these distinctions in test names so future maintainers understand why a container is present.
Test data lifecycle requires care with reusable containers and parallel execution. A reused PostgreSQL container makes suites faster, but each test still needs deterministic cleanup or a unique schema/tenant namespace. Do not depend on alphabetical test order or share a global idempotency key. When concurrency is the subject, control the initial data and the barrier explicitly; when migration history is the subject, begin from the intended historical version rather than whatever a prior test left behind.
Minimal reproducible Spring example
This Java 17 listing is a configuration skeleton, not a copy-paste executable checkout test: the application’s supported JWT test issuer, checkout JSON contract, migration tool, and order-table names are intentionally application-specific. It starts PostgreSQL once for the test class and dynamically supplies JDBC properties before Spring creates its datasource. It uses @SpringBootTest because the target contract includes security, HTTP, migrations, service wiring, and a real transactional repository. A Spring Boot 3.5 project should confirm the matching @ServiceConnection or dynamic-property approach in its own supported versions.
package com.example.orders;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderApiIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17.10-alpine3.23");
@DynamicPropertySource
static void databaseProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Test
void startsTheProductionLikeApplicationContext() {
// The concrete replay assertions are listed immediately below.
}
}
For this dated example, postgres:17.10-alpine3.23 is the exact PostgreSQL patch and Alpine release listed by the Docker Official Images catalog as available on 2026-08-05; it avoids the mutable 17-alpine selector. An exact tag can still be rebuilt. A hermetic CI or release record should resolve the tag with a registry-aware command such as docker buildx imagetools inspect postgres:17.10-alpine3.23, retain the full multi-platform manifest digest and selected architecture in test evidence, and use postgres@sha256:<reviewed-manifest-digest> when immutable replay is required. Never claim reproducibility from a shortened digest or from a locally cached tag whose registry identity was not recorded.
Use this concrete replay check sequence to turn the skeleton into the application’s executable test. First apply production migrations to the fresh PostgreSQL container and seed one tenant-scoped customer and one SKU with one available unit. Second obtain a test JWT from the application’s supported test issuer (or configure its JwtDecoder test double to validate a fixed signed-token fixture), then send an authenticated POST /orders with a fixed idempotency key. Assert the documented success status and response identifier. Third send the identical authenticated request again and assert the documented replay status and the same response identifier, not a second reservation. Finally, query the database through a new transaction or new JDBC connection and assert exactly one committed order, one committed reservation of one unit, and the migration-created unique (customer_id, idempotency_key) row. The fresh boundary is essential: an entity still visible in the original persistence context is not proof of commit.
If the endpoint returns a timeout after commit, repeat the request with the same key and assert the replay contract explicitly rather than inferring it from one happy path. Keep test credentials synthetic and redacted; the point is real HTTP/security behavior and database durability, not a production token or copied customer record.
For concurrency, coordinate two callers behind a barrier so both compete for the final inventory. Do not merely invoke a mocked repository twice in sequence. After both complete, assert the durable invariant: successful committed quantity is at most available quantity, conflicts have the documented HTTP or domain result, and no partial idempotency record remains. Make thread and timeout handling bounded so a database regression produces a clear failure, not a permanently hung build.
Failure modes and dangerous misconceptions
“A slice test proves the application.” It proves the slice you loaded. A controller test with a mocked service is excellent for HTTP shape, but it cannot prove an index, transaction proxy, migration, or security filter integration unless those are deliberately part of that test.
“H2 is PostgreSQL enough.” H2 can be useful for fast limited tests, but its SQL, type system, locking, constraint behavior, and optimizer are not PostgreSQL. Do not use a green H2 test as evidence that production PostgreSQL migrations or concurrency rules work.
“An exception means rollback happened.” A transaction may be marked rollback-only, may have committed an independent transaction, or may have left prior work outside the transaction. Assert committed state with a fresh boundary and test the desired durable result.
“Containers reproduce production.” They improve dependency fidelity, not operational equivalence. They do not automatically reproduce production data, replica lag, DNS, TLS, credentials, Kubernetes probes, pool saturation, or a payment provider. State the remaining gaps.
“Security can be disabled in integration tests.” A disabled filter chain cannot prove authorization, CSRF posture, challenge behavior, or tenant ownership. Use supported test identities and exercise allowed and denied requests.
Security, privacy, transaction, capacity, and cost implications
Test fixtures are data handling systems. Use synthetic customers, addresses, tokens, and order values; do not copy production records into a developer container. Keep credentials out of source and test output, redact failing request bodies, and configure test logs so bearer tokens and JDBC passwords cannot be published by CI artifacts. An integration test that requires a privileged production secret is an architecture smell, not a validation prerequisite.
Transaction fidelity prevents costly false confidence. Test the unique idempotency constraint, reservation predicate, rollback behavior, and migration sequence against PostgreSQL. Keep remote payment, email, and external identity calls behind controllable adapters; inject a timeout or failure and assert local state, outbox intent, and user-safe result. The local database commit cannot make a remote request atomic, even when both are exercised in the same test.
Containers consume CPU, memory, disk, image-download time, and CI capacity. Reuse them sensibly within an isolated test suite, pin versions, make startup failures visible, and avoid silently falling back to an in-memory database. Parallel tests must isolate schema, database, tenant, or data keys; otherwise a flaky uniqueness failure is a shared fixture bug, not an application signal. Set test timeouts and cleanup policies deliberately.
Testing and production validation
Build a layered suite. Unit-test pure calculations, authorization policy branches, and error mapping. Use MVC slices for JSON validation, content type, HTTP status, problem documents, and authentication/authorization contract edges. Use full-context Testcontainers tests for migrations, datasource configuration, query behavior, security chain integration, transaction commit/rollback, and repository constraints. Add a small number of end-to-end tests against a production-like deployment for ingress, TLS, readiness, and real configuration.
For the order service, cover at least: invalid request does not reach service; no credential and insufficient authority are denied; another tenant cannot read an order; duplicate key returns its recorded outcome; two callers cannot oversell final stock; a migration creates required uniqueness/indexes; a database timeout produces the documented retry-safe result; and a post-commit outbox record can be recovered. Each test should state which evidence it does not provide.
Before release, run the suite from a clean environment and retain concise logs, image versions, migration version, and test report. In a staging rehearsal, introduce bounded latency and dependency loss, observe readiness and pool wait, and compare the deployed configuration with the test configuration. Production validation then checks live probes, safe canary commands, dashboards, alert routing, rollback behavior, and recovery—not merely the green local test report.
Make CI failures diagnostically useful. Preserve a redacted database log or query fingerprint when an assertion fails, identify the migration version and container image, and distinguish an unavailable Docker daemon from an application regression. Retrying an infrastructure failure may be reasonable after classification; repeatedly retrying a deterministic migration or concurrency failure only delays investigation. The suite should make its dependency requirement explicit so contributors do not unknowingly obtain a weaker fallback test.
Operations checklist
- Select a unit, slice, full-context, or deployment test based on the risk being proved.
- Use
@SpringBootTestonly when cross-layer wiring is part of the contract. - Run migrations against Testcontainers PostgreSQL for database-dependent behavior.
- Do not substitute H2 evidence for PostgreSQL SQL, locking, or migration fidelity.
- Assert committed state through a fresh transaction or connection when durability matters.
- Exercise real security configuration with allowed and denied identities.
- Test HTTP status, problem contracts, tenant ownership, and idempotent replay behavior.
- Coordinate concurrent calls to prove the final inventory invariant.
- Inject bounded database and remote failures; verify local recovery and outbox state.
- Document production differences and rehearse the remaining operational paths.
Official sources
- Spring Boot testing — accessed 2026-08-05.
- Spring Boot Testcontainers — accessed 2026-08-05.
- Testcontainers for Java — accessed 2026-08-05.
- PostgreSQL documentation — accessed 2026-08-05.
Continue the learning path
Use Spring Security Filter Chain and Authorization Explained to make the security tests meaningful, then connect test evidence to recovery in Production Spring Boot Incident Troubleshooting. Continue via the Spring Backend course, Topics, and Spring Backend Engineering.