From aeae49e269a88fd295a7caa89efa6688680d4826 Mon Sep 17 00:00:00 2001 From: Golo Roden Date: Thu, 8 Oct 2026 17:36:11 +0200 Subject: [PATCH] feat: Make the test container a Testcontainers container, and let it hand out a client with options. Container wrapped a GenericContainer, so it did not work where frameworks expect a Testcontainers container, e.g. with @ServiceConnection in Spring Boot. It now extends GenericContainer, as the official modules of Testcontainers do. All of its methods keep their names and their behavior, including replacing a container whose database does not become reachable. It also works in a try-with-resources statement now. getClient(ClientOptions) hands out a client with options of the caller, so that tests can use the same data mapper as production. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hc2MPSLu7HDHTm8HiBsrDz --- README.md | 17 ++ .../testcontainers/Container.java | 151 +++++++++--------- .../testcontainers/ContainerTest.java | 56 +++++-- 3 files changed, 140 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 0d1e7f7..fed86d9 100644 --- a/README.md +++ b/README.md @@ -716,6 +716,23 @@ To check if the test container is running, call the `isRunning` method: var isRunning = container.isRunning(); ``` +To get a client with options of your own, e.g. the data mapper of your application, hand them to `getClient`: + +```java +var client = container.getClient(new ClientOptions() + .withDataMapper(jsonMapper)); +``` + +`Container` is a Testcontainers `GenericContainer`, so you can use it wherever Testcontainers containers work, e.g. in a `try`-with-resources statement: + +```java +try (var container = new Container()) { + container.start(); + + // ... +} +``` + #### Configuring the Container Instance By default, `Container` uses the `latest` tag of the official EventSourcingDB Docker image. To change that, call the `withImageTag` method: diff --git a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java index 65148e0..4a35748 100644 --- a/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java +++ b/eventsourcingdb-testcontainers/src/main/java/io/thenativeweb/eventsourcingdb/testcontainers/Container.java @@ -3,6 +3,7 @@ import static java.nio.charset.StandardCharsets.UTF_8; import io.thenativeweb.eventsourcingdb.Client; +import io.thenativeweb.eventsourcingdb.ClientOptions; import java.net.URI; import java.security.KeyPair; import java.security.KeyPairGenerator; @@ -35,29 +36,33 @@ * container.stop(); * } * } + * + *

It is a Testcontainers {@link GenericContainer}, so it works wherever Testcontainers containers do, e.g. in a + * {@code try}-with-resources statement, or with the JUnit extension of Testcontainers. */ -public final class Container { +public final class Container extends GenericContainer { private static final String IMAGE_NAME = "thenativeweb/eventsourcingdb"; private static final String SIGNING_KEY_PATH = "/etc/esdb/signing-key.pem"; - // How many containers start() starts at most, as it replaces each + // How many times start() starts the container at most, as it replaces each // container whose database does not become reachable. private static final int MAX_START_ATTEMPTS = 3; - private String imageTag = "latest"; private int internalPort = 3000; private String apiToken = "secret"; private @Nullable KeyPair signingKeyPair; - private @Nullable GenericContainer container; - // Starts a container. It is a field only so that tests can replace it. - Consumer> starter = GenericContainer::start; + // Starts the container once. It is a field only so that tests can replace + // it. + Consumer starter = Container::startOnce; /** * Creates a container that uses the {@code latest} tag of the official EventSourcingDB image, port 3000 inside the * container, and the API token {@code secret}, and does not sign events. */ - public Container() {} + public Container() { + super(DockerImageName.parse(IMAGE_NAME + ":latest")); + } /** * Sets the tag of the image to use, e.g. a specific version of EventSourcingDB. @@ -66,7 +71,7 @@ public Container() {} * @return this container */ public Container withImageTag(String tag) { - imageTag = tag; + setDockerImageName(IMAGE_NAME + ":" + tag); return this; } @@ -104,6 +109,40 @@ public Container withSigningKey() { return this; } + // Applies the configuration right before the container starts, so that the + // with methods may be called in any order. + @Override + protected void configure() { + var command = new ArrayList<>(List.of( + "run", + "--api-token", + apiToken, + "--data-directory-temporary", + "--http-enabled", + "--https-enabled=false", + "--http-port", + String.valueOf(internalPort))); + + withExposedPorts(internalPort); + waitingFor(Wait.forHttp("/api/v1/ping").forPort(internalPort).withStartupTimeout(Duration.ofSeconds(10))); + + if (signingKeyPair != null) { + withCopyToContainer(Transferable.of(pem(signingKeyPair.getPrivate()), 0777), SIGNING_KEY_PATH); + command.addAll(List.of("--signing-key-file", SIGNING_KEY_PATH)); + } + + withCommand(command.toArray(String[]::new)); + } + + private static byte[] pem(PrivateKey privateKey) { + var encoder = Base64.getMimeEncoder(64, new byte[] {'\n'}); + + return ("-----BEGIN PRIVATE KEY-----\n" + + encoder.encodeToString(privateKey.getEncoded()) + + "\n-----END PRIVATE KEY-----\n") + .getBytes(UTF_8); + } + /** * Starts the container, and waits until the instance answers pings. * @@ -115,23 +154,20 @@ public Container withSigningKey() { * @throws RuntimeException if the container does not start for another reason, e.g. because the image does not * exist */ + @Override public void start() { for (var attempt = 1; ; attempt++) { - var container = newContainer(); - try { - starter.accept(container); - this.container = container; + starter.accept(this); return; } catch (RuntimeException ex) { - // Docker Desktop sometimes does not make the port it maps a - // container to reachable from the host. The container keeps - // running, but the database in it can never be reached, so it - // is replaced by a new one. Every other failure is thrown at - // once. Either way, the container that failed to start is - // removed, so that it is not left behind. - var isRunning = container.isRunning(); - container.stop(); + // A container that keeps running although starting it failed + // is one whose database can not be reached, so it is replaced + // by a new one. Every other failure is thrown at once. Either + // way, the container that failed to start is removed, so that + // it is not left behind. + var isRunning = isRunning(); + stop(); if (!isRunning) { throw ex; @@ -147,39 +183,8 @@ public void start() { } } - private GenericContainer newContainer() { - var command = new ArrayList<>(List.of( - "run", - "--api-token", - apiToken, - "--data-directory-temporary", - "--http-enabled", - "--https-enabled=false", - "--http-port", - String.valueOf(internalPort))); - - GenericContainer container = new GenericContainer<>(DockerImageName.parse(IMAGE_NAME + ":" + imageTag)) - .withExposedPorts(internalPort) - .waitingFor( - Wait.forHttp("/api/v1/ping").forPort(internalPort).withStartupTimeout(Duration.ofSeconds(10))); - - if (signingKeyPair != null) { - container.withCopyToContainer(Transferable.of(pem(signingKeyPair.getPrivate()), 0777), SIGNING_KEY_PATH); - command.addAll(List.of("--signing-key-file", SIGNING_KEY_PATH)); - } - - container.withCommand(command.toArray(String[]::new)); - - return container; - } - - private static byte[] pem(PrivateKey privateKey) { - var encoder = Base64.getMimeEncoder(64, new byte[] {'\n'}); - - return ("-----BEGIN PRIVATE KEY-----\n" - + encoder.encodeToString(privateKey.getEncoded()) - + "\n-----END PRIVATE KEY-----\n") - .getBytes(UTF_8); + void startOnce() { + super.start(); } /** @@ -187,8 +192,10 @@ private static byte[] pem(PrivateKey privateKey) { * * @throws IllegalStateException if the container is not running */ + @Override public String getHost() { - return running().getHost(); + throwIfNotRunning(); + return super.getHost(); } /** @@ -197,7 +204,8 @@ public String getHost() { * @throws IllegalStateException if the container is not running */ public int getMappedPort() { - return running().getMappedPort(internalPort); + throwIfNotRunning(); + return getMappedPort(internalPort); } /** @@ -234,38 +242,31 @@ public PublicKey getVerificationKey() { return signingKeyPair().getPublic(); } - /** {@return whether the container is running} */ - public boolean isRunning() { - return container != null; - } - /** - * Stops and removes the container. If it is not running, this does nothing. + * {@return a client for the instance} + * + * @throws IllegalStateException if the container is not running */ - public void stop() { - if (container == null) { - return; - } - - container.stop(); - container = null; + public Client getClient() { + return getClient(new ClientOptions()); } /** - * {@return a client for the instance} + * {@return a client for the instance, with the given options} + * + *

Use this to test with the same options as in production, e.g. the data mapper of your application. * + * @param options the options of the client * @throws IllegalStateException if the container is not running */ - public Client getClient() { - return new Client(getBaseUrl(), apiToken); + public Client getClient(ClientOptions options) { + return new Client(getBaseUrl(), apiToken, options); } - private GenericContainer running() { - if (container == null) { + private void throwIfNotRunning() { + if (!isRunning()) { throw new IllegalStateException("container must be running"); } - - return container; } private KeyPair signingKeyPair() { diff --git a/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java index 2b2a6f7..a188a79 100644 --- a/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java +++ b/eventsourcingdb-testcontainers/src/test/java/io/thenativeweb/eventsourcingdb/testcontainers/ContainerTest.java @@ -2,9 +2,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.thenativeweb.eventsourcingdb.ClientOptions; import io.thenativeweb.eventsourcingdb.EventCandidate; import java.net.URI; import java.util.ArrayList; @@ -13,6 +15,9 @@ import org.junit.jupiter.api.Test; import org.testcontainers.DockerClientFactory; import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.containers.GenericContainer; +import tools.jackson.databind.PropertyNamingStrategies; +import tools.jackson.databind.json.JsonMapper; class ContainerTest { @Test @@ -76,6 +81,39 @@ void signsEventsWithItsSigningKey() { } } + // Being a Testcontainers container is what frameworks such as Spring Boot + // build on, e.g. to connect a client to it. + @Test + void isATestcontainersContainer() { + assertInstanceOf(GenericContainer.class, new Container()); + } + + record BookAcquired(String bookTitle) {} + + @Test + void handsOutAClientWithTheGivenOptions() { + var container = new Container().withImageTag(ImageTag.fromDockerfile()); + + container.start(); + try { + var client = container.getClient(new ClientOptions() + .withDataMapper(JsonMapper.builder() + .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + .build())); + + var event = client.writeEvents(List.of(new EventCandidate( + "https://www.eventsourcingdb.io", + "/test", + "io.eventsourcingdb.test", + new BookAcquired("2001")))) + .getFirst(); + + assertEquals("2001", event.data().path("book_title").asString()); + } finally { + container.stop(); + } + } + @Test void failsToStartWithAnImageThatDoesNotExist() { var container = new Container().withImageTag("does-not-exist"); @@ -135,9 +173,9 @@ void replacesAContainerWhoseDatabaseDoesNotBecomeReachable() { // Docker Desktop sometimes does not make the port of a container // reachable from the host. The container keeps running, but starting // it fails, as it does here twice. - container.starter = genericContainer -> { - genericContainer.start(); - containerIds.add(genericContainer.getContainerId()); + container.starter = self -> { + self.startOnce(); + containerIds.add(self.getContainerId()); if (containerIds.size() < 3) { throw new ContainerLaunchException("the database did not become reachable"); } @@ -159,9 +197,9 @@ void givesUpAfterThreeAttemptsAndSaysWhy() { var failures = new ArrayList(); var container = new Container().withImageTag(ImageTag.fromDockerfile()); - container.starter = genericContainer -> { - genericContainer.start(); - containerIds.add(genericContainer.getContainerId()); + container.starter = self -> { + self.startOnce(); + containerIds.add(self.getContainerId()); var failure = new ContainerLaunchException("the database did not become reachable"); failures.add(failure); throw failure; @@ -184,11 +222,11 @@ void throwsAnyOtherFailureAtOnceAndRemovesTheContainer() { // Without an API token, EventSourcingDB exits right away. var container = new Container().withImageTag(ImageTag.fromDockerfile()).withApiToken(""); - container.starter = genericContainer -> { + container.starter = self -> { try { - genericContainer.start(); + self.startOnce(); } finally { - containerIds.add(genericContainer.getContainerId()); + containerIds.add(self.getContainerId()); } };