Welcome back, intrepid developer! You’ve mastered spinning up powerful, ephemeral environments with Testcontainers. But what happens when things don’t go as planned? When your containerized application doesn’t start, or your test fails in unexpected ways? That’s where debugging comes in!

In this chapter, we’re going to transform you into a debugging detective for your Testcontainers-powered tests. We’ll explore why debugging containers can be a unique challenge and equip you with the essential tools and techniques to peer inside your test environment, understand what’s happening, and fix problems. From poring over container logs to directly interacting with running containers and even performing remote debugging of your application within a Testcontainer, you’ll gain the confidence to troubleshoot any issue.

Before we dive in, make sure you’re comfortable with the basics of using Testcontainers in your chosen language (Java, JavaScript/TypeScript, or Python) from our previous chapters. Let’s get started on becoming master debuggers!

The Art of Debugging Containerized Tests

Debugging traditional applications can be tricky enough, but when you introduce containers into the mix, there’s an extra layer of abstraction and isolation. Your application code is running inside a separate, ephemeral environment, managed by Docker, and orchestrated by Testcontainers. This isolation, while fantastic for reproducible tests, can initially make debugging feel like trying to solve a puzzle blindfolded.

The key is to remember that at their heart, Testcontainers are just Docker containers. This means all the powerful Docker debugging tools are still at your disposal. Testcontainers just gives you a programmatic way to control them.

Why Debugging Containers is Different (and Fun!)

  1. Isolation: Containers are designed to be isolated from your host system. This means your application logs and processes aren’t directly visible on your host unless you explicitly expose them.
  2. Ephemerality: By default, Testcontainers are throwaway. They’re created for a test, and once the test (or test suite) finishes, they’re cleaned up. This is great for resource management but means you can’t easily inspect a container after a test failure without a little trickery.
  3. Black Box Tendencies: If your application fails to start inside a container, or a dependency isn’t behaving, it can feel like a black box. You need ways to “look inside.”

Let’s map out a general strategy for debugging:

flowchart TD A[Test Fails or Behaves Unexpectedly] --> B{Check Logs?} B -->|Yes| C[Testcontainers Internal Issues?] B -->|No| D[Move to Container-Specific Debugging] C --> C1[Increase Testcontainers Log Verbosity] C1 --> E[Problem Solved?] D --> F{Check Container Logs?} F -->|Yes| G[Use `container.getLogs` or `docker logs`] G --> G1[Analyze Application/Service Output] G1 --> E F -->|No, or Logs Insufficient| H{Inspect Running Container?} H -->|Yes| I[Use `docker ps`, `docker inspect`, `docker exec`] I --> I1[Manually check state, config, run commands inside container] I1 --> E H -->|No, still stuck| J{Remote Debug Application in Container?} J -->|Yes| K[Configure Debug Port & Map it] K --> K1[Attach IDE/Debugger to Container Process] K1 --> E E -->|No| A E -->|Yes| L[Test Passes! Cleanup.]

Essential Debugging Tools and Concepts

  1. Container Logs: The most fundamental tool. Your application, databases, and services inside containers will output logs. Testcontainers provides ways to capture these, and Docker itself keeps them.
  2. Docker CLI Tools (docker ps, docker logs, docker exec, docker inspect): These are your best friends. They let you see what containers are running, view their logs directly, execute commands inside a running container, and get detailed configuration information.
  3. Testcontainers’ Ryuk Process: Testcontainers uses a small, internal helper container called Ryuk to ensure all containers started by Testcontainers are cleaned up after your tests finish. This is normally excellent, but for debugging failed tests, it can remove the evidence too quickly! We’ll learn how to temporarily disable it.
  4. Remote Debugging: For complex issues, you might need to connect your IDE’s debugger directly to your application process running inside the Testcontainer. This allows you to set breakpoints, step through code, and inspect variables.

Let’s look at how to apply these in practice across our languages.

Step-by-Step Debugging Techniques

We’ll cover three primary techniques: capturing logs, keeping containers alive for inspection, and remote debugging.

1. Capturing and Analyzing Container Logs

Your application’s logs are often the first place to look for clues. Testcontainers makes it easy to grab these logs programmatically.

Java Example

For Java with Testcontainers, you can easily access logs via the getLogs() method or use a Slf4jLogConsumer.

First, ensure you have a basic Testcontainers setup. Let’s use a PostgreSQL container.

pom.xml (relevant dependencies, as of 2026-02-14)

<dependencies>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers</artifactId>
        <version>1.19.4</version> <!-- Current stable as of Feb 2026, check Testcontainers.org for latest -->
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <version>1.19.4</version> <!-- Matches core Testcontainers version -->
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-api</artifactId>
        <version>5.10.1</version> <!-- JUnit 5 latest stable -->
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter-engine</artifactId>
        <version>5.10.1</version>
        <scope>test</scope>
    </dependency>
</dependencies>

PostgreSqlLogTest.java

package com.example.testcontainers;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import org.slf4j.LoggerFactory; // For Slf4jLogConsumer if you prefer

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;

import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;

@Testcontainers
class PostgreSqlLogTest {

    // Using an explicit DockerImageName with version for clarity and stability
    @Container
    private static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:16.2"))
            .withDatabaseName("testdb")
            .withUsername("testuser")
            .withPassword("testpass");

    @BeforeEach
    void setup() {
        System.out.println("PostgreSQL container started at: " + postgres.getJdbcUrl());
    }

    @Test
    void testDatabaseConnectionAndBadQuery() {
        try (Connection connection = DriverManager.getConnection(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword());
             Statement statement = connection.createStatement()) {

            // This query should pass
            statement.execute("CREATE TABLE users (id SERIAL PRIMARY KEY, name VARCHAR(100));");
            statement.execute("INSERT INTO users (name) VALUES ('Alice');");

            // This query is intentionally malformed to cause an error
            // Note: Postgres will log an error for this.
            statement.execute("SELECT * FROM non_existent_table;"); // This will throw an SQLException

            ResultSet resultSet = statement.executeQuery("SELECT name FROM users WHERE name = 'Alice';");
            assertTrue(resultSet.next(), "Should find Alice");

        } catch (Exception e) {
            System.err.println("Test failed with exception: " + e.getMessage());
            // It's crucial here to get the logs of the container if the test fails
            System.out.println("--- PostgreSQL Container Logs ---");
            System.out.println(postgres.getLogs()); // Print all container logs
            System.out.println("-------------------------------");
            fail("Database operation failed: " + e.getMessage());
        }
    }
}

Explanation:

  • We intentionally added a bad SQL query (SELECT * FROM non_existent_table;) to simulate a failure within the database operation.
  • In the catch block, after an Exception is caught, we use postgres.getLogs() to retrieve all the logs emitted by the PostgreSQL container up to that point. This output will include the database’s own error messages, which are invaluable for debugging.
  • Pro-tip: For real-time logging, especially during startup, Testcontainers supports Slf4jLogConsumer. You can add .withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger(PostgreSqlLogTest.class))) to your container definition to pipe container logs directly to your test runner’s console.

JavaScript/TypeScript Example

For Node.js with testcontainers-node, the logs() method provides a stream of output.

First, ensure testcontainers is installed:

npm install --save-dev testcontainers @types/testcontainers # For TypeScript
# or
yarn add --dev testcontainers

postgresql-log.test.ts

import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { GenericContainer, StartedTestContainer } from 'testcontainers';
import { Client } from 'pg'; // PostgreSQL client for Node.js

describe('PostgreSQL Log Test', () => {
  let postgresContainer: StartedTestContainer;
  let client: Client;

  // IMPORTANT: For debugging, it's often useful to keep containers alive after test failure.
  // We'll discuss this in the next section. For now, rely on logs.

  beforeAll(async () => {
    postgresContainer = await new PostgreSqlContainer('postgres:16.2')
      .withDatabase('testdb')
      .withUsername('testuser')
      .withPassword('testpass')
      .start();

    // You can also capture logs in real-time if needed, but for simple failures,
    // getting them at the end is often sufficient.
    postgresContainer.on(GenericContainer.ON_CONTAINER_START, async () => {
      console.log(`PostgreSQL container started: ${postgresContainer.getHost()}:${postgresContainer.getMappedPort(5432)}`);
    });

    client = new Client({
      host: postgresContainer.getHost(),
      port: postgresContainer.getMappedPort(5432),
      user: 'testuser',
      password: 'testpass',
      database: 'testdb',
    });
    await client.connect();
  }, 30000); // 30 second timeout for container startup

  afterAll(async () => {
    await client.end();
    await postgresContainer.stop();
  });

  it('should connect to PostgreSQL and log errors for bad query', async () => {
    try {
      await client.query("CREATE TABLE users (id SERIAL PRIMARY KEY, name VARCHAR(100));");
      await client.query("INSERT INTO users (name) VALUES ('Alice');");

      // Intentionally malformed query
      await client.query("SELECT * FROM non_existent_table;");

      const res = await client.query("SELECT name FROM users WHERE name = 'Alice';");
      expect(res.rows.length).toBe(1);
      expect(res.rows[0].name).toBe('Alice');

    } catch (error: any) {
      console.error("Test failed with exception:", error.message);
      const logs = await postgresContainer.logs();
      console.log('--- PostgreSQL Container Logs ---');
      logs.on('data', line => console.log(`[POSTGRES_LOG] ${line}`));
      // We need to wait for the logs stream to finish or collect it
      // For short tests, sometimes collecting them after the fact is more reliable
      // Or simply let the stream print until the test process exits
      await new Promise(resolve => setTimeout(resolve, 1000)); // Give some time for logs to print
      console.log('-------------------------------');
      fail(`Database operation failed: ${error.message}`);
    }
  }, 10000); // 10 second timeout for test execution
});

Explanation:

  • Similar to Java, we introduce a bad query.
  • In the catch block, postgresContainer.logs() returns a Node.js stream. We attach a data listener to print each line. Since streams are asynchronous, a small setTimeout is added to give the logs a chance to print before the test potentially exits. For more robust log capture, you might collect stream data into a buffer.
  • Pro-tip: For direct debugging of the testcontainers-node library itself, you can set the DEBUG=testcontainers environment variable when running your tests to get verbose output.

Python Example

For Python, pytest-docker or testcontainers-python provide mechanisms to get logs. We’ll use testcontainers-python here.

First, install dependencies:

pip install testcontainers-python[postgresql] psycopg2-binary
# testcontainers-python v4.14.1 (Jan 2026)

test_postgresql_logs.py

import pytest
from testcontainers.postgres import PostgresContainer
import psycopg2

@pytest.fixture(scope="module")
def postgres_container():
    # Use an explicit image and version
    with PostgresContainer("postgres:16.2", dbname="testdb", user="testuser", password="testpass") as postgres:
        postgres.start()
        print(f"PostgreSQL container started at: {postgres.get_connection_url()}")
        yield postgres

@pytest.fixture(scope="function")
def db_connection(postgres_container):
    conn = None
    try:
        conn = psycopg2.connect(postgres_container.get_connection_url())
        yield conn
    finally:
        if conn:
            conn.close()

def test_database_connection_and_bad_query(postgres_container, db_connection):
    try:
        cursor = db_connection.cursor()
        cursor.execute("CREATE TABLE users (id SERIAL PRIMARY KEY, name VARCHAR(100));")
        cursor.execute("INSERT INTO users (name) VALUES ('Alice');")
        db_connection.commit()

        # Intentionally malformed query
        cursor.execute("SELECT * FROM non_existent_table;")
        db_connection.commit() # This commit will likely fail or cause earlier error to surface

        cursor.execute("SELECT name FROM users WHERE name = 'Alice';")
        result = cursor.fetchone()
        assert result is not None and result[0] == 'Alice'

    except psycopg2.Error as e:
        print(f"Test failed with psycopg2.Error: {e}")
        # Get logs from the container
        print("--- PostgreSQL Container Logs ---")
        # In testcontainers-python, get_logs() returns a single string of all logs
        print(postgres_container.get_logs())
        print("-------------------------------")
        pytest.fail(f"Database operation failed: {e}")
    except Exception as e:
        print(f"Test failed with unexpected exception: {e}")
        print("--- PostgreSQL Container Logs ---")
        print(postgres_container.get_logs())
        print("-------------------------------")
        pytest.fail(f"Unexpected error: {e}")

Explanation:

  • Similar pattern: a malformed query to trigger an error.
  • In the except block, postgres_container.get_logs() is called to retrieve all accumulated logs from the container, which are then printed to the console.
  • pytest.fail() is used to explicitly mark the test as failed and provide the error message.

2. Keeping Containers Alive for Inspection (TESTCONTAINERS_RYUK_DISABLED)

When a test fails, Testcontainers, by default, cleans up the containers immediately. This is usually desirable, but for debugging, you might want to “pause” the cleanup process to inspect the container manually using Docker CLI tools.

You can achieve this by setting the environment variable TESTCONTAINERS_RYUK_DISABLED=true before running your tests.

How it works: Ryuk is a small container that Testcontainers launches to track and clean up other containers. By setting TESTCONTAINERS_RYUK_DISABLED=true, you tell Testcontainers not to start Ryuk. This means any containers launched by Testcontainers will remain running after your test JVM/process exits, allowing you to examine them.

Important Considerations:

  • Do NOT use this in CI/CD or production environments. This is purely for local debugging. If you forget to clean up manually, you’ll accumulate stale containers.
  • Manual Cleanup Required: When Ryuk is disabled, you are responsible for stopping and removing the containers manually using docker rm -f <container_id> or docker stop <container_id>.

Steps to use TESTCONTAINERS_RYUK_DISABLED:

  1. Open your terminal.
  2. Set the environment variable:
    • Linux/macOS: export TESTCONTAINERS_RYUK_DISABLED=true
    • Windows (Command Prompt): set TESTCONTAINERS_RYUK_DISABLED=true
    • Windows (PowerShell): $env:TESTCONTAINERS_RYUK_DISABLED="true"
  3. Run your tests as usual.
  4. After the tests finish (and fail), open a new terminal window (or ensure the variable is still set in your current one if you don’t launch a new shell).
  5. Use Docker commands to inspect:
    • docker ps -a: Lists all containers (running and exited). Look for the name of your container (e.g., testcontainers/postgresql).
    • docker logs <container_id_or_name>: View the logs again.
    • docker exec -it <container_id_or_name> bash: (or sh, psql, etc.) Execute a shell or a specific command inside the running container. This is incredibly powerful for checking file systems, running database queries directly, or inspecting application state.
    • docker inspect <container_id_or_name>: Get detailed low-level information about the container’s configuration, network, volumes, etc.
  6. Once you’re done, remember to clean up: docker rm -f <container_id_or_name>.

3. Remote Debugging an Application Inside a Testcontainer

This is the “heavy artillery” of debugging. If logs and inspection aren’t enough, you can connect your IDE’s debugger directly to your application process running inside a Testcontainer. This lets you set breakpoints, step through code, and inspect variables just as you would with a locally running application.

The general principle involves:

  1. Configuring your application inside the container to start in debug mode and listen on a specific port.
  2. Mapping this debug port from the container to a port on your host machine.
  3. Configuring your IDE to connect to this host port for remote debugging.

Java (Spring Boot Example)

Let’s imagine you have a Spring Boot application that connects to a database. We’ll run this application inside a Testcontainer and debug it.

Dockerfile for a simple Spring Boot app

FROM openjdk:17-jdk-slim
VOLUME /tmp
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} app.jar
# Expose default Spring Boot port and debug port
EXPOSE 8080
EXPOSE 5005
ENTRYPOINT ["java", "-jar", "app.jar"]

MySpringBootDebugTest.java

package com.example.testcontainers;

import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.Network;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

import static org.junit.jupiter.api.Assertions.assertEquals;

@Testcontainers
class MySpringBootDebugTest {

    private static Network network = Network.newNetwork();

    @Container
    private static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:16.2"))
            .withDatabaseName("mydb")
            .withUsername("myuser")
            .withPassword("mypass")
            .withNetwork(network)
            .withNetworkAliases("postgres-db"); // Alias for app to connect to

    @Container
    private static GenericContainer<?> myApp = new GenericContainer<>(DockerImageName.parse("my-spring-boot-app:latest")) // Replace with your app's image
            .withNetwork(network)
            .withEnv("SPRING_DATASOURCE_URL", "jdbc:postgresql://postgres-db:5432/mydb")
            .withEnv("SPRING_DATASOURCE_USERNAME", "myuser")
            .withEnv("SPRING_DATASOURCE_PASSWORD", "mypass")
            // Crucial for remote debugging: enable debug agent and expose port
            // Use JAVA_TOOL_OPTIONS for flexibility, common in containerized apps
            .withEnv("JAVA_TOOL_OPTIONS", "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005")
            .withExposedPorts(8080, 5005) // Expose app port and debug port
            .waitingFor(Wait.forHttp("/actuator/health").forPort(8080).withStartupTimeout(Duration.ofSeconds(60)));

    @Test
    void testAppWithRemoteDebuggingCapability() throws IOException, InterruptedException {
        // Log the debug port for easy IDE connection
        System.out.println("Spring Boot App Debug Port (Host): " + myApp.getMappedPort(5005));
        System.out.println("Spring Boot App HTTP Port (Host): " + myApp.getMappedPort(8080));

        // Now, manually attach your IDE's remote debugger to localhost:<mapped_debug_port> (e.g., 5005)
        // Set a breakpoint in your Spring Boot application's code.

        HttpClient client = HttpClient.newHttpClient();
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create("http://" + myApp.getHost() + ":" + myApp.getMappedPort(8080) + "/hello")) // Replace with your actual endpoint
                .build();

        HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());

        assertEquals(200, response.statusCode());
        // Assert on response content if needed
        System.out.println("App response: " + response.body());

        // Keep the test running briefly to allow debugger interaction if needed
        // Thread.sleep(5000);
    }
}

Explanation:

  • my-spring-boot-app:latest: You’d replace this with the actual Docker image of your Spring Boot application. You’d build this image locally first (docker build -t my-spring-boot-app:latest .).
  • JAVA_TOOL_OPTIONS: This environment variable is a common way to pass JVM options, including debugging options, to a Java application within a container.
    • agentlib:jdwp: Activates the Java Debug Wire Protocol agent.
    • transport=dt_socket: Specifies socket transport.
    • server=y: Makes the JVM listen for a debugger connection.
    • suspend=n: Allows the application to start normally and wait for a debugger to attach. If suspend=y, the application would pause immediately upon startup until a debugger connects.
    • address=*:5005: Tells the debugger to listen on port 5005 on all interfaces.
  • .withExposedPorts(8080, 5005): Testcontainers exposes both your application’s HTTP port (8080) and the debug port (5005).
  • getMappedPort(5005): This provides the dynamically assigned host port that maps to the container’s internal debug port. You’ll use this host port in your IDE.

How to connect with IntelliJ IDEA:

  1. Go to Run -> Edit Configurations....
  2. Add a new Remote JVM Debug configuration.
  3. Set Host to localhost and Port to the mapped debug port printed by your test (e.g., 5005).
  4. Run your Testcontainers test. Once the application container starts, quickly switch to IntelliJ and “Debug” your newly created Remote JVM Debug configuration. Your IDE should connect, and you can now set breakpoints in your Spring Boot application code.

JavaScript/TypeScript (Node.js Example)

For Node.js, we use the --inspect flag.

Dockerfile for a simple Node.js app

FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
EXPOSE 9229 # Default Node.js debug port
CMD ["node", "src/index.js"]

my-node-app.test.ts

import { GenericContainer, StartedTestContainer } from 'testcontainers';
import { Wait } from 'testcontainers';
import fetch from 'node-fetch'; // npm install node-fetch@2 for older CommonJS, or use global fetch in Node 18+

describe('My Node.js App Debug Test', () => {
  let appContainer: StartedTestContainer;

  beforeAll(async () => {
    appContainer = await new GenericContainer('my-node-app:latest') // Replace with your app's image
      .withExposedPorts(3000, 9229) // Expose app port and debug port
      // Enable Node.js inspector
      .withCommand(['node', '--inspect=0.0.0.0:9229', 'src/index.js']) // Listen on all interfaces
      .waitingFor(Wait.forHttp("/health").forPort(3000).withStartupTimeout(120000))
      .start();

    console.log("Node.js App Debug Port (Host):", appContainer.getMappedPort(9229));
    console.log("Node.js App HTTP Port (Host):", appContainer.getMappedPort(3000));

    // Wait a moment for the debugger to be ready if suspend=n,
    // or quickly attach your debugger if suspend=y
    await new Promise(resolve => setTimeout(resolve, 2000));

  }, 180000); // 3 minute timeout for container startup

  afterAll(async () => {
    await appContainer.stop();
  });

  it('should respond to an HTTP request', async () => {
    const response = await fetch(`http://${appContainer.getHost()}:${appContainer.getMappedPort(3000)}/`);
    const text = await response.text();
    expect(response.status).toBe(200);
    expect(text).toContain('Hello from Node.js!');
  }, 10000);
});

Explanation:

  • my-node-app:latest: Replace with your actual Node.js app image.
  • --inspect=0.0.0.0:9229: This command-line argument tells Node.js to start its inspector (debugger) on port 9229, listening on all network interfaces inside the container.
  • .withExposedPorts(3000, 9229): Exposes both the app’s port (3000) and the debugger port (9229).
  • getMappedPort(9229): Get the host-mapped port for the debugger.

How to connect with VS Code:

  1. In VS Code, go to Run and Debug tab.
  2. Click create a launch.json file if you don’t have one.
  3. Add a new configuration of type Node.js: Attach to Process. Modify it to look like this:
    {
        "name": "Attach to Testcontainers Node.js App",
        "type": "node",
        "request": "attach",
        "address": "localhost",
        "port": 9229, // Use the mapped port from getMappedPort(9229)
        "localRoot": "${workspaceFolder}",
        "remoteRoot": "/app" // This is the WORKDIR in your Dockerfile
    }
    
  4. Run your Testcontainers test. Once the myApp container starts and prints the mapped debug port, quickly start the “Attach to Testcontainers Node.js App” debugger in VS Code.

Python (Flask/Django with debugpy Example)

Python applications often use a dedicated debugging library like debugpy.

Dockerfile for a simple Flask app

FROM python:3.10-slim-buster
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 5000 # Flask default port
EXPOSE 5678 # debugpy default port
CMD ["python", "app.py"]

app.py (simple Flask app with debugpy integration)

import debugpy
from flask import Flask

# Check for debugpy environment variable to enable debugging
if debugpy.is_client_connected():
    print("Debugger is attached. Ready to debug.")
else:
    # This block allows you to start the debugger server before your app logic,
    # and wait for a client to attach.
    # It's better to configure this via environment variables or CLI flags
    # rather than hardcoding in prod, but for an example, this works.
    try:
        debugpy.listen(("0.0.0.0", 5678)) # Listen on all interfaces on port 5678
        print("Debugpy server listening on 0.0.0.0:5678")
        # debugpy.wait_for_client() # Uncomment if you want the app to *suspend* until debugger attaches
        # print("Debugger client connected.")
    except Exception as e:
        print(f"Error starting debugpy: {e}")

app = Flask(__name__)

@app.route('/')
def hello():
    # Set a breakpoint here in your IDE once connected
    message = "Hello from Flask in Testcontainers!"
    return message

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

test_flask_debug.py

import pytest
from testcontainers.core.generic import GenericContainer
from testcontainers.core.waiting_utils import wait_for_http_status
import requests

@pytest.fixture(scope="module")
def flask_app_container():
    # Ensure your Flask Docker image is built locally first:
    # docker build -t my-flask-app:latest .
    container = GenericContainer("my-flask-app:latest") \
        .with_exposed_ports(5000, 5678) \
        .with_env("PYTHONUNBUFFERED", "1") # Important for real-time logs in Python

    # No need to explicitly add debugpy command here if it's in app.py
    # or enabled by env vars in app.py.
    # If using CLI flag, it would be .with_command(["python", "-m", "debugpy", "--listen", "0.0.0.0:5678", "--wait-for-client", "app.py"])

    with container as c:
        c.start()
        # Wait for HTTP endpoint to be ready
        wait_for_http_status(f"http://{c.get_host()}:{c.get_mapped_port(5000)}/", 200, timeout=60)
        print(f"Flask App Debug Port (Host): {c.get_mapped_port(5678)}")
        print(f"Flask App HTTP Port (Host): {c.get_mapped_port(5000)}")
        yield c

def test_flask_app_endpoint(flask_app_container):
    # Connect your VS Code debugger now to localhost:mapped_debug_port
    # and set a breakpoint in app.py
    host = flask_app_container.get_host()
    port = flask_app_container.get_mapped_port(5000)
    response = requests.get(f"http://{host}:{port}/")
    assert response.status_code == 200
    assert "Hello from Flask" in response.text

Explanation:

  • my-flask-app:latest: Replace with your Flask app image.
  • debugpy.listen(("0.0.0.0", 5678)): In app.py, this line tells debugpy to start a debugging server, listening on port 5678 on all interfaces.
  • .with_exposed_ports(5000, 5678): Exposes both the app’s port (5000) and the debug port (5678).
  • get_mapped_port(5678): Gets the host-mapped port for the debugger.

How to connect with VS Code:

  1. As with Node.js, create or modify your launch.json file.
  2. Add a new configuration of type Python: Remote Attach. Modify it:
    {
        "name": "Python: Remote Attach (Testcontainers)",
        "type": "python",
        "request": "attach",
        "connect": {
            "host": "localhost",
            "port": 5678 // Use the mapped port from get_mapped_port(5678)
        },
        "pathMappings": [
            {
                "localRoot": "${workspaceFolder}",
                "remoteRoot": "/app" // This is the WORKDIR in your Dockerfile
            }
        ],
        "justMyCode": false // Set to false to debug library code if needed
    }
    
  3. Run your Testcontainers test. Once the flask_app_container starts and prints the mapped debug port, quickly start the “Python: Remote Attach (Testcontainers)” debugger in VS Code.

Mini-Challenge: Debug a Failing Kafka Test

Let’s put your debugging skills to the test!

Scenario: You have a Testcontainers-based test that uses Kafka. It’s failing, and you suspect an issue with message production or consumption, or perhaps the Kafka broker itself.

Challenge:

  1. Create a simple Testcontainers test with a Kafka container (use testcontainers-java/kafka, testcontainers-node/kafka, or testcontainers-python/kafka).
  2. Intentionally introduce a subtle error in your Kafka client code (e.g., publish to a topic that the consumer isn’t subscribed to, or send a message that’s too large if you configured Kafka to reject it).
  3. Run the test. It should fail.
  4. Now, re-run the test with TESTCONTAINERS_RYUK_DISABLED=true (or the equivalent for your environment).
  5. After the test fails and completes, use docker ps -a to find your Kafka container.
  6. Use docker logs <kafka_container_id> to inspect the Kafka broker’s logs. Look for error messages related to topics, producers, or consumers.
  7. (Bonus): Use docker exec -it <kafka_container_id> bash (or sh) to get a shell inside the Kafka container. See if you can use Kafka’s command-line tools (like kafka-topics.sh or kafka-console-consumer.sh) to inspect the topic and messages directly.

Hint: Focus on the error messages in both your test code’s output and the Kafka container’s logs. The combination often reveals the root cause. Remember to clean up your containers manually afterwards!

Common Pitfalls & Troubleshooting

Debugging is largely about pattern recognition and methodical investigation. Here are some common issues you might encounter with Testcontainers and how to approach them:

  1. Container Fails to Start (or takes too long):

    • Symptoms: ContainerStartupException, TimeoutException, tests just hang.
    • Troubleshooting:
      • Check Docker: Is your Docker daemon running? Can you run docker ps successfully from your terminal?
      • Image Availability: Is the Docker image specified available? Check for typos in the image name or tag (e.g., postgres:latest might pull a huge image; postgres:16.2 is better). Try pulling it manually: docker pull <image_name>.
      • Waiting Strategies: Are your Wait.for... conditions correct? Is the port exposed that you’re waiting for? Is the health check endpoint actually available? Sometimes the service inside the container takes longer to fully initialize than the basic port opening.
      • Container Logs (immediately): Use the programmatic log capture (e.g., getLogs()) or the Slf4jLogConsumer to see what the container is printing during startup. This is often the quickest way to spot configuration errors within the container’s entrypoint.
      • Resources: Is your Docker engine allocated enough memory/CPU? Large containers or many containers can exhaust resources.
  2. Application Inside Container Fails (after container starts):

    • Symptoms: Your application fails with an internal error, but the Testcontainer itself seemed to start.
    • Troubleshooting:
      • Application Logs: The number one place to look. Capture them using getLogs() or logs(), or pipe them to your console.
      • Environment Variables/Configuration: Did you pass all necessary environment variables (e.g., SPRING_DATASOURCE_URL, KAFKA_BOOTSTRAP_SERVERS) correctly? Are there typos? Are the host/port values inside the container correct (e.g., using network aliases like postgres-db instead of localhost or 127.0.0.1)?
      • Dependencies: Can your application reach its dependencies (e.g., database, message queue) inside the Testcontainers network? If using Network.newNetwork(), ensure all relevant containers are added to it and use network aliases.
      • Remote Debugging: If logs aren’t sufficient, remote debugging (as covered above) is your next step to step through your application code.
  3. Networking Issues:

    • Symptoms: Connection refused, Host unreachable, Timeout.
    • Troubleshooting:
      • Exposed Ports: Did you expose the correct ports with .withExposedPorts()?
      • Mapped Ports: Are you connecting to the getMappedPort() on the host, not the internal container port?
      • Firewall: Is your host machine’s firewall blocking connections to the mapped ports?
      • Internal Network: If containers need to talk to each other, are they on the same Network (e.g., Network.newNetwork())? Are you using the correct network aliases/hostnames for inter-container communication?
      • Host OS Differences: Windows/WSL2 and macOS sometimes have slightly different networking behaviors or firewall rules for Docker.
  4. Resource Exhaustion / Stale Containers:

    • Symptoms: Docker slows down, “No space left on device” errors, containers piling up after tests.
    • Troubleshooting:
      • Ryuk Disabled? If you used TESTCONTAINERS_RYUK_DISABLED=true, ensure you manually clean up your containers.
      • Testcontainers Cleanup: If Ryuk is enabled, check your Testcontainers library version. Older versions or specific configurations might have issues. Usually, Ryuk is very reliable.
      • Docker System Prune: Periodically run docker system prune -f to clean up unused images, containers, volumes, and networks. Be careful, this is aggressive!
      • Memory/CPU: Monitor Docker Desktop’s resource usage. Increase allocated resources if needed.

Debugging Testcontainers tests is an iterative process. Start with the simplest tools (logs), and escalate to more powerful ones (inspection, remote debugging) if the initial clues aren’t enough. With practice, you’ll become incredibly efficient at diagnosing and resolving issues in your containerized test environments!

Summary

You’ve now added powerful debugging skills to your Testcontainers toolkit! Here’s a quick recap of the key takeaways:

  • Logs are Your First Line of Defense: Always start by checking the container’s logs for immediate clues about startup failures or application errors. Testcontainers provides programmatic access (getLogs(), logs()) for this.
  • Temporarily Disable Ryuk for Inspection: Use TESTCONTAINERS_RYUK_DISABLED=true to keep containers alive after test execution, allowing you to use docker ps, docker logs, docker exec, and docker inspect for manual, in-depth analysis. Remember to clean up manually!
  • Remote Debugging is for Deep Dives: When logs aren’t enough, configure your application inside the container to expose a debug port (e.g., JAVA_TOOL_OPTIONS, --inspect, debugpy). Map this port to your host and attach your IDE’s debugger for full step-through capabilities.
  • Common Pitfalls: Be aware of issues like container startup failures, incorrect environment configuration, networking problems, and resource exhaustion.
  • Iterative Process: Debugging is a journey. Start simple, gather information, form hypotheses, and escalate your tools as needed.

With these techniques, you’re well-equipped to tackle the challenges of containerized test environments and ensure your Testcontainers-powered integration tests are robust and reliable.

Next up, we’ll shift our focus to integrating Testcontainers into your CI/CD pipelines, making sure your debugging efforts translate into smooth, automated testing workflows!

References

This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.