or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

docs

configuration.mdcontainer-lifecycle.mddocker-client.mddocker-compose.mdimage-building.mdimage-management.mdimage-pull-policies.mdindex.mdjunit-jupiter-integration.mdlifecycle.mdmodules-overview.mdnetwork-configuration.mdoutput-handling.mdstartup-checks.mdutility-classes.mdwait-strategies.md
tile.json

lifecycle.mddocs/

Lifecycle Management

Management of container and resource lifecycles for coordinated startup and shutdown operations with dependency resolution and test lifecycle integration.

Capabilities

Startable Interface

Core interface for any resource that can be started and stopped, with support for dependency management. This is the fundamental abstraction used by all Testcontainers for lifecycle control.

/**
 * Represents a resource that can be started and stopped with automatic dependency management.
 * This is a core abstraction for coordinating the lifecycle of containers and other resources.
 */
public interface Startable extends AutoCloseable {
    /**
     * Start the resource.
     * Implementations should ensure all dependencies are started first.
     * This method may block until the resource is fully started.
     */
    void start();

    /**
     * Stop the resource.
     * Implementations should clean up all resources allocated during start().
     * This method is called automatically by close().
     */
    void stop();

    /**
     * Get the dependencies that must be started before this resource.
     * Returns an empty set by default, but can be overridden to declare dependencies.
     *
     * @return set of Startable instances that must be started first (default: empty set)
     */
    default Set<Startable> getDependencies() {
        return Collections.emptySet();
    }

    /**
     * Close this resource (implements AutoCloseable).
     * Default implementation calls stop().
     */
    @Override
    default void close() {
        stop();
    }
}

Implemented By:

  • GenericContainer - All container types in Testcontainers
  • Network - Container networks
  • Any custom resource implementing this interface

Usage Examples:

import org.testcontainers.lifecycle.Startable;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.utility.DockerImageName;

// All GenericContainer instances implement Startable
Startable container = new GenericContainer<>(DockerImageName.parse("redis:7.0"))
    .withExposedPorts(6379);

// Start and stop
container.start();
try {
    // Use container
} finally {
    container.stop();
}

// Or use with try-with-resources (since it implements AutoCloseable)
try (Startable container = new GenericContainer<>(DockerImageName.parse("postgres:15"))
        .withExposedPorts(5432)) {
    container.start();
    // Use container
} // Automatically stopped and closed

// Declare dependencies
Startable database = new GenericContainer<>(DockerImageName.parse("postgres:15"))
    .withExposedPorts(5432);

Startable application = new GenericContainer<>(DockerImageName.parse("myapp:latest"))
    .withEnv("DB_HOST", "db")
    .dependsOn(database);

// Start application and its dependencies
application.start();  // Database is started first

Custom Startable Implementation:

import org.testcontainers.lifecycle.Startable;
import java.util.Collections;
import java.util.Set;

public class CustomResource implements Startable {
    private String resourceId;

    @Override
    public void start() {
        // Allocate resource
        this.resourceId = allocateResource();
        System.out.println("Resource started: " + resourceId);
    }

    @Override
    public void stop() {
        // Clean up resource
        if (resourceId != null) {
            releaseResource(resourceId);
            resourceId = null;
            System.out.println("Resource stopped");
        }
    }

    @Override
    public Set<Startable> getDependencies() {
        return Collections.emptySet();
    }

    private String allocateResource() {
        return "resource-" + System.nanoTime();
    }

    private void releaseResource(String id) {
        // Cleanup logic
    }
}

// Usage
try (Startable resource = new CustomResource()) {
    resource.start();
    // Use resource
}

Startables Class

Utility class for managing multiple Startable instances with parallel execution and automatic dependency resolution. This is the recommended way to start multiple containers efficiently.

/**
 * Utility class for managing multiple Startable instances with dependency resolution.
 * Uses CompletableFuture for asynchronous, parallel startup while respecting dependencies.
 * This is the primary way to manage coordinated container lifecycle in production code.
 */
@UtilityClass
public class Startables {

    /**
     * Start multiple resources asynchronously, respecting their dependencies.
     * Resources are started in parallel where possible, but dependencies are honored.
     *
     * Performance characteristics:
     * - All resources with no dependencies start in parallel
     * - As soon as a resource completes, any dependent resources can start
     * - Returns a CompletableFuture that completes when all resources are started
     *
     * Example dependency graph:
     *   a -> b -> e
     *       /
     *      c
     *   d ----/
     * Result: a, c, d start in parallel, then b starts, then e starts
     *
     * @param startables variable argument list of resources to start
     * @return CompletableFuture<Void> that completes when all resources are started
     * @throws CompletionException (wrapping underlying exception) if any resource fails to start
     */
    public static CompletableFuture<Void> deepStart(Startable... startables);

    /**
     * Start multiple resources from a Collection asynchronously, respecting dependencies.
     * Equivalent to deepStart(collection.stream()).
     *
     * @param startables collection of resources to start
     * @return CompletableFuture<Void> that completes when all resources are started
     * @throws CompletionException (wrapping underlying exception) if any resource fails to start
     */
    public static CompletableFuture<Void> deepStart(Collection<? extends Startable> startables);

    /**
     * Start multiple resources from an Iterable asynchronously, respecting dependencies.
     * Equivalent to deepStart(StreamSupport.stream(iterable.spliterator(), false)).
     *
     * @param startables iterable of resources to start
     * @return CompletableFuture<Void> that completes when all resources are started
     * @throws CompletionException (wrapping underlying exception) if any resource fails to start
     */
    public static CompletableFuture<Void> deepStart(Iterable<? extends Startable> startables);

    /**
     * Start multiple resources from a Stream asynchronously, respecting their dependencies.
     * This is the primary implementation; other methods delegate to this.
     *
     * Dependency resolution:
     * 1. For each Startable in the stream, getDependencies() is called recursively
     * 2. A cache prevents starting the same resource multiple times
     * 3. Dependencies are started before the dependent using thenRunAsync()
     * 4. All independent resources start in parallel using a cached thread pool
     * 5. Failures fail-fast: first exception completes the returned future exceptionally
     *
     * Threading:
     * - Uses a daemon thread pool named "testcontainers-lifecycle-[counter]"
     * - Maximum pool size is unlimited (cached thread pool)
     * - Threads are reused for efficiency
     *
     * @param startables stream of resources to start
     * @return CompletableFuture<Void> that completes when all resources are started
     * @throws CompletionException (wrapping underlying exception) if any resource fails to start
     */
    public static CompletableFuture<Void> deepStart(Stream<? extends Startable> startables);
}

Key Features:

  • Dependency-aware: Respects getDependencies() declarations
  • Parallel execution: Starts independent resources concurrently
  • Fail-fast: Stops and fails on first error
  • Async API: Uses CompletableFuture for non-blocking operations
  • Deduplication: Prevents starting the same resource multiple times

Usage Examples:

import org.testcontainers.lifecycle.Startables;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.container.Network;
import org.testcontainers.utility.DockerImageName;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CompletableFuture;

// Start containers with dependencies using varargs
GenericContainer<?> database = new GenericContainer<>(DockerImageName.parse("postgres:15"))
    .withExposedPorts(5432)
    .withEnv("POSTGRES_PASSWORD", "secret");

GenericContainer<?> cache = new GenericContainer<>(DockerImageName.parse("redis:7.0"))
    .withExposedPorts(6379);

GenericContainer<?> application = new GenericContainer<>(DockerImageName.parse("myapp:latest"))
    .withExposedPorts(8080)
    .dependsOn(database, cache);

// Start all containers respecting dependencies
CompletableFuture<Void> started = Startables.deepStart(database, cache, application);
started.join();  // Wait for all to complete

System.out.println("All containers started");

// Access containers
String dbHost = database.getHost();
Integer dbPort = database.getMappedPort(5432);
String appHost = application.getHost();
Integer appPort = application.getMappedPort(8080);

Using Collections:

import org.testcontainers.lifecycle.Startables;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.utility.DockerImageName;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;

List<GenericContainer<?>> services = new ArrayList<>();

// Create multiple service instances
for (int i = 0; i < 3; i++) {
    GenericContainer<?> service = new GenericContainer<>(
        DockerImageName.parse("myservice:latest")
    ).withExposedPorts(8080);
    services.add(service);
}

// Start all services in parallel
CompletableFuture<Void> allStarted = Startables.deepStart(services);
allStarted.join();

System.out.println("All " + services.size() + " services started");

Using Streams:

import org.testcontainers.lifecycle.Startables;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.utility.DockerImageName;
import java.util.stream.IntStream;

// Create containers using stream and start
IntStream.range(0, 5)
    .mapToObj(i -> new GenericContainer<>(DockerImageName.parse("redis:7.0"))
        .withExposedPorts(6379))
    .collect(java.util.stream.Collectors.toList())
    .stream()
    .peek(container -> Startables.deepStart(container).join());

Error Handling:

import org.testcontainers.lifecycle.Startables;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.utility.DockerImageName;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

GenericContainer<?> badContainer = new GenericContainer<>(
    DockerImageName.parse("nonexistent:latest")
);

GenericContainer<?> goodContainer = new GenericContainer<>(
    DockerImageName.parse("redis:7.0")
);

CompletableFuture<Void> startup = Startables.deepStart(badContainer, goodContainer);

try {
    startup.join();
} catch (Exception e) {
    System.err.println("Failed to start containers: " + e.getCause());
    e.getCause().printStackTrace();
}

Complex Dependency Graph:

import org.testcontainers.lifecycle.Startables;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.utility.DockerImageName;

// Build a complex service topology
GenericContainer<?> database = new GenericContainer<>(DockerImageName.parse("postgres:15"))
    .withExposedPorts(5432)
    .withEnv("POSTGRES_PASSWORD", "secret");

GenericContainer<?> cache = new GenericContainer<>(DockerImageName.parse("redis:7.0"))
    .withExposedPorts(6379);

GenericContainer<?> messageQueue = new GenericContainer<>(DockerImageName.parse("rabbitmq:3-management"))
    .withExposedPorts(5672, 15672);

GenericContainer<?> authService = new GenericContainer<>(DockerImageName.parse("auth-service:latest"))
    .withExposedPorts(8080)
    .dependsOn(database);

GenericContainer<?> apiService = new GenericContainer<>(DockerImageName.parse("api-service:latest"))
    .withExposedPorts(8080)
    .dependsOn(database, cache, authService);

GenericContainer<?> workerService = new GenericContainer<>(DockerImageName.parse("worker-service:latest"))
    .dependsOn(messageQueue, database);

// Start entire system with optimal parallelization
Startables.deepStart(
    database,
    cache,
    messageQueue,
    authService,
    apiService,
    workerService
).join();

System.out.println("Entire microservices system started");

// Startup order with this graph:
// Parallel: database, cache, messageQueue
// Then: authService (depends on database)
// Parallel to authService: apiService waits for all three
// Parallel to authService: workerService (depends on messageQueue, database)
// Final: apiService starts after authService completes

Test Integration:

import org.testcontainers.lifecycle.Startables;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import org.junit.jupiter.api.BeforeAll;
import java.util.concurrent.ExecutionException;

@Testcontainers
public class IntegrationTest {

    @Container
    private static final GenericContainer<?> database =
        new GenericContainer<>(DockerImageName.parse("postgres:15"))
            .withExposedPorts(5432)
            .withEnv("POSTGRES_PASSWORD", "secret");

    @Container
    private static final GenericContainer<?> application =
        new GenericContainer<>(DockerImageName.parse("myapp:latest"))
            .withExposedPorts(8080)
            .dependsOn(database);

    // Note: @Testcontainers annotation handles startup automatically
    // But you can also use deepStart for manual control

    @BeforeAll
    public static void setupWithManualStart() throws ExecutionException, InterruptedException {
        // If not using @Testcontainers, manually start with deepStart
        Startables.deepStart(database, application).get();
    }

    // Tests now run with both containers available
}

TestLifecycleAware Interface

Interface for containers and resources that need to react to test lifecycle events. This enables containers to perform test-specific initialization and cleanup, or capture diagnostics when tests fail.

/**
 * Interface for containers that are aware of and react to test lifecycle events.
 * Implementations can hook into test start and completion events to perform
 * test-specific initialization, cleanup, or diagnostic collection.
 *
 * Implementations of this interface will have their methods called by compatible
 * test integrations (JUnit 4, JUnit 5, TestNG, etc.).
 */
public interface TestLifecycleAware {
    /**
     * Called before a test starts.
     * Default implementation does nothing.
     *
     * Typical uses:
     * - Setup test-specific state in the container
     * - Clear caches or reset database state
     * - Configure per-test logging or monitoring
     * - Initialize test fixtures
     *
     * @param description metadata about the test about to run
     */
    default void beforeTest(TestDescription description) {}

    /**
     * Called after a test completes, whether success or failure.
     * Default implementation does nothing.
     *
     * Typical uses:
     * - Capture diagnostic logs if test failed
     * - Verify state assertions for testing the container itself
     * - Save test artifacts or results
     * - Cleanup test-specific resources
     * - Perform health checks
     *
     * @param description metadata about the completed test
     * @param throwable optional exception if the test failed
     *                  Present (non-empty) if test threw an exception
     *                  Empty (Optional.empty()) if test passed
     */
    default void afterTest(TestDescription description, Optional<Throwable> throwable) {}
}

Implemented By:

  • Custom container classes extending GenericContainer
  • Test fixture containers
  • Monitoring and diagnostic containers

Usage Examples:

import org.testcontainers.lifecycle.TestLifecycleAware;
import org.testcontainers.lifecycle.TestDescription;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.utility.DockerImageName;
import java.util.Optional;

/**
 * PostgreSQL container that resets state between tests
 */
public class TestAwarePostgreSQLContainer
    extends GenericContainer<TestAwarePostgreSQLContainer>
    implements TestLifecycleAware {

    public TestAwarePostgreSQLContainer() {
        super(DockerImageName.parse("postgres:15"));
    }

    @Override
    protected void configure() {
        withExposedPorts(5432);
        withEnv("POSTGRES_PASSWORD", "test");
    }

    @Override
    public void beforeTest(TestDescription description) {
        try {
            // Clear all data before each test
            execInContainer("psql", "-U", "postgres", "-c",
                "DROP SCHEMA public CASCADE; CREATE SCHEMA public;");
            System.out.println("Database reset for test: " + description.getTestId());
        } catch (Exception e) {
            throw new RuntimeException("Failed to reset database", e);
        }
    }

    @Override
    public void afterTest(TestDescription description, Optional<Throwable> throwable) {
        if (throwable.isPresent()) {
            try {
                // Capture database state for failed tests
                String logs = execInContainer("psql", "-U", "postgres", "-c",
                    "SELECT * FROM information_schema.tables;")
                    .getStdout();
                System.out.println("Database tables on test failure (" +
                    description.getTestId() + "):\n" + logs);
            } catch (Exception e) {
                System.err.println("Failed to capture diagnostics: " + e.getMessage());
            }
        }
    }

    public String getJdbcUrl() {
        return String.format("jdbc:postgresql://%s:%d/postgres",
            getHost(),
            getMappedPort(5432));
    }
}

// Usage in tests
class MyDatabaseTest {
    static final TestAwarePostgreSQLContainer db =
        new TestAwarePostgreSQLContainer();

    @BeforeAll
    static void setup() {
        db.start();
    }

    // beforeTest() and afterTest() are called automatically by test framework
    // if integrated with Testcontainers
}

Monitoring Container Example:

import org.testcontainers.lifecycle.TestLifecycleAware;
import org.testcontainers.lifecycle.TestDescription;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.utility.DockerImageName;
import java.util.Optional;
import java.time.Instant;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

/**
 * Prometheus monitoring container that captures metrics per test
 */
public class PrometheusTestContainer
    extends GenericContainer<PrometheusTestContainer>
    implements TestLifecycleAware {

    private final Path metricsDir;
    private Instant testStartTime;

    public PrometheusTestContainer(Path metricsDir) {
        super(DockerImageName.parse("prom/prometheus:latest"));
        this.metricsDir = metricsDir;
    }

    @Override
    protected void configure() {
        withExposedPorts(9090);
    }

    @Override
    public void beforeTest(TestDescription description) {
        testStartTime = Instant.now();
        System.out.println("Starting metrics collection for: " +
            description.getTestId());
    }

    @Override
    public void afterTest(TestDescription description, Optional<Throwable> throwable) {
        try {
            // Query metrics from Prometheus
            String metrics = execInContainer("wget", "-O", "-",
                "http://localhost:9090/api/v1/query?query=up")
                .getStdout();

            // Save to test-specific file
            String filename = description.getFilesystemFriendlyName() + ".metrics";
            Path metricsFile = metricsDir.resolve(filename);
            Files.createDirectories(metricsFile.getParent());
            Files.writeString(metricsFile, metrics);

            System.out.println("Metrics saved to: " + metricsFile);

            if (throwable.isPresent()) {
                System.out.println("Test " + description.getTestId() +
                    " FAILED. Metrics available at: " + metricsFile);
            }
        } catch (Exception e) {
            System.err.println("Failed to collect metrics: " + e.getMessage());
        }
    }

    public String getPrometheusUrl() {
        return String.format("http://%s:%d",
            getHost(),
            getMappedPort(9090));
    }
}

// Usage
class PerformanceTest {
    static final Path METRICS_DIR = Paths.get("target/test-metrics");

    static final PrometheusTestContainer prometheus =
        new PrometheusTestContainer(METRICS_DIR);

    @BeforeAll
    static void setup() {
        prometheus.start();
    }

    // beforeTest() captures start time
    // afterTest() saves metrics and diagnostics
}

Logging Container Example:

import org.testcontainers.lifecycle.TestLifecycleAware;
import org.testcontainers.lifecycle.TestDescription;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.utility.DockerImageName;
import java.util.Optional;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;

/**
 * Application container that saves logs per test
 */
public class LogCapturingContainer
    extends GenericContainer<LogCapturingContainer>
    implements TestLifecycleAware {

    private final Path logsDir;

    public LogCapturingContainer(Path logsDir) {
        super(DockerImageName.parse("myapp:latest"));
        this.logsDir = logsDir;
    }

    @Override
    protected void configure() {
        withExposedPorts(8080);
    }

    @Override
    public void afterTest(TestDescription description, Optional<Throwable> throwable) {
        try {
            String logs = getLogs();
            String filename = description.getFilesystemFriendlyName() + ".log";
            Path logFile = logsDir.resolve(filename);
            Files.createDirectories(logFile.getParent());
            Files.writeString(logFile, logs);

            if (throwable.isPresent()) {
                System.out.println("Test failed. Full logs saved to: " + logFile);

                // Log last 50 lines to console for quick debugging
                String[] lines = logs.split("\n");
                int start = Math.max(0, lines.length - 50);
                System.out.println("\nLast 50 lines of container logs:");
                for (int i = start; i < lines.length; i++) {
                    System.out.println(lines[i]);
                }
            }
        } catch (Exception e) {
            System.err.println("Failed to capture logs: " + e.getMessage());
        }
    }
}

TestDescription Interface

Metadata interface providing information about the test being executed. Passed to TestLifecycleAware callbacks.

/**
 * Describes a test being executed.
 * Provides metadata that can be used to customize container behavior per test.
 */
public interface TestDescription {
    /**
     * Get the unique test identifier.
     * This is typically the fully qualified method name: "package.Class.method"
     * or sometimes includes parameters for parameterized tests.
     *
     * Suitable for:
     * - Logging and diagnostics
     * - Creating test-specific resources or tags
     * - Identifying tests in container state
     *
     * @return unique test identifier string
     */
    String getTestId();

    /**
     * Get a filesystem-friendly name for the test.
     * This is a sanitized version of the test ID with special characters removed/replaced.
     * Safe to use as a filename or directory name.
     *
     * Conversions applied:
     * - Colons, slashes, and other special characters replaced with underscores
     * - Path separators normalized to underscores
     * - Length may be limited depending on filesystem restrictions
     *
     * Examples:
     * - "com.example.MyTest.testExample" -> "com_example_MyTest_testExample"
     * - "com.example.ParamTest.testWith[1]" -> "com_example_ParamTest_testWith_1_"
     *
     * Suitable for:
     * - Creating test-specific log files
     * - Creating test-specific directories
     * - Creating test-specific artifacts
     *
     * @return filesystem-safe test name string
     */
    String getFilesystemFriendlyName();
}

Typical Implementation in Test Frameworks:

import org.testcontainers.lifecycle.TestDescription;
import java.lang.reflect.Method;

/**
 * Example implementation for JUnit 5
 */
public class JUnit5TestDescription implements TestDescription {
    private final String testId;
    private final String filesystemFriendlyName;

    public JUnit5TestDescription(Class<?> testClass, Method testMethod) {
        this.testId = testClass.getName() + "." + testMethod.getName();
        this.filesystemFriendlyName = sanitizeForFilesystem(testId);
    }

    @Override
    public String getTestId() {
        return testId;
    }

    @Override
    public String getFilesystemFriendlyName() {
        return filesystemFriendlyName;
    }

    private static String sanitizeForFilesystem(String testId) {
        return testId
            .replaceAll("[:\\\\/<>|?*]", "_")  // Replace special chars
            .replaceAll("\\[", "_")             // Replace bracket
            .replaceAll("\\]", "_")
            .replaceAll("\\s+", "_");           // Replace spaces
    }
}

Usage in TestLifecycleAware Implementations:

import org.testcontainers.lifecycle.TestLifecycleAware;
import org.testcontainers.lifecycle.TestDescription;
import org.testcontainers.containers.GenericContainer;
import java.util.Optional;
import java.nio.file.Paths;

public class DiagnosticContainer
    extends GenericContainer<DiagnosticContainer>
    implements TestLifecycleAware {

    @Override
    public void beforeTest(TestDescription description) {
        String testId = description.getTestId();
        String friendlyName = description.getFilesystemFriendlyName();

        System.out.println("Starting test: " + testId);
        System.out.println("  Artifact directory: " + friendlyName);

        // Create test-specific directories using friendly name
        java.nio.file.Path artifactDir = Paths.get("target", "artifacts", friendlyName);
        try {
            java.nio.file.Files.createDirectories(artifactDir);
        } catch (Exception e) {
            System.err.println("Failed to create artifact directory: " + e);
        }
    }

    @Override
    public void afterTest(TestDescription description, Optional<Throwable> throwable) {
        String testId = description.getTestId();
        String friendlyName = description.getFilesystemFriendlyName();

        if (throwable.isPresent()) {
            System.out.println("Test FAILED: " + testId);
            System.out.println("  Diagnostics in: " + friendlyName);

            // Save detailed diagnostics
            try {
                String logs = getLogs();
                java.nio.file.Path logFile = Paths.get(
                    "target", "artifacts", friendlyName, "container.log");
                java.nio.file.Files.writeString(logFile, logs);
            } catch (Exception e) {
                System.err.println("Failed to save logs: " + e);
            }
        } else {
            System.out.println("Test PASSED: " + testId);
        }
    }
}

Complete Usage Example

import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.Network;
import org.testcontainers.lifecycle.Startables;
import org.testcontainers.lifecycle.TestLifecycleAware;
import org.testcontainers.lifecycle.TestDescription;
import org.testcontainers.utility.DockerImageName;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.Files;

/**
 * Complete example of lifecycle management in a test
 */
public class LifecycleManagementExample {

    /**
     * Custom container with lifecycle awareness
     */
    public static class ApplicationContainer
        extends GenericContainer<ApplicationContainer>
        implements TestLifecycleAware {

        private final Path diagnosticsDir;

        public ApplicationContainer(Path diagnosticsDir) {
            super(DockerImageName.parse("myapp:latest"));
            this.diagnosticsDir = diagnosticsDir;
        }

        @Override
        protected void configure() {
            withExposedPorts(8080)
                .withEnv("LOG_LEVEL", "INFO");
        }

        @Override
        public void beforeTest(TestDescription description) {
            System.out.println("Setting up test: " + description.getTestId());
            try {
                // Reset application state for each test
                execInContainer("curl", "-X", "POST", "http://localhost:8080/api/reset");
            } catch (Exception e) {
                throw new RuntimeException("Failed to reset application", e);
            }
        }

        @Override
        public void afterTest(TestDescription description, Optional<Throwable> throwable) {
            try {
                String logs = getLogs();
                Path logFile = diagnosticsDir.resolve(
                    description.getFilesystemFriendlyName() + ".log");
                Files.createDirectories(logFile.getParent());
                Files.writeString(logFile, logs);

                if (throwable.isPresent()) {
                    System.out.println("Test failed. Logs: " + logFile);
                }
            } catch (Exception e) {
                System.err.println("Failed to save diagnostics: " + e);
            }
        }

        public String getAppUrl() {
            return String.format("http://%s:%d", getHost(), getMappedPort(8080));
        }
    }

    // Setup containers with dependencies
    static Network network = Network.newNetwork();

    static GenericContainer<?> database = new GenericContainer<>(
        DockerImageName.parse("postgres:15")
    )
        .withNetwork(network)
        .withNetworkAliases("postgres")
        .withExposedPorts(5432)
        .withEnv("POSTGRES_PASSWORD", "test");

    static GenericContainer<?> cache = new GenericContainer<>(
        DockerImageName.parse("redis:7.0")
    )
        .withNetwork(network)
        .withNetworkAliases("redis")
        .withExposedPorts(6379);

    static ApplicationContainer app = new ApplicationContainer(
        Paths.get("target", "test-diagnostics")
    )
        .withNetwork(network)
        .withEnv("DB_HOST", "postgres")
        .withEnv("CACHE_HOST", "redis")
        .withExposedPorts(8080)
        .dependsOn(database, cache);

    @BeforeAll
    static void startAllContainers() {
        // Start all containers with optimized parallelization
        Startables.deepStart(database, cache, app).join();
        System.out.println("All containers started");
    }

    @Test
    void testApplicationFunctionality() {
        String appUrl = app.getAppUrl();
        System.out.println("Testing application at: " + appUrl);

        // Test application
        // beforeTest() callback is automatically called
        // afterTest() callback is automatically called on completion
    }

    @Test
    void testWithDatabase() {
        String dbHost = database.getHost();
        Integer dbPort = database.getMappedPort(5432);
        System.out.println("Database at: " + dbHost + ":" + dbPort);

        // Test with database
    }

    @Test
    void testWithCache() {
        String cacheHost = cache.getHost();
        Integer cachePort = cache.getMappedPort(6379);
        System.out.println("Cache at: " + cacheHost + ":" + cachePort);

        // Test with cache
    }
}

Summary

The Testcontainers lifecycle management APIs provide:

  1. Startable Interface - Core abstraction for any resource with start/stop lifecycle
  2. Startables Utility - Efficient parallel container startup with dependency resolution
  3. TestLifecycleAware Interface - Test-specific initialization and diagnostics
  4. TestDescription Interface - Test metadata for customization and logging

These APIs enable:

  • Clean resource management with try-with-resources
  • Efficient parallel startup of dependent containers
  • Test-specific container configuration and state management
  • Automatic diagnostic collection on test failures
  • Integration with testing frameworks