Integration tests in Java: speeding up Testcontainers with tmpfs and pre-initialization

Introduction

Testcontainers is a Java library that manages Docker containers straight from your test code. While the tests run, it spins up whatever container you need – a database, a message broker, a search engine, and so on – and tears it down once the tests finish.

Why bother? Because it lets you run integration tests against real software instead of in-memory emulators. Your tests exercise the same engine you ship to production.

In this article I’ll walk through two ways to make working with Testcontainers faster:

  1. tmpfs – moving files into RAM.
  2. Pre-initialization – moving the expensive initialization into a separate Docker image.

There’s plenty written online about the first approach, but the second one is barely covered anywhere, and the particular technique I landed on isn’t documented at all.

I’ll use a MySQL container as the running example, but everything here applies equally to any other service.

Part 1. Testcontainers and tmpfs

A plain MySQL test

Dependencies:

dependencies {
    testImplementation platform("org.testcontainers:testcontainers-bom:2.0.4")
    testImplementation "org.testcontainers:testcontainers-mysql"
    testImplementation "com.mysql:mysql-connector-j:9.6.0"
    testImplementation "org.testcontainers:testcontainers-junit-jupiter"
    testImplementation "org.junit.jupiter:junit-jupiter"
}Code language: Groovy (groovy)

We bring up a MySQL container, pass in the database name and credentials, feed it a couple of initialization scripts, and start it:

MySQLContainer container = new MySQLContainer(DockerImageName.parse("mysql:8.0.45"));
container.withDatabaseName("testdb")
    .withUsername("user")
    .withPassword("password")
    .withInitScripts("mysql/init.tables.sql", "mysql/init.data.sql");
container.start();Code language: Java (java)

After that, the tests themselves run.

I won’t dig into how long the test cases take to execute – there’s no shortage of articles on that. Let’s focus on initialization instead.

Where the time goes

If we sketch out the path from calling start() to a ready container, it looks roughly like this:

container.start() Pull Docker image Start DBMS process Initialize data Ready

After the first docker pull the image already sits on disk locally, so we can leave that step out of the picture. The bulk of the time goes into two other stages: starting the DBMS process inside the container, and initializing the data. The first barely depends on the size of the database but still takes a while – mysqld boots up, initializes its system directories, and only then opens the port. The second scales with the size of the database: lots of tables, indexes, bulky test data, and a stack of Flyway/Liquibase migrations all drag the startup out.

tmpfs: the first step

The simplest thing you can do without touching any logic is to put the DBMS data directory on tmpfs – that is, in RAM:

container.withTmpFs(Map.of("/var/lib/mysql", "rw"));Code language: Java (java)

Table creation and the rest of the I/O start running noticeably faster.

Measurements

Here are timings for container.start() under two workloads:

  • empty DB – a clean start with no data;
  • 100 tables – 100 tables of 20 rows each, i.e. 2,000 INSERTs plus the DDL.

Median container.start() time, in milliseconds.

MySQL: with and without tmpfs

ScenarioTestcontainers, msTestcontainers + tmpfs, ms
Empty DB10,5138,593
100 tables28,43713,613

On an empty database the win is modest – we shave off a couple of seconds at process startup. But under heavy initialization the difference is already twofold: from ~28.4 seconds down to ~13.6. The effect is obvious. That said, 13.6 seconds for a single test run is still an awful lot, especially when the test itself finishes in tens of milliseconds.

Part 2. Preinitialized containers

The idea: initialize once

The key observation: if initialization is deterministic (the same scripts, the same credentials, and the same Docker image always produce the same result), then there’s no point repeating it on every start. It’s enough to do it once:

  1. bring up a temporary container;
  2. run the full initialization inside it – DDL, migrations, seed it with test data;
  3. turn the prepared container into a Docker image via docker commit;
  4. use that prepared image in all subsequent tests.

The expensive initialization runs once, when the image is built, instead of on every start(). That’s what pre-initialization is.

In practice it breaks down into two phases:

PhaseWhenWhat happens
Docker image buildFirst runThe service starts on tmpfs and runs through initialization. When the container stops, the files are saved to a separate directory (not tmpfs), and then the Docker image is built.
Test startEvery start()On startup the files are restored into tmpfs, after which the DBMS process launches. The initialization scripts are not run again.

This is achieved with a custom entrypoint script in place of the native one: on the first run it performs the initialization on tmpfs and saves the files into the image when the container stops; on later runs it restores the files into tmpfs and hands off to the native entrypoint.

The preinit-testcontainers library

I packaged all of this logic into a library called preinit-testcontainers.

The library is modular. It ships several modules, including preinit-testcontainers-mysql for MySQL. There are also modules for other services: ClickHouse, PostgreSQL, and Redis. The library is general-purpose and can be used to run any other container, not just the ones listed above.

Importing the MySQL module:

testImplementation "com.sviat-tech:preinit-testcontainers-mysql:2.0.1"Code language: Groovy (groovy)

Creating a container looks like this:

import com.sviattech.preinittestcontainers.PreInitStartCallback;
import com.sviattech.preinittestcontainers.mysql.CreateMySQLContainerCommand;
import com.sviattech.preinittestcontainers.mysql.MySQLContainerFactory;
import org.testcontainers.mysql.MySQLContainer;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.Statement;
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(
        "mysql-callback-seed-v1",
        container -> {
            MySQLContainer mysql = (MySQLContainer) container;
            try (Connection connection = DriverManager.getConnection(
                    mysql.getJdbcUrl(), "user", "password");
                 Statement statement = connection.createStatement()) {
                statement.execute("INSERT INTO users (id, name) VALUES (999, 'from-callback')");
            }
        }))
    .build();

try (MySQLContainer container = MySQLContainerFactory.createMySQLContainer(command)) {
    container.start();
    // assertions, JDBC, Spring Data...
}Code language: Java (java)

The numbers

There are two distinct stages here:

  • the first run – building the prepared image;
  • a repeat start() – an ordinary start once the image has been built.

Let’s look at the repeat start first, since that’s what the whole exercise was about.

Testcontainers + tmpfs vs. Preinit + tmpfs (repeat start)

ScenarioTestcontainers + tmpfs, msPreinit + tmpfs (repeat start), ms
100 tables13,6131,389
Empty DB8,5931,445

For MySQL with 100 tables, startup time drops from ~13.6 to ~1.4 seconds – roughly 10x. On an empty DB the effect is significant too (~6x).

Preinit + tmpfs (first start) – the one-time cost

The first run with pre-initialization takes longer: you have to build the image before you can start the container. Here’s the build + first start time, in milliseconds:

ScenarioMySQL, ms
100 tables15,174
Empty DB9,967

The build phase on its own, for MySQL with 100 tables, is ~13.8 s. That’s comparable to Testcontainers + tmpfs + initialization.

The payoff is clear: the first run costs ~15 seconds, but after that every start takes ~1.4 seconds instead of ~13.6.

A quick recap of how startup time changed:

  • a plain container with initialization scripts – tens of seconds;
  • tmpfs cuts that roughly in half;
  • pre-initialization adds a one-time image build (~15 s);
  • the repeat test start – about 1.4 seconds.

Measurements across different databases

Median container.start() time, in milliseconds. Two workloads: empty DB and 100 tables (100 tables x 20 rows).

ScenarioModeMySQL, msPostgreSQL, msClickHouse, ms
Empty DBTestcontainers10,5131,5085,486
Empty DBTestcontainers + tmpfs8,5931,3255,576
Empty DBPreinit + tmpfs (first start)9,9671,6967,974
Empty DBPreinit + tmpfs (repeat start)1,4454512,388
100 tablesTestcontainers28,43715,06829,526
100 tablesTestcontainers + tmpfs13,6133,66314,256
100 tablesPreinit + tmpfs (first start)15,1744,33717,232
100 tablesPreinit + tmpfs (repeat start)1,3895513,403

Conclusion

In summary:

  1. tmpfs speeds the container up, but it doesn’t remove the repeated initialization.
  2. Pre-initialization removes the main recurring problem – initialization on every start – by doing it just once, on the first run.

For MySQL under the heavy workload, the difference between Testcontainers + tmpfs and preinit + tmpfs came out to roughly 13.6 s vs. 1.4 s. That’s an order-of-magnitude reduction in initialization time.

Source code and examples:

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top