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

  1. The problem with testing against H2
  2. What Testcontainers is and how it works
  3. Dependencies
  4. The first test with @Container
  5. @DynamicPropertySource versus @ServiceConnection
  6. A shared container: IntegrationTestBase
  7. Reuse with withReuse
  8. Verifying Flyway for real
  9. The queries H2 does not support
  10. Isolating data between tests
  11. Testcontainers in local development
  12. Other useful containers
  13. Cost, continuous integration and debugging
  14. Common Mistakes and Tips
  15. Exercises

  1. 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.

  1. 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.

  1. 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.

  1. The first test with @Container

package 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:

  • @Testcontainers is the JUnit 5 extension that starts and stops the @Container fields.
  • static changes the lifecycle completely: a static @Container field 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. Using latest guarantees that one day the build breaks on its own.
  • The IT suffix makes Failsafe run it under ./mvnw verify rather than Surefire under ./mvnw test (06-01). That is what keeps the short cycle down to seconds.

  1. @DynamicPropertySource versus @ServiceConnection

The 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.

  1. A shared container: IntegrationTestBase

One 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.

  1. Reuse with withReuse

You 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=true

The 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=true in 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.

  1. 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");
}

  1. 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.

  1. 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:

  • @Transactional by default in tests that only read or whose writing does not involve transactions of its own. That is what @DataJpaTest does and it works well.
  • Explicit clean-up in the full-flow tests over HTTP, because there the commit happens inside the server and the test's rollback cannot reach it. It is a classic mistake: putting @Transactional on a MockMvc test 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.

  1. 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);
    }
}
./mvnw spring-boot:test-run

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.

  1. 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.

  1. 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. FareCalculator with a container would be absurd.
  • To test the web layer. @WebMvcTest with @MockitoBean (06-04) is two orders of magnitude faster.
  • For trivial queries H2 resolves identically. @DataJpaTest is 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:

./mvnw verify      # Surefire runs *Test; Failsafe, the *IT with the container

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

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved