The previous lesson closed with a crack that was named but not sealed: everything we have tested against the database has run on H2 in memory, and the Ribalta network runs on PostgreSQL 16. While the tests are simple the difference does not show, and that is precisely the trap: a test that passes green on H2 while the same code fails in production is worse than having no test at all, because on top of everything it inspires confidence.
This lesson removes that zone of faith. We will see with concrete examples what breaks when you change engine, what Testcontainers is and how it works, how to start a real, ephemeral PostgreSQL for the tests, the difference between wiring it up by hand with @DynamicPropertySource and doing it automatically with @ServiceConnection, how to share one container across every class so the suite does not take half an hour, how to genuinely check that migrations V1…V6 leave the schema the entities expect, how to isolate data between tests and how to use the same containers to start the application in development without installing anything. By the end, CicloUrbana's suite will be complete and the module closed.
Contents
- The problem with testing against H2
- What Testcontainers is and how it works
- Dependencies
- The first test with
@Container @DynamicPropertySourceversus@ServiceConnection- A shared container:
IntegrationTestBase - Reuse with
withReuse - Verifying Flyway for real
- The queries H2 does not support
- Isolating data between tests
- Testcontainers in local development
- Other useful containers
- Cost, continuous integration and debugging
- Common Mistakes and Tips
- Exercises
- The problem with testing against H2
H2 has a PostgreSQL compatibility mode, and it works surprisingly well. Until it does not.
| Difference | What happens in CicloUrbana |
|---|---|
| Native SQL | findExpensive from 04-06 is nativeQuery = true; any PostgreSQL-specific function (ILIKE, to_tsvector, jsonb_path_query) blows up on H2 |
| Partial indexes | Migration V3 creates CREATE INDEX ... WHERE status <> 'CLOSED'; H2 does not support them and the migration either fails or is ignored |
| Sequences | The allocationSize = 50 of 04-03 and the real INCREMENT BY of the sequence must match; on H2 the identifiers come out differently and a mismatch that corrupts data in Ribalta never surfaces here |
| Types | jsonb, uuid, text[], interval, timestamptz; H2 approximates or rejects them |
Deferred constraints and ON CONFLICT |
A PostgreSQL upsert has no equivalent |
| Locks | The @Lock(PESSIMISTIC_WRITE) of 04-07 on Bike behaves differently: the race between two users renting the same bike cannot be reproduced on H2 |
| Case sensitivity | H2 folds unquoted identifiers to upper case and PostgreSQL to lower case: a table Stations works on one and not on the other |
| Error messages | The SQL code of a uniqueness violation differs, so the catch that translates to ResourceConflictException may never fire |
And the most serious case of all, because it is silent: ddl-auto: validate against a schema created by H2 validates nothing useful. In 06-04 we disabled Flyway in the test profile and let Hibernate create the tables with create-drop. That means the test schema is generated by the entity itself, so it always agrees with itself. An entity out of step with migrations V1…V6 would pass every test and fail on start-up in production.
The conclusion is not that H2 is bad: it is fast and it serves many slices well. The conclusion is that the suite also needs a stretch running against the real engine.
- What Testcontainers is and how it works
Testcontainers is a library that starts Docker containers from test code and destroys them at the end. It gives you databases, queues, storage and fake services that are real and ephemeral, without installing anything on the machine or sharing a server between developers.
flowchart LR
T["The JUnit test"] --> API["Testcontainers API"]
API --> D["Docker daemon"]
D --> C["postgres:16-alpine container<br/>random port"]
D --> R["Ryuk (watchdog container)"]
C --> W["Waits until it is ready<br/>(wait strategy)"]
W --> T
R -. "wipes everything if the JVM dies" .-> C
The cycle is always the same: pull the image if it is missing, start the container publishing the internal port on a free, random port of the host, wait until the service is ready —for PostgreSQL, until it accepts connections— and hand the test the real URL, username and password.
Two pieces are worth knowing by name. The random port is what lets several builds run at once on the same agent without clashing, and it is the reason the URL cannot be written into application.yml: it is unknown until start-up. Ryuk is an auxiliary container that Testcontainers starts and that wipes everything created if the JVM dies abruptly; without it, a kill -9 would leave orphaned containers eating memory.
Requirements: a working Docker environment (Docker Desktop, Colima, Podman in compatible mode or Docker Engine on Linux) and permission to use it. If there is none, the tests fail when starting the container; in section 13 we will see how to skip them gracefully.
- Dependencies
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>1.20.4</version>
<type>pom</type>
<scope>import</scope> <!-- aligns the versions of every module -->
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId> <!-- @Testcontainers and @Container -->
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId> <!-- PostgreSQLContainer -->
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId> <!-- @ServiceConnection -->
<scope>test</scope>
</dependency>
</dependencies>Three notes. The BOM stops you mixing versions between junit-jupiter, postgresql and the other modules, which is a common source of strange errors; Spring Boot already manages the Testcontainers version, so declaring the BOM is optional but advisable if you add modules Boot does not know about. spring-boot-testcontainers is the Spring Boot 3.1+ piece that provides @ServiceConnection and the development support of section 11. And everything is <scope>test</scope>: none of it is packaged.
- The first test with
@Container
@Containerpackage com.ciclourbana.rentals;
@SpringBootTest
@Testcontainers // manages the lifecycle of the @Container fields
class RentalRepositoryPostgresIT {
@Container
@ServiceConnection // connects the DataSource automatically (section 5)
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired private RentalRepository rentalRepository;
@Test
void connectsToARealPostgresAndNotToAnInMemoryDatabase() {
assertThat(postgres.isRunning()).isTrue();
assertThat(rentalRepository.count()).isNotNegative();
}
}The details matter more than they look:
@Testcontainersis the JUnit 5 extension that starts and stops the@Containerfields.staticchanges the lifecycle completely: a static@Containerfield starts once for the whole class; an instance one, once per test method. With PostgreSQL, the second option multiplies the time by the number of tests and hardly ever pays off.postgres:16-alpine: the tag is always pinned, and matched to the production version. Usinglatestguarantees that one day the build breaks on its own.- The
ITsuffix makes Failsafe run it under./mvnw verifyrather than Surefire under./mvnw test(06-01). That is what keeps the short cycle down to seconds.
@DynamicPropertySource versus @ServiceConnection
@DynamicPropertySource versus @ServiceConnectionThe container starts on a URL unknown beforehand. There are two ways of making Spring use it.
The classic form, with the @DynamicPropertySource that 06-04 left ready:
@DynamicPropertySource
static void configureDataSource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}It works because the method runs after the container starts and before the context is created, and because it registers suppliers, not values: they are evaluated at exactly the right moment.
The modern form (Spring Boot 3.1+) is a single annotation:
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");@DynamicPropertySource |
@ServiceConnection |
|
|---|---|---|
| Code | Three or more lines per service | One annotation |
| Property names | Written by hand: a typo is discovered late | Resolved by Spring Boot |
| Other services (Redis, Kafka, RabbitMQ) | You have to know each one's keys | The same mechanism for all of them |
| Fine tuning (extra pool parameters) | Yes | Combinable with @DynamicPropertySource |
| Available since | Always | Spring Boot 3.1 |
The course recommendation is @ServiceConnection, and @DynamicPropertySource is left for what is not a service connection: the URL of a fake API, a computed configuration flag, a temporary directory. Under the bonnet, @ServiceConnection works through ConnectionDetails, the same mechanism the autoconfiguration of 02-06 consults before looking at the properties.
- A shared container:
IntegrationTestBase
IntegrationTestBaseOne container per test class looks clean and ruins the suite: twenty classes are twenty PostgreSQL start-ups, one to three seconds each, plus twenty different Spring contexts if every class declares its own configuration.
The solution is the shared static container pattern, and it fits exactly the base class that 06-04 recommended for not fragmenting the context cache:
package com.ciclourbana;
@SpringBootTest
@ActiveProfiles("test")
@AutoConfigureMockMvc
public abstract class IntegrationTestBase {
/*
* Static and WITHOUT @Container: we do not want the extension to stop it
* at the end of each class. It is started once in the static block and
* lives until the JVM dies; Ryuk takes care of wiping it if anything fails.
*/
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("ciclourbana")
.withUsername("ribalta")
.withPassword("ribalta");
static {
POSTGRES.start();
}
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
}
}And every integration test inherits from it:
class RentalFullFlowIT extends IntegrationTestBase {
@Autowired private MockMvc mockMvc;
// ... without a single container annotation
}| Container per class | Shared container | |
|---|---|---|
| PostgreSQL start-ups | One per class | One per run |
| A 20-class suite | +40 s in containers alone | +2 s |
| Data isolation | Total | Requires a strategy (section 10) |
| Spring contexts | Risk of one per class | One, thanks to the base class |
Why the static block instead of @Container. The @Testcontainers extension stops a static container at the end of the class; inherited, that would mean stopping and starting it with every subclass. By starting it by hand in the static block, the JVM keeps it alive for the whole run of the suite and Ryuk guarantees the clean-up. It is the detail that separates a two-minute suite from a twelve-minute one.
With @ServiceConnection the pattern is even shorter, because the @DynamicPropertySource disappears; in exchange, the annotation requires a @Container field, so the usual variant is to declare the container in a @TestConfiguration imported by the base class, which is exactly what section 11 does.
- Reuse with
withReuse
withReuseYou can go one step further and keep the container between runs of the suite: the first time it starts, and subsequent times the one already running is reused.
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true);# ~/.testcontainers.properties (in the developer's HOME, NOT in the repository)
testcontainers.reuse.enable=trueThe switch living in the personal file and not in the project is deliberate: reuse is a development convenience, not shared configuration. Its conditions and its risks:
- Without
testcontainers.reuse.enable=truein the user's file,withReuse(true)is ignored. - The container is not wiped at the end: it goes on eating memory until it is stopped by hand.
- Data persists between runs. That is the advantage —instant start-up— and the danger: a test relying on the table being empty will start failing intermittently.
- In continuous integration it is always disabled. Every build must start from scratch, and the agent is ephemeral anyway.
- Verifying Flyway for real
This is the section that justifies the whole lesson. In the test profile of 06-04 we disabled Flyway and let Hibernate create the schema; now we do the opposite, which is what production does:
# src/test/resources/application-integration.yml
spring:
flyway:
enabled: true
locations: classpath:db/migration
jpa:
hibernate:
ddl-auto: validate # EXACTLY as in production (04-08)@ActiveProfiles("integration")
class FlywayMigrationsIT extends IntegrationTestBase {
@Autowired private Flyway flyway;
@Autowired private JdbcTemplate jdbc;
@Test
void appliesTheSixMigrationsOnACleanPostgres() {
MigrationInfo[] applied = flyway.info().applied();
assertThat(applied).hasSize(6)
.extracting(i -> i.getVersion().getVersion())
.containsExactly("1", "2", "3", "4", "5", "6");
assertThat(applied).allSatisfy(
i -> assertThat(i.getState().isFailed()).isFalse());
}
@Test
void leavesTheFourRibaltaStationsThatMigrationV2Loads() {
List<String> names = jdbc.queryForList(
"select name from stations order by name", String.class);
assertThat(names).containsExactly(
"Main Square", "North Station", "River Park", "University");
}
@Test
void createsThePartialIndexOnOpenIncidentsFromV3() {
// A partial index: PostgreSQL supports it, H2 does not. Here it can be checked.
assertThat(jdbc.queryForObject(
"select indexdef from pg_indexes where indexname = 'idx_incidents_open'",
String.class)).contains("WHERE");
}
}And the most valuable test in the module, which fits in two lines:
@Test
void theMigrationSchemaMatchesTheEntities() {
// If this test passes, it means ddl-auto: validate did not complain when
// creating the context over the schema left by V1..V6. An entity with a
// field no migration created would have made the start-up fail.
assertThat(true).isTrue();
}It looks like a joke and it is exactly the opposite. With ddl-auto: validate, the context does not start if an entity does not match the schema, so the mere fact that the class runs proves the alignment. It is the test that catches the scenario this lesson opened with: somebody adds @Column private String notes; to Incident and forgets migration V7. With H2 and create-drop, all green; here, the build stops with Schema-validation: missing column [notes].
If you prefer an explicit assertion instead of an isTrue() that looks decorative, the honest form is to check the schema:
@Test
void theIncidentsTableHasTheColumnsTheEntityDeclares() {
assertThat(jdbc.queryForList(
"select column_name from information_schema.columns where table_name='incidents'",
String.class))
.contains("id", "bike_id", "type", "description", "status",
"detected_level", "police_report", "created_at", "version");
}
- The queries H2 does not support
With a real PostgreSQL, the native queries of 04-06 become checkable:
class NativeQueriesIT extends IntegrationTestBase {
@Autowired private RentalRepository rentalRepository;
@Autowired private TestEntityManager em;
@Test
void findExpensiveUsesPostgresNativeSqlAndFiltersByAmount() {
em.persist(aRental().finishedWithAmount("3.50").build());
em.persist(aRental().finishedWithAmount("12.80").build());
em.flush();
List<Rental> expensive = rentalRepository.findExpensive(new BigDecimal("10.00"));
assertThat(expensive).hasSize(1)
.first().extracting(Rental::getTotalAmount)
.isEqualTo(new BigDecimal("12.80"));
}
@Test
void theSearchByNameIgnoresCaseAndAccentsAsItDoesInRibalta() {
// ILIKE is PostgreSQL's: on H2 this query does not even compile
assertThat(stationRepository.searchByName("square"))
.extracting(Station::getName).containsExactly("Main Square");
}
@Test
void theSequenceAssignsIdentifiersConsistentWithAllocationSize50() {
Station a = em.persistFlushFind(TestStations.mainSquare());
Station b = em.persistFlushFind(TestStations.university());
// With allocationSize=50 and INCREMENT BY 50 the ids are consecutive in
// memory; if the migration declares INCREMENT BY 1, gaps of 50 appear.
assertThat(b.getId()).isEqualTo(a.getId() + 1);
}
}The third one is especially interesting because it checks the consistency between two files nobody connects: the allocationSize = 50 of the annotation on Station and the INCREMENT BY of the sequence in V1__create_initial_schema.sql. A mismatch there breaks nothing visibly: it simply makes the identifiers jump fifty at a time or, worse, makes two instances of the application assign the same one. It is a failure you can only see with the real engine.
- Isolating data between tests
With a shared container, the data one test leaves behind is seen by the next. There are three strategies:
| Strategy | How | Advantages | Drawbacks |
|---|---|---|---|
Transaction with rollback (@Transactional on the test) |
Everything is undone at the end | Blazing fast, zero clean-up code | No use if the code under test manages its own transactions; does not test the commit; hides deferred constraints |
Explicit clean-up (@Sql with a TRUNCATE, or a @BeforeEach that deletes) |
A known state before every test | Works with any code; genuinely tests the commit |
You have to maintain the delete order because of the foreign keys |
| Container per class | An unshared @Container |
Perfect isolation | Seconds per class: for exceptional cases only |
CicloUrbana's policy, and the reasoning:
@Transactionalby default in tests that only read or whose writing does not involve transactions of its own. That is what@DataJpaTestdoes and it works well.- Explicit clean-up in the full-flow tests over HTTP, because there the
commithappens inside the server and the test's rollback cannot reach it. It is a classic mistake: putting@Transactionalon aMockMvctest that writes, seeing the data persist and not understanding why.
@Sql(scripts = "/data/clean-ribalta.sql", executionPhase = BEFORE_TEST_METHOD)
class RentalFullFlowIT extends IntegrationTestBase { /* ... */ }-- clean-ribalta.sql: the order matters because of the foreign keys
TRUNCATE TABLE incidents, rentals, bikes, stations, users
RESTART IDENTITY CASCADE;RESTART IDENTITY resets the sequences, so identifiers are predictable between tests; CASCADE saves you having to get the exact order right. And a weighty warning: this script lives in src/test/resources and must never be able to run against another database. Having the URL manufactured by an ephemeral container is, on top of being convenient, a protection.
- Testcontainers in local development
Spring Boot 3.1 gave Testcontainers a second use that has nothing to do with testing: starting the application in development with its real dependencies, without installing PostgreSQL or keeping a docker-compose file to hand.
// src/test/java/com/ciclourbana/ContainersConfig.java
@TestConfiguration(proxyBeanMethods = false)
public class ContainersConfig {
@Bean
@ServiceConnection
PostgreSQLContainer<?> developmentPostgres() {
return new PostgreSQLContainer<>("postgres:16-alpine").withReuse(true);
}
}
// src/test/java/com/ciclourbana/TestCicloUrbanaApplication.java
public class TestCicloUrbanaApplication {
public static void main(String[] args) {
SpringApplication.from(CicloUrbanaApplication::main)
.with(ContainersConfig.class)
.run(args);
}
}With that command, the application starts with its real main, but the context also includes the container: Flyway applies V1…V6 over a clean PostgreSQL 16 and http://localhost:8080/api/v1/stations answers with the four Ribalta stations. A new developer clones the repository and works without installing any database. And since it is a @Bean in a @TestConfiguration, the same class can be imported from IntegrationTestBase and share the container definition between development and tests.
The alternative, also from Spring Boot 3.1, is the Docker Compose support:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-docker-compose</artifactId>
<optional>true</optional>
</dependency>With a compose.yaml at the root, the application starts the services on boot and stops them on shutdown, connecting them just as @ServiceConnection does.
| Testcontainers in development | spring-boot-docker-compose |
|
|---|---|---|
| Where it is defined | Java code (@TestConfiguration) |
compose.yaml |
| Shared with the tests | Yes, the same class | Not directly |
| Used by other tools | No | Yes: docker compose up without Java |
| Fine control (waits, initialisation) | Total, in Java | Whatever Compose offers |
Neither is superior: if the team already lives with docker compose, the second fits better; if what you want is for tests and development to share exactly the same definition, the first.
- Other useful containers
Testcontainers has modules for almost everything, and CicloUrbana will need them in turn:
| Module | What for | Where it will appear |
|---|---|---|
postgresql |
The real database | This lesson |
redis (or GenericContainer with redis:7-alpine) |
Distributed cache | The cache of 09-02 |
| WireMock / MockServer | Simulating an external API (Ribalta's payment provider) | Service communication, 07-06 |
kafka |
Messaging between services | 07-05 and 07-06 |
localstack |
AWS locally: S3, SQS, DynamoDB | Deploying to AWS, 08-03 |
selenium / Playwright |
End-to-end tests with a browser | Outside the scope of this course |
GenericContainer |
Any image with no module of its own | The wildcard |
The same idea applies to all of them: @Container plus @ServiceConnection when the module supports it, or @DynamicPropertySource for the rest —for example, the URL of a WireMock standing in for the payment service.
- Cost, continuous integration and debugging
What it costs. The image download happens once (about 80 MB for postgres:16-alpine); the start-up, one to three seconds; each test, however long its SQL takes. With the shared container of section 6, twenty integration classes add a few seconds to the total.
When NOT to use Testcontainers:
- To test pure business logic.
FareCalculatorwith a container would be absurd. - To test the web layer.
@WebMvcTestwith@MockitoBean(06-04) is two orders of magnitude faster. - For trivial queries H2 resolves identically.
@DataJpaTestis still valid for the bulk of the repositories. - Where there is no Docker. In that case, either the stretch is skipped or those tests cannot be run.
The healthy proportion is once again the pyramid of 06-01: hundreds of unit tests, dozens of slices and a handful of container tests covering what only the real engine reveals —migrations, native SQL, sequences, locks, types.
In continuous integration you need a runner with Docker available. The command is the usual one:
And when Docker is missing, the graceful way not to break the build is the assumption from 06-02:
@BeforeAll
static void requiresDocker() {
assumeTrue(DockerClientFactory.instance().isDockerAvailable(),
"Docker not available: container tests are skipped");
}The specific runner configuration —Docker in Docker, image caches, auxiliary services— is the subject of 08-05, Continuous Integration and Delivery.
Debugging. When a container test fails incomprehensibly, the cause is usually in the service's own logs:
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine")
.withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger("postgres")))
.waitingFor(Wait.forListeningPort().withStartupTimeout(Duration.ofSeconds(60)));
// And at any point in the test:
System.out.println(POSTGRES.getLogs());withLogConsumer redirects the container's output into your log —where the syntax error at or near that H2 would never have produced will appear— and waitingFor tunes the wait strategy when a slow agent makes the start-up exceed the default timeout.
Common Mistakes and Tips
Using latest as the image tag. The build works today and fails in three months without anyone having touched a thing. Pin the version, and make it the production one.
An instance @Container instead of a static one. It starts a PostgreSQL for every test method. It is the number one cause of unbearably slow container suites.
Putting @Container on the base class's container. The extension stops it at the end of every subclass and starts it again. Start it in the static block and let Ryuk clean up.
@Transactional on a test that writes over HTTP. The commit happens inside the server and the test's rollback cannot reach it: the data persists and the next test fails. With a MockMvc test that writes, use explicit clean-up.
Assuming the port is 5432. Testcontainers publishes a random one: use getJdbcUrl() or @ServiceConnection, never a hand-written URL.
Leaving Flyway disabled in the container tests. You lose precisely what you came to check. In the integration profile, flyway.enabled: true and ddl-auto: validate.
withReuse(true) in continuous integration. It contaminates successive builds with earlier data and produces intermittent failures that are impossible to reproduce. It is a local convenience, and that is why the switch lives in the developer's HOME.
Tip: one image, one base class, one container. Every *IT test in CicloUrbana inherits from IntegrationTestBase. A single PostgreSQL start-up, a single Spring context and a suite that still takes as long as it should.
Tip: when a test passes with H2 and fails with PostgreSQL, celebrate. You have just found a production bug before it reached Ribalta. That is exactly the job of this lesson.
Exercises
Exercise 1
Create IntegrationTestBase following section 6 and migrate RentalSecurityIT from 06-04 onto it, also changing the profile so that Flyway is applied and ddl-auto is validate. Use the context cache log to check that there is still a single context and that the container starts only once even with several *IT classes. Document the time before and after.
Exercise 2
Write FlywayMigrationsIT with four checks: that six migrations are applied without failures; that V2 leaves the four Ribalta stations with their correct capacities (24, 30, 18, 36); that the index on rentals(started_at) created by V4 exists; and that the uniqueness constraint on stations.name is effective —inserting a duplicate and expecting the exception—. Then deliberately provoke the failure: add a notes field to the Incident entity without writing migration V7, run it and note the exact message it produces.
Exercise 3
A colleague proposes deleting every H2 test and running absolutely everything with Testcontainers, "because that way we test against the real thing". Write a reasoned three-paragraph reply: what is right about the proposal, what concrete problems it would create in CicloUrbana, and what split you recommend, with a list of which kind of test uses which engine and why.
Solutions
Solution 1
The base class is the one from section 6, with the integration profile added; the migrated test looks like this:
@ActiveProfiles("integration")
class RentalSecurityIT extends IntegrationTestBase {
@Autowired private MockMvc mockMvc;
@Test
@WithUserDetails("[email protected]")
void aCitizenCannotFinishSomeoneElsesRental() throws Exception {
mockMvc.perform(post("/api/v1/rentals/9/finish")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"destinationStationId\": 2}"))
.andExpect(status().isForbidden());
}
}One important change the exercise hides: with Flyway active, the test users and rentals are no longer created by Hibernate, but must come from a test-data migration or from an @Sql. It is more work and in exchange you test the same schema Ribalta runs.
Typical figures on a modern laptop, and what they teach:
| Scenario | Containers | Contexts | Time |
|---|---|---|---|
Before: each *IT with its own @Container and annotations |
4 | 4 | ~38 s |
After: IntegrationTestBase |
1 | 1 | ~11 s |
The log should show [size = 1, hitCount = 3, missCount = 1] with four classes, and a single Creating container for image: postgres:16-alpine line in the whole run. If more than one appears, the container is not being shared, and the cause is almost always a forgotten @Container in a subclass or an annotation that changes the cache key.
Solution 2
@ActiveProfiles("integration")
class FlywayMigrationsIT extends IntegrationTestBase {
@Autowired private Flyway flyway;
@Autowired private JdbcTemplate jdbc;
@Test
void appliesTheSixMigrationsWithoutFailures() {
assertThat(flyway.info().applied()).hasSize(6)
.allSatisfy(i -> assertThat(i.getState().isFailed()).isFalse());
}
@Test
void v2LeavesTheFourRibaltaStationsWithTheirCapacity() {
assertThat(jdbc.queryForList(
"select name, capacity from stations order by name"))
.extracting(r -> r.get("name"), r -> r.get("capacity"))
.containsExactly(
tuple("Main Square", 24), tuple("North Station", 30),
tuple("River Park", 18), tuple("University", 36));
}
@Test
void v4CreatesTheIndexOnTheRentalStartDate() {
assertThat(jdbc.queryForList(
"select indexname from pg_indexes where tablename = 'rentals'",
String.class)).contains("idx_rentals_started_at");
}
@Test
void theUniquenessConstraintOnTheStationNameIsEffective() {
assertThatThrownBy(() -> jdbc.update(
"insert into stations (id, name, address, capacity) "
+ "values (99, 'Main Square', 'Another address', 10)"))
.isInstanceOf(DuplicateKeyException.class);
}
}A comment on the fourth one: it checks a guarantee no earlier test could give. That StationService throws DuplicateStationException is an application-level check, and it does not protect against two simultaneous requests that query at the same time and both find the name free. The database's UNIQUE constraint does, and here it is verified. That the exception arriving is a DuplicateKeyException —translated by the @Repository of 02-01— and not a SQLException is another detail that depends on the real engine.
The provoked failure produces, when the context is created, something like:
org.hibernate.tool.schema.spi.SchemaManagementException: Schema-validation: missing column [notes] in table [incidents]
And that message is the result we were after: the error shows up in the build, not in the deployment. With H2 and create-drop, the column would have been created automatically from the entity, the tests would have passed green and the failure would have waited for start-up in Ribalta, where validate does meet the real table.
Solution 3
What is right about it. The substance of the argument is correct and it is the thesis of this lesson: testing against an engine different from production's is testing something else. Anything that depends on the dialect, the types, the sequences, the indexes or the transactional behaviour is only verifiable with PostgreSQL, and a suite that does not include that stretch has a dangerous blind spot, precisely because it is painted green.
What problems it would create. First, the time: replacing every @DataJpaTest with a container test turns a suite of seconds into one of minutes, and a slow suite stops being run during development —the exact mechanism of the ice-cream cone of 06-01. Second, the dependency on Docker: whoever does not have it, or the runner that does not allow it, is left with no tests at all, not just without the integration ones. Third, isolation: with a shared container you have to manage the clean-up in every test, and with one per class the cost goes through the roof. And fourth, more fundamentally: most of CicloUrbana's repository tests verify trivial derived queries that behave the same on any engine; running them against PostgreSQL catches not a single extra failure and costs a hundred times more.
The recommended split.
| Kind of test | Engine | Reason |
|---|---|---|
Unit (FareCalculator, RentalService with mocks) |
None | They do not touch the database |
@WebMvcTest of the API contract |
None | The service is mocked |
@DataJpaTest of derived queries and simple JPQL |
H2, fast | They behave the same on both engines |
Flyway migrations and ddl-auto: validate |
PostgreSQL | It is literally what is being checked |
Native SQL, ILIKE, partial indexes, jsonb |
PostgreSQL | H2 does not support them |
Sequences and allocationSize |
PostgreSQL | Entity-schema consistency only shows up here |
Locks and concurrency (@Lock from 04-07) |
PostgreSQL | H2 does not reproduce the behaviour |
| Full HTTP flow of the critical paths | PostgreSQL | It is the test closest to production |
In one sentence: H2 for what is common to every engine, PostgreSQL for what is specific to PostgreSQL. And a safeguard that makes the debate unnecessary: if a test passes on H2 and its container equivalent fails, the right answer is not to argue, it is to move that test into the container stretch, because it has just proved it belongs there.
Conclusion
Module 6 closes with CicloUrbana tested from top to bottom. You know why H2 is not enough —dialect, types, functions, partial indexes, sequences, locks, error messages— and, above all, why its danger is not that it fails, but that it passes green while the code would fail in Ribalta. You know Testcontainers from the inside: the Docker daemon, the ephemeral container with its random port, the wait strategy and the Ryuk watchdog that cleans up if the JVM dies. You know how to declare the dependencies with the BOM, write the first test with @Testcontainers and @Container, and connect the data source both possible ways, with a clear preference for @ServiceConnection and @DynamicPropertySource reserved for what is not a service connection. You have mastered the pattern that decides the performance of the whole stretch —a static container started in the IntegrationTestBase base class, one start-up and one Spring context— and you know when withReuse(true) helps locally and why it must never be switched on in continuous integration.
You have turned the project's last zone of faith into assertions: migrations V1…V6 applied over a clean PostgreSQL, the four stations loaded by V2, the partial index of V3 that H2 cannot create, the UNIQUE constraint that no application-level check can replace and, most valuable of all, ddl-auto: validate failing the build when an entity drifts out of step with the schema —the failure that used to wait for the deployment. You have tested the native queries of 04-06 and the consistency between the entity's allocationSize and the sequence's INCREMENT BY. You know how to isolate data by choosing between rollback, explicit clean-up and a container per class, and why @Transactional is no use when the write happens over HTTP. And you have discovered that the same containers start the application in development with ./mvnw spring-boot:test-run, so that whoever clones CicloUrbana can work without installing a database, with spring-boot-docker-compose as an alternative and with Redis, WireMock, Kafka and LocalStack waiting their turn in the modules ahead.
Look at what has changed since the start of the module. CicloUrbana went from having a single empty test generated by Initializr to a pyramid-shaped suite: hundreds of unit tests with JUnit 5 and AssertJ that pin Ribalta's fares to the cent and run in seconds; Mockito doubles that force the flat battery, the full station and the repository outage; slices that verify every status code, every ProblemDetail and every field of the public JSON; the complete access matrix of module 5 written as a table where Marta gets her 403 on touching somebody else's rental and her 200 on finishing her own; and a final stretch against a real PostgreSQL 16 guaranteeing that the schema, the queries and the migrations behave as they do in production. The question the module opened with —"how do we know it works?"— has at last an answer that does not depend on anyone's memory: ./mvnw verify.
And yet CicloUrbana is still not ready to live in the world. It is correct, but it is not operable. Nobody can ask it whether it is healthy before sending it traffic, nor whether the database is responding, nor which version is deployed. It does not tell environments apart: the same configuration serves a developer's laptop and the council's server, with the same log level and the same secrets. It runs nothing on its own: nobody expires the rentals forgotten overnight, nobody recalculates station occupancy every minute, and the confirmation email is sent on the very thread serving the request, keeping the citizen waiting. And it is still a JAR that has to be started by hand on a machine with Java 21 installed. Module 7, Advanced Spring Boot Features, solves all of that: Actuator and its health probes, the profiles that separate development from production, scheduled tasks and asynchronous execution, packaging into Docker with layered images, and the doorway to microservices and fault-tolerant communication between services. The Ribalta network works and it is proven; now it has to be genuinely put into service.
Spring Boot Course
Module 1: Introduction to Spring Boot
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
