Faster Testcontainers integration tests by baking initialization into local Docker images.
Repository: github.com/sviatoslav1989/preinit-testcontainers
Blog article: Integration tests in Java: speeding up Testcontainers with tmpfs and pre-initialization
Contents: What and why · How it works · Prerequisites · Installation · Quick start · Tips · Performance · Internals · Extension points · Examples
Testcontainers starts fresh containers on every test run. Each cold start pays a high cost: image pull, process boot, and initialization (SQL scripts, migrations, custom setup).
preinit-testcontainers addresses initialization by:
- On first use, starting a temporary container, running your init (JDBC scripts,
PreInitStartCallback, etc.), then committing a local end image with a deterministic name (hash of config). - On later starts, using that baked image instead of cold-starting from upstream.
- Using a bundled entrypoint (
testcontainer-entrypoint.sh) with tmpfs snapshot/restore so mutable data dirs (e.g. MySQL/var/lib/mysql) stay fast while reflecting pre-baked state. - Using cross-process file locking so parallel test workers do not rebuild the same image twice.
When to use it:
- Integration tests with heavy DB bootstrap (SQL init scripts, custom server flags).
- Suites where container startup dominates CI time.
- You already use Testcontainers 2.x and Docker locally/CI.
When not to: Ephemeral one-off containers with no init cost, or environments without Docker commit support.
- Build a
Create*ContainerCommand(immutable builder). - First run: factory builds/commits the end image (one-time cost).
- Test run: container starts from the end image + tmpfs restore.
- Same API as stock Testcontainers (
start(), JDBC URL, JUnit extension).
Preinit replaces the image entrypoint with testcontainer-entrypoint.sh. To delegate correctly to the upstream image and mount tmpfs on the right data dirs, the factory needs image metadata (ENTRYPOINT, CMD, VOLUMES). DB modules ship this automatically; custom images may need withMetadata(...) — see Image metadata.
sequenceDiagram
participant Test
participant Factory as Container factory
participant Docker
Test->>Factory: createMySQLContainer(command)
alt End image not cached
Factory->>Docker: Start temp container
Factory->>Docker: Run init scripts
Factory->>Docker: Commit end image
else End image cached
Note over Factory: Skip one-time build
end
Factory->>Docker: Start from end image
Note over Docker: tmpfs restore
Docker-->>Factory: Container ready
Factory-->>Test: MySQLContainer
Test->>Test: Run assertions
- Docker — daemon reachable; image commit supported.
- Java 8+ — published artifacts target Java 8 bytecode.
- Testcontainers 2.x — library targets
[2.0.0, 3.0.0); align consumer BOM to2.0.4+for Docker 29+. - JUnit 5 — typical; not strictly required.
Coordinates: com.sviat-tech / <version> (any dotted release, e.g. 2.0, 2.0.1, 2.0.1.0).
Artifacts are published to Maven Central. Use test / testImplementation scope in normal apps (src/main + src/test). The examples use implementation only because those modules are test-only sample projects.
| Artifact | Use when | Extra test dependency |
|---|---|---|
preinit-testcontainers |
Generic images / custom containers | — |
preinit-testcontainers-jdbc |
Custom JDBC-backed modules | your JDBC driver |
preinit-testcontainers-mysql |
MySQL (testcontainers-mysql) |
com.mysql:mysql-connector-j:9.6.0 |
preinit-testcontainers-mariadb |
MariaDB (testcontainers-mariadb) |
org.mariadb.jdbc:mariadb-java-client:3.5.3 |
preinit-testcontainers-postgresql |
PostgreSQL | org.postgresql:postgresql:42.7.5 |
preinit-testcontainers-clickhouse |
ClickHouse | com.clickhouse:clickhouse-jdbc:0.9.8 |
preinit-testcontainers-redis |
Redis (com.redis:testcontainers-redis) |
— |
Each DB module pulls its Testcontainers counterpart transitively via api.
Import the Testcontainers BOM once, then add one module below (plus testcontainers-junit-jupiter and junit-jupiter for every example).
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>2.0.4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement><dependencies>
<dependency>
<groupId>com.sviat-tech</groupId>
<artifactId>preinit-testcontainers</artifactId>
<version>2.0.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies><dependencies>
<dependency>
<groupId>com.sviat-tech</groupId>
<artifactId>preinit-testcontainers-jdbc</artifactId>
<version>2.0.1</version>
<scope>test</scope>
</dependency>
<!-- your JDBC driver -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies><dependencies>
<dependency>
<groupId>com.sviat-tech</groupId>
<artifactId>preinit-testcontainers-mysql</artifactId>
<version>2.0.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>9.6.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies><dependencies>
<dependency>
<groupId>com.sviat-tech</groupId>
<artifactId>preinit-testcontainers-mariadb</artifactId>
<version>2.0.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<version>3.5.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies><dependencies>
<dependency>
<groupId>com.sviat-tech</groupId>
<artifactId>preinit-testcontainers-postgresql</artifactId>
<version>2.0.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.5</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies><dependencies>
<dependency>
<groupId>com.sviat-tech</groupId>
<artifactId>preinit-testcontainers-clickhouse</artifactId>
<version>2.0.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.clickhouse</groupId>
<artifactId>clickhouse-jdbc</artifactId>
<version>0.9.8</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies><dependencies>
<dependency>
<groupId>com.sviat-tech</groupId>
<artifactId>preinit-testcontainers-redis</artifactId>
<version>2.0.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>repositories {
mavenCentral()
}
dependencies {
testImplementation platform("org.testcontainers:testcontainers-bom:2.0.4")
testImplementation "com.sviat-tech:preinit-testcontainers:2.0.1"
testImplementation "org.testcontainers:testcontainers-junit-jupiter"
testImplementation "org.junit.jupiter:junit-jupiter"
}repositories {
mavenCentral()
}
dependencies {
testImplementation platform("org.testcontainers:testcontainers-bom:2.0.4")
testImplementation "com.sviat-tech:preinit-testcontainers-jdbc:2.0.1"
// your JDBC driver
testImplementation "org.testcontainers:testcontainers-junit-jupiter"
testImplementation "org.junit.jupiter:junit-jupiter"
}repositories {
mavenCentral()
}
dependencies {
testImplementation platform("org.testcontainers:testcontainers-bom:2.0.4")
testImplementation "com.sviat-tech:preinit-testcontainers-mysql:2.0.1"
testImplementation "com.mysql:mysql-connector-j:9.6.0"
testImplementation "org.testcontainers:testcontainers-junit-jupiter"
testImplementation "org.junit.jupiter:junit-jupiter"
}repositories {
mavenCentral()
}
dependencies {
testImplementation platform("org.testcontainers:testcontainers-bom:2.0.4")
testImplementation "com.sviat-tech:preinit-testcontainers-mariadb:2.0.1"
testImplementation "org.mariadb.jdbc:mariadb-java-client:3.5.3"
testImplementation "org.testcontainers:testcontainers-junit-jupiter"
testImplementation "org.junit.jupiter:junit-jupiter"
}repositories {
mavenCentral()
}
dependencies {
testImplementation platform("org.testcontainers:testcontainers-bom:2.0.4")
testImplementation "com.sviat-tech:preinit-testcontainers-postgresql:2.0.1"
testImplementation "org.postgresql:postgresql:42.7.5"
testImplementation "org.testcontainers:testcontainers-junit-jupiter"
testImplementation "org.junit.jupiter:junit-jupiter"
}repositories {
mavenCentral()
}
dependencies {
testImplementation platform("org.testcontainers:testcontainers-bom:2.0.4")
testImplementation "com.sviat-tech:preinit-testcontainers-clickhouse:2.0.1"
testImplementation "com.clickhouse:clickhouse-jdbc:0.9.8"
testImplementation "org.testcontainers:testcontainers-junit-jupiter"
testImplementation "org.junit.jupiter:junit-jupiter"
}repositories {
mavenCentral()
}
dependencies {
testImplementation platform("org.testcontainers:testcontainers-bom:2.0.4")
testImplementation "com.sviat-tech:preinit-testcontainers-redis:2.0.1"
testImplementation "org.testcontainers:testcontainers-junit-jupiter"
testImplementation "org.junit.jupiter:junit-jupiter"
}preInitializeddefaults totrueon commands.- First start may be slow (image build); later starts reuse the end image.
- Init scripts live on the test classpath (e.g.
src/test/resources/).
import com.sviattech.preinittestcontainers.PreInitStartCallback;
import com.sviattech.preinittestcontainers.mysql.CreateMySQLContainerCommand;
import com.sviattech.preinittestcontainers.mysql.MySQLContainerFactory;
import org.testcontainers.mysql.MySQLContainer;
import java.util.List;
CreateMySQLContainerCommand command = CreateMySQLContainerCommand.builder()
.withBaseImageName("mysql:8.0.45")
.withInitScripts(List.of("mysql/init.tables.sql", "mysql/init.data.sql"))
.withDbName("testdb")
.withUsername("user")
.withPassword("password")
.withAfterPreInitStartCallback(PreInitStartCallback.of(
"example-callback-v1",
container -> {
// Custom init during image build (not at test runtime).
}))
.build();
try (MySQLContainer container = MySQLContainerFactory.createMySQLContainer(command)) {
container.start();
// container.getJdbcUrl(), etc.
}Use the core preinit-testcontainers artifact with GenericContainerFactory.createGenericContainer() for images without a bundled module:
testImplementation "com.sviat-tech:preinit-testcontainers:2.0.1"
testImplementation "org.testcontainers:testcontainers-junit-jupiter"import com.sviattech.preinittestcontainers.CreateGenericContainerCommand;
import com.sviattech.preinittestcontainers.GenericContainerFactory;
import com.sviattech.preinittestcontainers.PreInitStartCallback;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
CreateGenericContainerCommand command = CreateGenericContainerCommand.builder()
.withBaseImageName("mongo:7.0")
.withExposedPorts(27017)
.waitingFor(Wait.forListeningPort())
.withAfterPreInitStartCallback(PreInitStartCallback.of(
"mongo-seed-v1",
container -> {
container.execInContainer("mongosh", "--eval",
"db.getSiblingDB('testdb').users.insertOne({name: 'alice'})");
}))
.build();
try (GenericContainer<?> container = GenericContainerFactory.createGenericContainer(command)) {
container.start();
// container.getHost(), container.getMappedPort(27017)
}- Image metadata for
mongois resolved via docker inspect fallback (no bundled.metadatafile in core).
- Single-test runs: Preinit is especially helpful when you run one test (e.g. from your IDE) — container startup often dominates runtime, and preinit reduces
start()time significantly once the end image exists (see Performance). - First-run cost: The initial start builds and commits the end image. Steady-state
start()times match the Performance "Preinit" rows. - Parallel CI: Cross-process locking via
ImageCreationLockServiceis built-in; tune viaImageCreationLockOptionif needed (see Extension points). - Disable pre-init:
.withPreInitialized(false)for stock Testcontainers behavior. - Spring Boot: See examples/spring-boot-mysql (BOM import, exclude Testcontainers from
spring-boot-starter-test). - Building from source:
./gradlew build(Java 21 toolchain for dev; published JAR is Java 8).
Startup time for container.start() until ready (measured by TimedContainerStart). Workload: 100 tables with 20 rows per table (2,000 inserts), or empty DB. Five repetitions per database; median reported below. Results from this repo's dev setup (WSL2/Docker); absolute numbers vary — relative speedups are the takeaway.
All scenarios use tmpfs on the DB data directory, comparing vanilla Testcontainers (init at startup) vs preinit (pre-baked init + tmpfs restore).
| Scenario | MySQL | MariaDB | PostgreSQL | ClickHouse |
|---|---|---|---|---|
| Vanilla + tmpfs, 100 tables | 13,613 | 8,515 | 3,663 | 14,256 |
| Preinit + tmpfs, 100 tables | 1,389 | 1,952 | 551 | 3,403 |
| Vanilla + tmpfs, empty | 8,593 | 5,299 | 1,325 | 5,576 |
| Preinit + tmpfs, empty | 1,445 | 2,088 | 451 | 2,388 |
Images: mysql:8.0.45 (/var/lib/mysql), mariadb:12.2 (/var/lib/mysql), postgres:17 (/var/lib/postgresql/data), clickhouse/clickhouse-server:26.3.4.11 (/var/lib/clickhouse).
Speedups (preinit vs vanilla + tmpfs):
- 100 tables: ~10× MySQL, ~4× MariaDB, ~7× PostgreSQL, ~4× ClickHouse.
- Empty: ~6× MySQL, ~2.5× MariaDB, ~3× PostgreSQL, ~2× ClickHouse.
Init-heavy workloads show the largest gains; empty DB still benefits from pre-baked image + tmpfs restore.
The following sections describe what happens under create*Container() — useful for debugging, custom images, and module authors. If you use a bundled DB module with a supported image tag, Quick start and Tips are usually enough.
Why this exists: Preinit wraps the upstream image entrypoint with testcontainer-entrypoint.sh and mounts tmpfs on data-directory VOLUMES for snapshot/restore (see How it works). The factory needs each image's ENTRYPOINT, CMD, and VOLUMES to wire that correctly. You can skip this if you use a bundled DB module and a supported image tag.
This is upstream Docker image invocation data — not Maven or project metadata.
The value type is ContainerMetadata: entrypoint, entrypointPath, cmd, and volumes. getTmpFs() derives default tmpfs mounts from volumes (e.g. /var/lib/mysql for MySQL).
Database modules ship version-ranged properties files on the classpath, e.g. mysql.metadata at preinit-testcontainers/metadata/{repo-last-segment}.metadata (mysql:8.0.45 → mysql.metadata).
record.0.startVersion=5.5
record.0.endVersion=9.7
record.0.entrypointPath=/usr/local/bin/docker-entrypoint.sh
record.0.entrypoint=docker-entrypoint.sh
record.0.cmd=mysqld
record.0.volumes=/var/lib/mysqlGenericContainerFactory.resolveMetadata picks metadata in this order:
- Explicit
withMetadata(...)on the command ContainerMetadataRegistry.find— defaultFileBasedContainerMetadataRegistryloads bundled.metadatafilesDockerImageMetadataInspector.inspect— livedocker inspectwhen no bundled file matches
flowchart TD
cmd["CreateContainer<br/>Command"]
cmd --> q1{"Explicit<br/>metadata?"}
q1 -->|Yes| meta["Container<br/>Metadata"]
q1 -->|No| reg["Metadata registry<br/>.find()"]
reg --> q2{"Bundled<br/>.metadata?"}
q2 -->|Yes| meta
q2 -->|No| inspect["Docker<br/>inspect"]
inspect --> meta
meta --> wrap["Wrap entrypoint<br/>+ tmpfs"]
Version matching (MetadataFile.resolve): latest or empty tag → highest endVersion record; in-range tag → matching record; tag newer than max → max record; tag older than min → inspect fallback.
For custom or unsupported images without a bundled .metadata file, set metadata explicitly via CreateContainerCommandBuilder.withMetadata.
The committed local image name is deterministic so identical configuration reuses the cache. Any input that changes image contents must affect the hash.
Resolution (GenericContainerFactory.createPreinitialized):
- Explicit
withEndImageName(...)wins - Otherwise
ContainerEndImageNameCalculator.calculate— defaultGenericContainerEndImageNameCalculator
String inputs (in order): cmdParameters, environment variables (key=value, keys sorted), privileged=..., callback.uniqueKey() when a PreInitStartCallback is set.
File inputs (in order): docker/testcontainer-entrypoint.sh, then each classpathResourceMapping path — for each path, hash path bytes plus raw classpath file bytes.
Digest: MD5 over concatenated inputs (no delimiters between string params; null → literal "null").
Format: {prefix}-{baseImageName}.{first8HexChars} — e.g. test-mysql:8.0.45.a1b2c3d4 (prefix "test").
JdbcEndImageNameCalculator extends the default: prefix = dbName; extra string hash of dbName, username, password; extra file hash of initScripts.
Changing init scripts, env vars, credentials, or classpath mappings produces a new image name. preInitialized=false does not affect the hash (stock path skips commit).
| Interface | When to use |
|---|---|
ContainerFactory |
Entry point: create(command) |
CreateContainerCommand |
Read-side command contract |
CreateContainerCommandBuilder |
Fluent builder (withBaseImageName, withMetadata, withEndImageName, …) |
PreInitStartCallback |
Custom init during image build |
ContainerEndImageNameCalculator |
Custom hashing for new container modules |
ContainerMetadataRegistry |
Custom metadata lookup |
DockerImageMetadataInspector |
Replace live Docker inspect fallback |
ImageCreationLockService |
Customize cross-process build locking |
Module extension is typically done by subclassing GenericContainerFactory and GenericContainerEndImageNameCalculator rather than adding new interfaces under modules/. For image metadata details, see Image metadata.
Runnable examples live under examples/. From that directory:
./gradlew :example-preinit-testcontainers:test
./gradlew :example-preinit-testcontainers-mysql:test
./gradlew :example-preinit-testcontainers-mariadb:testLicensed under MIT.