The Problem

Testcontainers is an amazing library for integration testing with Docker containers. However, there was a long-standing issue (#554) - the getMappedPort() method only supported TCP ports, not UDP.

This was problematic for developers testing applications that use UDP protocols like DNS servers, game servers, VoIP applications, or any real-time streaming services.

Understanding Docker Port Mapping

Docker supports both TCP and UDP port mappings. When you run a container, you can expose ports like:

# TCP port (default)
docker run -p 8080:80 nginx

# UDP port
docker run -p 53:53/udp dns-server

# Both TCP and UDP on same port
docker run -p 53:53/tcp -p 53:53/udp dns-server

The Docker API returns port bindings with protocol information, but Testcontainers was ignoring the protocol and assuming everything was TCP.

The Solution

I implemented a new overloaded method that accepts the protocol:

API Design

// Existing method (TCP only)
Integer getMappedPort(int originalPort);

// New method with protocol support
Integer getMappedPort(int originalPort, InternetProtocol protocol);

// New method to expose UDP ports
void addExposedPort(int port, InternetProtocol protocol);

Implementation in ContainerState.java

default Integer getMappedPort(int originalPort, InternetProtocol protocol) {
    Preconditions.checkState(
        isRunning(), 
        "Container must be running to get mapped port"
    );
    
    Ports.Binding[] bindings = getContainerInfo()
        .getNetworkSettings()
        .getPorts()
        .getBindings()
        .get(new ExposedPort(originalPort, protocol));
    
    if (bindings == null || bindings.length == 0) {
        throw new IllegalArgumentException(
            "No mapped port for " + originalPort + "/" + protocol
        );
    }
    
    return Integer.valueOf(bindings[0].getHostPortSpec());
}

Implementation in GenericContainer.java

public void addExposedPort(int port, InternetProtocol protocol) {
    exposedPorts.add(new ExposedPort(port, protocol));
}

Testing the Implementation

I wrote comprehensive unit tests using Mockito to verify the behavior:

@Test
void shouldReturnMappedUdpPort() {
    // Setup mock container with UDP port binding
    when(containerInfo.getNetworkSettings()
        .getPorts()
        .getBindings()
        .get(new ExposedPort(53, InternetProtocol.UDP)))
        .thenReturn(new Ports.Binding[]{
            new Ports.Binding("0.0.0.0", "32853")
        });
    
    // Verify UDP port mapping works
    Integer mappedPort = container.getMappedPort(53, InternetProtocol.UDP);
    assertThat(mappedPort).isEqualTo(32853);
}

@Test
void shouldThrowWhenUdpPortNotMapped() {
    when(containerInfo.getNetworkSettings()
        .getPorts()
        .getBindings()
        .get(any()))
        .thenReturn(null);
    
    assertThatThrownBy(() -> 
        container.getMappedPort(53, InternetProtocol.UDP)
    ).isInstanceOf(IllegalArgumentException.class);
}

Backward Compatibility

The existing getMappedPort(int) method continues to work as before, defaulting to TCP. This ensures that existing code doesn't break:

// Still works - assumes TCP
container.getMappedPort(8080);

// New - explicitly specify protocol
container.getMappedPort(53, InternetProtocol.UDP);

Key Learnings

  • Docker internals: Understanding how Docker handles port bindings at the API level
  • API design: Adding new functionality without breaking existing users
  • Test isolation: Using mocks for unit tests when Docker isn't available
  • Protocol handling: TCP vs UDP networking differences

Links