eugene-khyst / podman-testcontainers

The example of using Podman with Testcontainers in Java projects, that use Gradle on Ubuntu Linux and MacOS (both x86_64 and Apple silicon).
Apache License 2.0
41 stars 6 forks source link
apple-silicon docker java junit linux mac-m1 macos podman podman-machine testcontainers testcontainers-junit-5

Testcontainers with Podman

What is Testcontainers

Testcontainers is a Java library that supports JUnit tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.

With Testcontainers, your JUnit tests can use PostgreSQL to run in a Docker container instead of an embedded H2 Database.

Why replace Docker with Podman?

Docker changed the Docker Desktop terms in 2021. Docker Desktop is not free for everyone anymore:

On Linux you can still use the Docker CLI and Docker Engine for free. On Windows you could install and run Docker CLI and Engine inside WSL2 (Windows Subsystem for Linux). On MacOS you can install Docker CLI and Engine inside a virtual machine.

Docker can be replaced with an open-source alternative called Podman maintained by the containers organization.

Podman is a daemonless container engine for developing, managing, and running OCI Containers on your Linux System. Containers can either be run as root or in rootless mode. Simply put: alias docker=podman.

This example shows how to use Podman with Testcontainers in Java projects that use Gradle on Ubuntu Linux and MacOS (both x86_64 and Apple silicon).

Install Podman

Linux

See https://podman.io/getting-started/installation#linux-distributions

Ubuntu 20.04

Set up the stable repository and install the podman package:

source /etc/os-release
echo "deb https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/xUbuntu_${VERSION_ID}/ /" | sudo tee /etc/apt/sources.list.d/devel:kubic:libcontainers:stable.list
curl -L https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/xUbuntu_${VERSION_ID}/Release.key | sudo apt-key add -
sudo apt-get update
sudo apt-get -y upgrade
sudo apt-get -y install podman

Verify the installation:

podman info

Ubuntu 20.10 and newer

The podman package is available in the official repositories for Ubuntu 20.10 and newer.

Install the podman package:

sudo apt-get -y update
sudo apt-get -y install podman

Verify the installation:

podman info

MacOS

See https://podman.io/getting-started/installation#macos

Podman is a tool for running Linux containers. Podman includes a command, podman machine that automatically manages Linux VM’s on MacOS.

Install Podman Machine and Remote Client:

brew install podman

Start the Podman-managed VM:

podman machine init
podman machine start

Verify the installation:

podman info

Verify that Podman is installed correctly

Run the busybox or other image to verify that Podman is installed correctly:

podman run --rm busybox echo "hello-world"

If you have the following error:

Error: short-name "busybox" did not resolve to an alias and no unqualified-search registries are defined in "/etc/containers/registries.conf"

Configure unqualified-search-registries:

echo "unqualified-search-registries = [\"docker.io\"]" | sudo tee -a /etc/containers/registries.conf

See https://www.redhat.com/sysadmin/container-image-short-names

Enable the Podman service

Testcontainers library communicates with Podman using socket file.

Linux

Start Podman service for a regular user (rootless) and make it listen to a socket:

systemctl --user enable --now podman.socket

Check the Podman service status:

systemctl --user status podman.socket

Check the socket file exists:

ls -la /run/user/$UID/podman/podman.sock

MacOS

Podman socket file /run/user/1000/podman/podman.sock can be found inside the Podman-managed Linux VM. A local socket on MacOS can be forwarded to a remote socket on Podman-managed VM using SSH tunneling.

The port of the Podman-managed VM can be found with the command podman system connection list --format=json.

Install jq to parse JSON:

brew install jq

Create a shell alias to forward the local socket /tmp/podman.sock to the remote socket /run/user/1000/podman/podman.sock:

echo "alias podman-sock=\"rm -f /tmp/podman.sock && ssh -i ~/.ssh/podman-machine-default -p \$(podman system connection list --format=json | jq '.[0].URI' | sed -E 's|.+://.+@.+:([[:digit:]]+)/.+|\1|') -L'/tmp/podman.sock:/run/user/1000/podman/podman.sock' -N core@localhost\"" >> ~/.zprofile
source ~/.zprofile

Open an SSH tunnel:

podman-sock

Make sure the SSH tunnel is open before executing tests using Testcontainers.

Configure Testcontainers

Testcontainers library loads configuration from multiple locations, including environment variables.

Configure Gradle build script

I recommended configuring Testcontainers in a Gradle build script.

build.gradle

test {
    OperatingSystem os = DefaultNativePlatform.currentOperatingSystem;
    if (os.isLinux()) {
        def uid = ["id", "-u"].execute().text.trim()
        environment "DOCKER_HOST", "unix:///run/user/$uid/podman/podman.sock"
    } else if (os.isMacOsX()) {
        environment "DOCKER_HOST", "unix:///tmp/podman.sock"
    }
    environment "TESTCONTAINERS_RYUK_DISABLED", "true"
}

Set DOCKER_HOST environment variable to Podman socket file depending on the operating system.

Disable Ryuk with the environment variable TESTCONTAINERS_RYUK_DISABLED.

Moby Ryuk helps you to remove containers/networks/volumes/images by given filter after specified delay.

Ryuk is a technology for Docker and doesn't support Podman. See https://github.com/testcontainers/moby-ryuk/issues/23

Testcontainers library uses Ruyk to remove containers. Instead of relying on Ryuk to implicitly remove containers, we will explicitly remove containers with a JVM shutdown hook:

Runtime.getRuntime().addShutdownHook(new Thread(container::stop));

Pass the environment variables

As an alternative to configuring Testcontainers in a Gradle build script, you can pass the environment variables to Gradle.

Linux

DOCKER_HOST="unix:///run/user/$UID/podman/podman.sock" \
TESTCONTAINERS_RYUK_DISABLED="true" \
./gradlew clean build -i

MacOS

DOCKER_HOST="unix:///tmp/podman.sock" \
TESTCONTAINERS_RYUK_DISABLED="true" \
./gradlew clean build -i

Create a base test class

It is useful to define a container that is only started once for all (or several) test classes. Starting a database container for each test class is a big overhead.

Containers should be JVM singletons and not a Spring singletons. Sometimes Spring can't reuse an already existing context, for example when @MockBean or @DirtiesContext are used. This means you get multiple Spring contexts in integration tests.

Testcontainers library supports singleton containers pattern. See https://www.testcontainers.org/test_framework_integration/manual_lifecycle_control/#singleton-containers

Create a base test class and implement a singleton container pattern.

BaseIntegrationTest.java


@SpringBootTest
public abstract class BaseIntegrationTest {

    public static final int REDIS_PORT = 6379;

    private static final PostgreSQLContainer<?> POSTGRES;
    private static final GenericContainer<?> REDIS;

    static {
        POSTGRES = createPostgresContainer();
        REDIS = createRedisContainer();
    }

    private static PostgreSQLContainer<?> createPostgresContainer() {
        PostgreSQLContainer<?> postgres =
                new PostgreSQLContainer<>(DockerImageName.parse("postgres").withTag("14-alpine"));
        postgres.start();
        Runtime.getRuntime().addShutdownHook(new Thread(postgres::stop));
        return postgres;
    }

    private static GenericContainer<?> createRedisContainer() {
        GenericContainer<?> redis =
                new GenericContainer<>(DockerImageName.parse("redis").withTag("6-alpine"))
                        .withExposedPorts(REDIS_PORT);
        redis.start();
        Runtime.getRuntime().addShutdownHook(new Thread(redis::stop));
        return redis;
    }

    @DynamicPropertySource
    static void registerPostgresProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.r2dbc.url", BaseIntegrationTest::getPostgresR2bcUrl);
        registry.add("spring.r2dbc.username", POSTGRES::getUsername);
        registry.add("spring.r2dbc.password", POSTGRES::getPassword);
        registry.add("spring.flyway.url", POSTGRES::getJdbcUrl);
        registry.add("spring.flyway.user", POSTGRES::getUsername);
        registry.add("spring.flyway.password", POSTGRES::getPassword);
    }

    @DynamicPropertySource
    static void registerRedisProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.redis.host", REDIS::getContainerIpAddress);
        registry.add("spring.redis.port", () -> REDIS.getMappedPort(REDIS_PORT));
    }

    private static String getPostgresR2bcUrl() {
        return "r2dbc:postgresql://"
                + POSTGRES.getContainerIpAddress()
                + ":"
                + POSTGRES.getMappedPort(PostgreSQLContainer.POSTGRESQL_PORT)
                + "/"
                + POSTGRES.getDatabaseName();
    }
}

The singleton container is started only once when the base class is loaded. The container can then be used by all inheriting test classes. At the end of the tests the JVM shutdown hook will take care of stopping the singleton container.

@DynamicPropertySource annotation and its supporting infrastructure allows properties from Testcontainers based tests to be exposed easily to Spring integration tests.

Implement test classes

Implement test classes by inheriting the base class BaseIntegrationTest.java.

PersonRepositoryTests


@Slf4j
class PersonRepositoryTests extends BaseIntegrationTest {

    @Autowired
    PersonRepository personRepository;

    @Test
    void shouldFindByLastName() {
        personRepository.save(new Person(null, "Harry", "Callahan")).subscribe();

        personRepository
                .findByLastName("Callahan")
                .doOnNext(person -> log.info("Person found with findByLastName(\"Callahan\"): {}", person))
                .as(StepVerifier::create)
                .expectNextMatches(
                        person ->
                                person.id() != null
                                        && "Harry".equals(person.firstName())
                                        && "Callahan".equals(person.lastName()))
                .verifyComplete();
    }
}

RedisIncrementTests

public class RedisIncrementTests extends BaseIntegrationTest {

    @Autowired
    ReactiveRedisTemplate<String, Long> redisTemplate;

    @Test
    void shouldIncrementKey() {
        Flux.range(0, 3)
                .flatMap(i -> redisTemplate.opsForValue().increment("mykey"))
                .as(StepVerifier::create)
                .expectNext(1L)
                .expectNext(2L)
                .expectNext(3L)
                .verifyComplete();
    }
}