Testcontainers for PostgreSQL, MySQL and Oracle testing

Testcontainers gives an integration test a real database engine without asking teams to maintain a permanent server. A Docker image starts PostgreSQL, MySQL or Oracle in isolation, Testcontainers waits for readiness, and the test connects through a mapped port. The container is discarded afterwards, so one suite cannot leave data or schema changes for the next.

That makes database testing practical in continuous delivery. Tests can exercise migrations, constraints and vendor-specific SQL beside unit tests while remaining repeatable on laptops and CI workers. It suits Australia's mixed market, where engineering teams in Sydney, Melbourne, Brisbane and Perth use local Docker alongside AWS, Azure or Google Cloud estates hosted in Australian regions.

Why disposable databases fit modern delivery

Shared test databases create queues, manual resets and hidden dependencies on schema state. They also make parallel branches fragile because two builds may use the same tables. A disposable container gives each test class or worker its own engine, and a failed test can be reproduced with the same image and configuration.

This is especially useful for codebases that support more than one database. A PostgreSQL path can validate JSONB, transactions and extension behaviour; MySQL can reveal differences in locking, collation or SQL modes; Oracle can prove that PL/SQL, drivers and enterprise schemas still work. Mocking JDBC may cover repository branching, but it cannot expose dialect errors or constraint problems that appear only in a real engine.

Set up a reliable container foundation

Testcontainers needs a Docker-compatible daemon. Local developers can use Docker Desktop or Docker Engine, while CI needs a runner with access to the daemon and permission to create containers and mapped ports. On macOS, verify that the Docker context points to the intended host before a suite starts, because Testcontainers can otherwise reach a remote engine unexpectedly.

Add the database modules alongside JUnit 5, using one pinned Testcontainers version through dependency management. The module coordinates are org.testcontainers:postgresql, org.testcontainers:mysql and org.testcontainers:oracle; org.testcontainers:junit-jupiter supplies the test lifecycle integration.

<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>postgresql</artifactId>
  <version>${testcontainers.version}</version>
  <scope>test</scope>
</dependency>

Treat testcontainers.version as a managed property rather than changing it independently for each module. Keep the database JDBC driver at test scope when it is used only by tests. JUnit's @Testcontainers and @Container annotations start the instance before the class and stop it afterwards; Testcontainers' Ryuk helper removes anything left behind after an abrupt failure. Container reuse should remain a deliberate local developer convenience, since a reused instance may retain previous data.

Run PostgreSQL and MySQL tests

For PostgreSQL, select the production-compatible major version and let the container own the temporary database. A static container gives every test method in the class the same instance without sharing it with other classes.

@Testcontainers
class OrderRepositoryIT {
    @Container
    static final PostgreSQLContainer<?> POSTGRES =
        new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("orders")
            .withUsername("app")
            .withPassword("test-only");

    @Test
    void storesAndReadsOrders() {
        // Use POSTGRES.getJdbcUrl() with the PostgreSQL driver.
    }
}

For MySQL, the equivalent container exposes a JDBC URL and scopes credentials to the test run. Use the MySQL 8.4 line when that is what production runs, since older tags can hide authentication and SQL-mode changes.

@Container
static final MySQLContainer<?> MYSQL =
    new MySQLContainer<>("mysql:8.4")
        .withDatabaseName("orders")
        .withUsername("app")
        .withPassword("test-only");

Run the same migration set against each supported engine, then test the behaviours that matter to the application. Constraints, indexes and transaction boundaries often differ even when the schemas look identical. Within a class, use transaction rollback or explicit truncation where practical so individual cases remain independent. A Testcontainers suite earns its place by asserting product behaviour rather than merely confirming that a connection succeeds.

Add Oracle without breaking the workflow

Oracle needs more care than the open-source engines. A community image such as gvenzl/oracle-xe:21-slim-faststart is convenient, while Oracle's own database images may require registry authentication and acceptance of licence terms. Check the image's architecture, supported Oracle release and usage rights for your organisation. Australian enterprises often have Oracle procurement rules, so a developer-friendly image must still pass legal and security reviews.

Use the dedicated Oracle container when the test needs precise control over startup or service naming.

@Container
static final OracleContainer ORACLE =
    new OracleContainer("gvenzl/oracle-xe:21-slim-faststart")
        .withPassword("test-only");

OracleContainer#getJdbcUrl() returns a Thin-driver URL for the mapped listener. Oracle starts more slowly and consumes more memory than PostgreSQL or MySQL, and its initialisation scripts have different responsibilities: creating users, granting privileges and selecting a pluggable database are separate concerns. Keep those steps in version-controlled SQL and execute them with the test's normal migration or setup mechanism.

Oracle images can also be architecture-sensitive. Docker Desktop on Apple silicon may use x86 emulation, which works for some images but adds time and can fail when a required binary or kernel feature is unavailable. Check the image manifest before relying on emulation, or run this database suite on an x86 CI worker.

Keep behaviour deterministic across machines

Isolation is only half the job. Every container should use the same database version, locale, collation and time zone as the test expects. For MySQL, set the session time zone explicitly and consider a fixed collation; for PostgreSQL, set the session zone to UTC; for Oracle, use ALTER SESSION SET TIME_ZONE = 'UTC'. Avoid relying on the host's regional defaults.

This matters across Australia. A developer in Brisbane may run on AEST while a Melbourne runner observes AEDT during daylight-saving transitions. UTC-based assertions keep midnight boundaries, timestamp comparisons and scheduled-job tests stable throughout the year. Locale differences can also change sorting, case conversion and identifier behaviour, so avoid comparing formatted date strings.

Pin image tags and, for strict reproducibility, digests. Keep a small local image cache on CI so the first build does not spend its time downloading gigabytes. Log image names, startup duration and mapped ports when a test fails; that gives an engineer enough evidence to reproduce the environment. It is also worth checking the build is green locally before blaming a remote runner, a phrase Australian delivery teams often use when they say "the build's green".

Compare engines and retain useful patterns

The right container is the one that matches the database contract under test, including its release line, extensions, SQL modes and transaction semantics. PostgreSQL usually starts fastest and is the easiest default for service development. MySQL is essential where product compatibility or managed-cloud parity matters. Oracle earns its higher cost when Oracle-specific code, schemas or support obligations are part of the release.

Container target Strongest use Startup and footprint Main caution
PostgreSQL JSONB, extensions and strict transactional SQL Fast and relatively small Alpine and Debian builds can expose different extension packages
MySQL MySQL 8 compatibility, locking and SQL modes Moderate Authentication and collation behaviour vary by release
Oracle PL/SQL, Oracle modes and enterprise schemas Slowest and memory-heavy Image entitlement, platform and licence review

For image selection, keep the decision tied to what production supports rather than what happens to start fastest:

Before a database suite becomes a required CI check, make these outcomes observable:

Carry the workflow into CI

A CI runner must provide a Docker-compatible daemon, stable clocks, enough memory for Oracle and permission for Testcontainers to manage its helper container. Many teams use a dedicated VM or self-hosted runner for that reason; a serverless build sandbox may not expose a usable socket. In Australian cloud regions, choose the nearest approved region for latency, but keep the database container inside the same job boundary and meet the organisation's data-residency rules.

Keep credentials test-only, scan approved database images and avoid reusing containers across pipeline runs. A reusable instance can hide migration leakage, which is precisely the problem disposable databases solve. For this release, the concrete next step is to add one PostgreSQL-backed repository test and run mvn verify locally before the existing CI job executes the same Maven goal.