From 1c7d785049565a58ff44e7f0a5bf98979d42e55c Mon Sep 17 00:00:00 2001
From: Mohit Kumar <91405114+mohitduhan19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 14:10:29 +0530
Subject: [PATCH 1/7] feat(core): add getMappedPort(int, InternetProtocol) for
UDP port lookups
---
.../containers/ContainerState.java | 26 +++++++++++++++++--
1 file changed, 24 insertions(+), 2 deletions(-)
diff --git a/core/src/main/java/org/testcontainers/containers/ContainerState.java b/core/src/main/java/org/testcontainers/containers/ContainerState.java
index e19f7a85310..459261a91c5 100644
--- a/core/src/main/java/org/testcontainers/containers/ContainerState.java
+++ b/core/src/main/java/org/testcontainers/containers/ContainerState.java
@@ -158,6 +158,24 @@ default Integer getFirstMappedPort() {
* @see #getCurrentContainerInfo()
*/
default Integer getMappedPort(int originalPort) {
+ return getMappedPort(originalPort, InternetProtocol.TCP);
+ }
+
+ /**
+ * Get the actual mapped port for a given port exposed by the container, for a specific protocol.
+ * It should be used in conjunction with {@link #getHost()}.
+ *
+ * Note: The returned port number might be outdated (for instance, after disconnecting from a network and reconnecting
+ * again). If you always need up-to-date value, override the {@link #getContainerInfo()} to return the
+ * {@link #getCurrentContainerInfo()}.
+ *
+ * @param originalPort the original port that is exposed
+ * @param protocol the protocol (TCP or UDP) that the port is exposed with
+ * @return the port that the exposed port is mapped to, or null if it is not exposed
+ * @see #getContainerInfo()
+ * @see #getCurrentContainerInfo()
+ */
+ default Integer getMappedPort(int originalPort, InternetProtocol protocol) {
Preconditions.checkState(
this.getContainerId() != null,
"Mapped port can only be obtained after the container is started"
@@ -166,13 +184,17 @@ default Integer getMappedPort(int originalPort) {
Ports.Binding[] binding = new Ports.Binding[0];
final InspectContainerResponse containerInfo = this.getContainerInfo();
if (containerInfo != null) {
- binding = containerInfo.getNetworkSettings().getPorts().getBindings().get(new ExposedPort(originalPort));
+ ExposedPort exposedPort = new ExposedPort(
+ originalPort,
+ com.github.dockerjava.api.model.InternetProtocol.parse(protocol.name())
+ );
+ binding = containerInfo.getNetworkSettings().getPorts().getBindings().get(exposedPort);
}
if (binding != null && binding.length > 0 && binding[0] != null) {
return Integer.valueOf(binding[0].getHostPortSpec());
} else {
- throw new IllegalArgumentException("Requested port (" + originalPort + ") is not mapped");
+ throw new IllegalArgumentException("Requested port (" + originalPort + "/" + protocol.toDockerNotation() + ") is not mapped");
}
}
From 01303949fda950f4b255a0fce5deb8870ca1629f Mon Sep 17 00:00:00 2001
From: Mohit Kumar <91405114+mohitduhan19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 14:12:55 +0530
Subject: [PATCH 2/7] feat(core): expose addExposedPort/withExposedPort with
InternetProtocol
---
.../containers/GenericContainer.java | 24 +++++++++++++++++++
1 file changed, 24 insertions(+)
diff --git a/core/src/main/java/org/testcontainers/containers/GenericContainer.java b/core/src/main/java/org/testcontainers/containers/GenericContainer.java
index 4d3778c63d1..1006d8ca5c6 100644
--- a/core/src/main/java/org/testcontainers/containers/GenericContainer.java
+++ b/core/src/main/java/org/testcontainers/containers/GenericContainer.java
@@ -1048,6 +1048,30 @@ public void addExposedPorts(int... ports) {
this.containerDef.addExposedTcpPorts(ports);
}
+ /**
+ * Expose a container port using a specific protocol, so that a randomly chosen host port will be bound to it
+ * when the container starts. Use {@link ContainerState#getMappedPort(int, InternetProtocol)} to retrieve the
+ * bound host port once the container is running.
+ *
+ * @param port the container port to expose
+ * @param protocol the protocol (TCP or UDP) that the port should be exposed with
+ */
+ public void addExposedPort(int port, InternetProtocol protocol) {
+ this.containerDef.addExposedPort(port, com.github.dockerjava.api.model.InternetProtocol.parse(protocol.name()));
+ }
+
+ /**
+ * Fluent variant of {@link #addExposedPort(int, InternetProtocol)}.
+ *
+ * @param port the container port to expose
+ * @param protocol the protocol (TCP or UDP) that the port should be exposed with
+ * @return this
+ */
+ public SELF withExposedPort(int port, InternetProtocol protocol) {
+ addExposedPort(port, protocol);
+ return self();
+ }
+
/**
* {@inheritDoc}
*/
From bf4f5282207d70b81459b15e697831e62b752d24 Mon Sep 17 00:00:00 2001
From: Mohit Kumar <91405114+mohitduhan19@users.noreply.github.com>
Date: Fri, 18 Sep 2026 14:15:34 +0530
Subject: [PATCH 3/7] test(core): cover getMappedPort(int, InternetProtocol)
for UDP
---
.../containers/ContainerStateTest.java | 41 +++++++++++++++++++
1 file changed, 41 insertions(+)
diff --git a/core/src/test/java/org/testcontainers/containers/ContainerStateTest.java b/core/src/test/java/org/testcontainers/containers/ContainerStateTest.java
index 7b37bc1926f..515c5f17ae8 100644
--- a/core/src/test/java/org/testcontainers/containers/ContainerStateTest.java
+++ b/core/src/test/java/org/testcontainers/containers/ContainerStateTest.java
@@ -1,12 +1,20 @@
package org.testcontainers.containers;
+import com.github.dockerjava.api.command.InspectContainerResponse;
+import com.github.dockerjava.api.model.ExposedPort;
+import com.github.dockerjava.api.model.Ports;
+import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.Answers;
+import org.mockito.Mockito;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.doCallRealMethod;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -35,4 +43,37 @@ void test(String name, String testSet, List expectedResult) {
List result = containerState.getBoundPortNumbers();
assertThat(result).hasSameElementsAs(expectedResult);
}
+
+ @Test
+ void getMappedPortWithProtocolLooksUpTheBindingForThatProtocol() {
+ ContainerState containerState = mock(ContainerState.class);
+ doCallRealMethod().when(containerState).getMappedPort(anyInt(), any());
+ when(containerState.getContainerId()).thenReturn("container-id");
+
+ InspectContainerResponse containerInfo = Mockito.mock(InspectContainerResponse.class, Answers.RETURNS_DEEP_STUBS);
+ ExposedPort udpPort = new ExposedPort(12345, com.github.dockerjava.api.model.InternetProtocol.UDP);
+ when(containerInfo.getNetworkSettings().getPorts().getBindings())
+ .thenReturn(Collections.singletonMap(udpPort, new Ports.Binding[] { Ports.Binding.bindPort(54321) }));
+ when(containerState.getContainerInfo()).thenReturn(containerInfo);
+
+ Integer mappedPort = containerState.getMappedPort(12345, InternetProtocol.UDP);
+
+ assertThat(mappedPort).isEqualTo(54321);
+ }
+
+ @Test
+ void getMappedPortWithProtocolThrowsWhenNotMapped() {
+ ContainerState containerState = mock(ContainerState.class);
+ doCallRealMethod().when(containerState).getMappedPort(anyInt(), any());
+ when(containerState.getContainerId()).thenReturn("container-id");
+
+ InspectContainerResponse containerInfo = Mockito.mock(InspectContainerResponse.class, Answers.RETURNS_DEEP_STUBS);
+ when(containerInfo.getNetworkSettings().getPorts().getBindings()).thenReturn(Collections.emptyMap());
+ when(containerState.getContainerInfo()).thenReturn(containerInfo);
+
+ org.assertj.core.api.Assertions
+ .assertThatThrownBy(() -> containerState.getMappedPort(12345, InternetProtocol.UDP))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("12345/udp");
+ }
}
From cc1ef6f84efa70b33e4c6fe18208edf741e15a55 Mon Sep 17 00:00:00 2001
From: Mohit Kumar <91405114+mohitduhan19@users.noreply.github.com>
Date: Sun, 27 Sep 2026 23:30:17 +0530
Subject: [PATCH 4/7] fix(core): preserve protocol in
GenericContainer#getFirstMappedPort
---
.../containers/GenericContainer.java | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/core/src/main/java/org/testcontainers/containers/GenericContainer.java b/core/src/main/java/org/testcontainers/containers/GenericContainer.java
index 1006d8ca5c6..a0c9794878e 100644
--- a/core/src/main/java/org/testcontainers/containers/GenericContainer.java
+++ b/core/src/main/java/org/testcontainers/containers/GenericContainer.java
@@ -263,6 +263,23 @@ public List getExposedPorts() {
return exposedPorts;
}
+ /**
+ * {@inheritDoc}
+ *
+ * Unlike the default implementation, this preserves the protocol the first port was exposed with,
+ * so it also works for containers whose first exposed port is UDP.
+ */
+ @Override
+ public Integer getFirstMappedPort() {
+ return this.containerDef.getExposedPorts()
+ .stream()
+ .findFirst()
+ .map(exposedPort ->
+ getMappedPort(exposedPort.getPort(), InternetProtocol.fromDockerNotation(exposedPort.getProtocol().toString()))
+ )
+ .orElseThrow(() -> new IllegalStateException("Container doesn't expose any ports"));
+ }
+
@Override
public void setExposedPorts(List exposedPorts) {
this.containerDef.exposedPorts.clear();
From aa70c966593c2e6135e674cc34f9998f53cc51e3 Mon Sep 17 00:00:00 2001
From: Mohit Kumar <91405114+mohitduhan19@users.noreply.github.com>
Date: Sun, 11 Oct 2026 17:56:24 +0530
Subject: [PATCH 5/7] fix(core): skip UDP-only exposed ports in the default
liveness check
A container that only exposes a UDP port via withExposedPort(port, InternetProtocol.UDP) failed to start, because the default wait strategy looked up a TCP mapping for every exposed port. UDP ports cannot be checked with a TCP connection, so they are now left out of the liveness check ports.
---
.../containers/GenericContainer.java | 34 ++++++++++++++++---
1 file changed, 29 insertions(+), 5 deletions(-)
diff --git a/core/src/main/java/org/testcontainers/containers/GenericContainer.java b/core/src/main/java/org/testcontainers/containers/GenericContainer.java
index a0c9794878e..6cb248be658 100644
--- a/core/src/main/java/org/testcontainers/containers/GenericContainer.java
+++ b/core/src/main/java/org/testcontainers/containers/GenericContainer.java
@@ -74,7 +74,6 @@
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
-import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
@@ -756,9 +755,13 @@ protected void containerIsStopped(InspectContainerResponse containerInfo) {}
@Deprecated
protected Integer getLivenessCheckPort() {
// legacy implementation for backwards compatibility
- Iterator exposedPortsIterator = this.containerDef.getExposedPorts().iterator();
- if (exposedPortsIterator.hasNext()) {
- return getMappedPort(exposedPortsIterator.next().getPort());
+ // UDP ports cannot be checked with a TCP connection, so only TCP ports are candidates
+ Optional firstTcpPort = this.containerDef.getExposedPorts()
+ .stream()
+ .filter(exposedPort -> exposedPort.getProtocol() == com.github.dockerjava.api.model.InternetProtocol.TCP)
+ .findFirst();
+ if (firstTcpPort.isPresent()) {
+ return getMappedPort(firstTcpPort.get().getPort());
} else if (!this.containerDef.getPortBindings().isEmpty()) {
return Integer.valueOf(
this.containerDef.getPortBindings().iterator().next().getBinding().getHostPortSpec()
@@ -776,7 +779,14 @@ protected Integer getLivenessCheckPort() {
@NonNull
@Deprecated
protected Set getLivenessCheckPorts() {
- final Set result = WaitStrategyTarget.super.getLivenessCheckPortNumbers();
+ // Ports exposed only as UDP have no TCP mapping and cannot be checked by the default wait strategy
+ final Set udpOnlyPorts = getUdpOnlyExposedPorts();
+ final Set result = getExposedPorts()
+ .stream()
+ .filter(port -> !udpOnlyPorts.contains(port))
+ .map(this::getMappedPort)
+ .collect(Collectors.toSet());
+ result.addAll(getBoundPortNumbers());
// for backwards compatibility
if (this.getLivenessCheckPort() != null) {
@@ -786,6 +796,20 @@ protected Set getLivenessCheckPorts() {
return result;
}
+ private Set getUdpOnlyExposedPorts() {
+ final Set udpPorts = new HashSet<>();
+ final Set tcpPorts = new HashSet<>();
+ for (ExposedPort exposedPort : this.containerDef.getExposedPorts()) {
+ if (exposedPort.getProtocol() == com.github.dockerjava.api.model.InternetProtocol.UDP) {
+ udpPorts.add(exposedPort.getPort());
+ } else if (exposedPort.getProtocol() == com.github.dockerjava.api.model.InternetProtocol.TCP) {
+ tcpPorts.add(exposedPort.getPort());
+ }
+ }
+ udpPorts.removeAll(tcpPorts);
+ return udpPorts;
+ }
+
@Override
public Set getLivenessCheckPortNumbers() {
return this.getLivenessCheckPorts();
From accc47598fc9dcfef62d4a989fd7b3d32a1362a2 Mon Sep 17 00:00:00 2001
From: Mohit Kumar <91405114+mohitduhan19@users.noreply.github.com>
Date: Sun, 11 Oct 2026 17:56:55 +0530
Subject: [PATCH 6/7] test(core): cover liveness check ports for UDP-only
containers
---
...enericContainerLivenessCheckPortsTest.java | 83 +++++++++++++++++++
1 file changed, 83 insertions(+)
create mode 100644 core/src/test/java/org/testcontainers/containers/GenericContainerLivenessCheckPortsTest.java
diff --git a/core/src/test/java/org/testcontainers/containers/GenericContainerLivenessCheckPortsTest.java b/core/src/test/java/org/testcontainers/containers/GenericContainerLivenessCheckPortsTest.java
new file mode 100644
index 00000000000..965196fe2f7
--- /dev/null
+++ b/core/src/test/java/org/testcontainers/containers/GenericContainerLivenessCheckPortsTest.java
@@ -0,0 +1,83 @@
+package org.testcontainers.containers;
+
+import com.github.dockerjava.api.command.InspectContainerResponse;
+import com.github.dockerjava.api.model.ExposedPort;
+import com.github.dockerjava.api.model.Ports;
+import org.junit.jupiter.api.Test;
+import org.mockito.Answers;
+import org.mockito.Mockito;
+import org.testcontainers.utility.DockerImageName;
+
+import java.util.AbstractMap;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+class GenericContainerLivenessCheckPortsTest {
+
+ @Test
+ void udpOnlyPortsAreNotUsedForLivenessChecks() {
+ GenericContainer> container = startedWithBindings(
+ binding(12345, com.github.dockerjava.api.model.InternetProtocol.UDP, 54321)
+ )
+ .withExposedPort(12345, InternetProtocol.UDP);
+
+ assertThat(container.getLivenessCheckPortNumbers()).isEmpty();
+ }
+
+ @Test
+ void tcpPortsAreStillUsedWhenUdpPortsAreAlsoExposed() {
+ GenericContainer> container = startedWithBindings(
+ binding(12345, com.github.dockerjava.api.model.InternetProtocol.UDP, 54321),
+ binding(8080, com.github.dockerjava.api.model.InternetProtocol.TCP, 58080)
+ )
+ .withExposedPort(12345, InternetProtocol.UDP)
+ .withExposedPorts(8080);
+
+ assertThat(container.getLivenessCheckPortNumbers()).containsExactly(58080);
+ }
+
+ @SafeVarargs
+ private static GenericContainer> startedWithBindings(Map.Entry... bindings) {
+ Map bindingMap = new HashMap<>();
+ for (Map.Entry binding : bindings) {
+ bindingMap.put(binding.getKey(), binding.getValue());
+ }
+ InspectContainerResponse containerInfo = Mockito.mock(InspectContainerResponse.class, Answers.RETURNS_DEEP_STUBS);
+ when(containerInfo.getNetworkSettings().getPorts().getBindings()).thenReturn(bindingMap);
+ return new StartedContainer(containerInfo);
+ }
+
+ private static class StartedContainer extends GenericContainer {
+
+ private final InspectContainerResponse containerInfo;
+
+ StartedContainer(InspectContainerResponse containerInfo) {
+ super(DockerImageName.parse("alpine:3.20"));
+ this.containerInfo = containerInfo;
+ }
+
+ @Override
+ public String getContainerId() {
+ return "container-id";
+ }
+
+ @Override
+ public InspectContainerResponse getContainerInfo() {
+ return containerInfo;
+ }
+ }
+
+ private static Map.Entry binding(
+ int port,
+ com.github.dockerjava.api.model.InternetProtocol protocol,
+ int hostPort
+ ) {
+ return new AbstractMap.SimpleEntry<>(
+ new ExposedPort(port, protocol),
+ new Ports.Binding[] { Ports.Binding.bindPort(hostPort) }
+ );
+ }
+}
From 23d0c086735625322c32945ff7b644d46db825ad Mon Sep 17 00:00:00 2001
From: Mohit Kumar <91405114+mohitduhan19@users.noreply.github.com>
Date: Sun, 11 Oct 2026 18:12:26 +0530
Subject: [PATCH 7/7] test(core): keep the UDP exposure in the mixed-protocol
liveness test
withExposedPorts(8080) replaced the UDP exposure added just before it, so the test only covered a TCP container.
---
.../containers/GenericContainerLivenessCheckPortsTest.java | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/core/src/test/java/org/testcontainers/containers/GenericContainerLivenessCheckPortsTest.java b/core/src/test/java/org/testcontainers/containers/GenericContainerLivenessCheckPortsTest.java
index 965196fe2f7..4f8db95c131 100644
--- a/core/src/test/java/org/testcontainers/containers/GenericContainerLivenessCheckPortsTest.java
+++ b/core/src/test/java/org/testcontainers/containers/GenericContainerLivenessCheckPortsTest.java
@@ -34,7 +34,7 @@ void tcpPortsAreStillUsedWhenUdpPortsAreAlsoExposed() {
binding(8080, com.github.dockerjava.api.model.InternetProtocol.TCP, 58080)
)
.withExposedPort(12345, InternetProtocol.UDP)
- .withExposedPorts(8080);
+ .withExposedPort(8080, InternetProtocol.TCP);
assertThat(container.getLivenessCheckPortNumbers()).containsExactly(58080);
}