Welcome back, aspiring Testcontainers pro! In the previous chapter, we explored the “why” behind Testcontainers – the pain points of traditional integration testing and how disposable environments offer a superior solution. Now, it’s time to get our hands dirty and witness the magic firsthand.

In this chapter, we’ll guide you through setting up your very first Testcontainer. Our mission? To programmatically spin up a real PostgreSQL database, use it in a test, and then let Testcontainers gracefully dispose of it. You’ll learn the core concepts of how Testcontainers interacts with Docker and see practical, step-by-step examples across Java, JavaScript/TypeScript, and Python. Get ready to banish those flaky tests and say “Hello, Postgres!” with confidence!

The Testcontainers Magic Trick: How It Works

Before we jump into code, let’s understand the fundamental steps Testcontainers takes to bring a containerized service to life for your tests. It’s not really magic, but rather a clever orchestration with Docker.

At its heart, Testcontainers acts as a friendly wrapper around your Docker daemon. When your test asks for a database (like PostgreSQL), here’s what happens:

  1. Request an Image: Your code tells Testcontainers, “Hey, I need a postgres:latest container!”
  2. Docker Client in Action: Testcontainers, using its internal Docker client, communicates with your local (or remote) Docker daemon.
  3. Image Pull: If you don’t already have the postgres:latest image locally, Docker pulls it from Docker Hub (or your configured registry).
  4. Container Launch: Docker launches a new container instance from that image. Testcontainers configures things like environment variables (e.g., database user/password), exposed ports, and mount points.
  5. Dynamic Port Mapping: Crucially, Testcontainers often maps internal container ports (like PostgreSQL’s default 5432) to random, available ports on your host machine. This prevents port conflicts when running multiple tests or multiple instances of the same service.
  6. Readiness Check: Testcontainers doesn’t just launch and assume the container is ready. It employs “wait strategies” – smart checks that wait until the service inside the container is fully up and functional (e.g., listening on its port, logs a specific message).
  7. Test Execution: Once the container is truly ready, Testcontainers provides you with the dynamically mapped host port and other connection details. Your test then connects to this real database instance and runs its assertions.
  8. Automatic Cleanup: When your test finishes (or the test suite completes, depending on configuration), Testcontainers signals Docker to stop and remove the container and its associated volume, leaving no trace behind. This is the “disposable” superpower!

This entire process ensures that each test run gets a clean, isolated environment, eliminating the headaches of shared development databases or complex mocking setups.

Here’s a simplified flow of this interaction:

flowchart TD A[Your Test Code] --> B{Testcontainers Library}; B -->|Docker API Call| C[Docker Daemon]; C -->|Pull Image| D[DockerHub/Registry]; C -->|Create & Start| E[Isolated Container];

Setting the Stage: Prerequisites

Before we dive into the code examples, make sure you have the following installed and running:

  1. Docker Desktop (or Docker Engine): This is non-negotiable! Testcontainers relies on Docker.
  2. Your Preferred IDE: IntelliJ IDEA (Java), VS Code (JS/TS, Python), PyCharm (Python) are great choices.
  3. Language-Specific Tools:
    • Java: JDK 17+ (LTS as of 2026-02-14, likely JDK 21 or 22), Maven (3.8+) or Gradle (8.x+).
    • JavaScript/TypeScript: Node.js (v20+ LTS), npm (9.x+) or yarn (4.x+).
    • Python: Python (3.9+), pip (23.x+), and optionally venv for virtual environments.

Your First Testcontainer: “Hello, Postgres!”

Now for the main event! We’ll demonstrate how to set up a PostgreSQL Testcontainer in Java, JavaScript/TypeScript, and Python. Pay attention to the similarities and differences in how each language’s Testcontainers library provides an idiomatic API.

Java Example (with JUnit 5)

We’ll use Testcontainers for Java with JUnit 5, which offers excellent integration via extensions.

1. Project Setup (Maven)

First, create a new Maven project (or add to an existing one). Add the following dependencies to your pom.xml:

<!-- pom.xml -->
<project>
    <!-- ... other project configurations ... -->
    <properties>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
        <junit.jupiter.version>5.10.1</junit.jupiter.version>
        <testcontainers.version>1.19.4</testcontainers.version> <!-- Verified stable as of 2026-02-14 based on latest releases -->
        <postgresql.driver.version>42.7.2</postgresql.driver.version>
    </properties>

    <dependencies>
        <!-- JUnit 5 -->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>${junit.jupiter.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <version>${junit.jupiter.version}</version>
            <scope>test</scope>
        </dependency>

        <!-- Testcontainers -->
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers</artifactId>
            <version>${testcontainers.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>postgresql</artifactId>
            <version>${testcontainers.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>${testcontainers.version}</version>
            <scope>test</scope>
        </dependency>

        <!-- PostgreSQL JDBC Driver -->
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <version>${postgresql.driver.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <!-- ... build and plugin configurations ... -->
</project>

What did we add?

  • junit-jupiter-api and junit-jupiter-engine: For writing and running JUnit 5 tests.
  • testcontainers: The core Testcontainers library.
  • postgresql: A specific module for PostgreSQL, providing handy utilities.
  • junit-jupiter: The JUnit 5 integration module that simplifies lifecycle management.
  • postgresql (driver): The standard JDBC driver to connect to PostgreSQL.

2. Create Your First Test

Create a new Java class for your test, for example, src/test/java/com/example/PostgresTest.java:

// src/test/java/com/example/PostgresTest.java
package com.example;

import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

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

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

// 1. Mark the class with @Testcontainers to enable JUnit 5 integration
@Testcontainers
class PostgresTest {

    // 2. Declare a static PostgreSQLContainer and annotate it with @Container
    // Testcontainers will automatically start and stop this container for your tests.
    // We specify the Docker image name and version.
    @Container
    public static PostgreSQLContainer<?> postgresContainer = new PostgreSQLContainer<>("postgres:15.5"); // Using a specific, recent LTS version

    @Test
    void testPostgresConnection() throws SQLException {
        // 3. Get connection details from the running container
        String jdbcUrl = postgresContainer.getJdbcUrl();
        String username = postgresContainer.getUsername();
        String password = postgresContainer.getPassword();

        // Let's print them to see what Testcontainers generated!
        System.out.println("JDBC URL: " + jdbcUrl);
        System.out.println("Username: " + username);
        System.out.println("Password: " + password);

        // 4. Establish a connection using a standard JDBC driver
        try (Connection connection = DriverManager.getConnection(jdbcUrl, username, password)) {
            // 5. Create a statement and execute a simple query
            try (Statement statement = connection.createStatement()) {
                ResultSet resultSet = statement.executeQuery("SELECT 1");

                // 6. Assert that we got a result, confirming the connection is live
                assertTrue(resultSet.next(), "Should get a result from the database.");
                assertTrue(resultSet.getInt(1) == 1, "The result should be 1.");
            }
        }
    }
}

What’s happening here?

  • @Testcontainers: This JUnit 5 extension tells JUnit that this test class uses Testcontainers. It’s responsible for managing the lifecycle of @Container annotated fields.
  • @Container: This annotation on our postgresContainer field is the magic. When the test class is initialized, Testcontainers sees this and automatically starts a new postgres:15.5 Docker container before any tests run.
  • PostgreSQLContainer<?>("postgres:15.5"): We instantiate a PostgreSQLContainer, which is a specialized GenericContainer for PostgreSQL. It comes with built-in wait strategies and default configurations (like default username test, password test, and database test). We’re using postgres:15.5, a recent stable version.
  • postgresContainer.getJdbcUrl(), getUsername(), getPassword(): These methods are provided by the PostgreSQLContainer to easily get the dynamically mapped port and pre-configured credentials needed to connect to the database from your host machine.
  • DriverManager.getConnection(...): This is standard JDBC code. You’re connecting to a real PostgreSQL instance, just like you would connect to any other database, but this one is temporary and managed by Testcontainers!
  • assertTrue(resultSet.next()): This assertion simply checks if the database returned a row, confirming our connection and query worked.

Run this test, and you’ll see a PostgreSQL container briefly appear in your Docker Desktop, run the test, and then disappear. Amazing!

JavaScript/TypeScript Example (with Jest)

For Node.js environments, we use the testcontainers-node library, often with a testing framework like Jest.

1. Project Setup

Create a new directory, initialize a Node.js project, and install dependencies:

mkdir testcontainers-nodejs-example
cd testcontainers-nodejs-example
npm init -y # or yarn init -y
npm install --save-dev jest typescript ts-node @types/node @types/jest testcontainers pg @types/pg # or yarn add -D ...
npx ts-jest config:init # if using TypeScript and Jest

Make sure your tsconfig.json (if using TypeScript) is configured correctly, for instance, target: "es2022", module: "commonjs".

2. Create Your First Test

Create a test file, e.g., src/test/postgres.test.ts:

// src/test/postgres.test.ts
import { GenericContainer, StartedTestContainer, Wait } from "testcontainers";
import { Client } from "pg"; // Node.js PostgreSQL client

describe("PostgreSQL Testcontainers Example", () => {
    let postgresContainer: StartedTestContainer; // Declare a variable to hold our container instance
    let pgClient: Client;

    // Before all tests in this suite, we'll start our container
    beforeAll(async () => {
        console.log("Starting PostgreSQL container...");
        // 1. Create a GenericContainer instance, specifying the Docker image
        // We're using 'postgres:15.5' for a recent LTS version
        postgresContainer = await new GenericContainer("postgres:15.5")
            // 2. Expose the default PostgreSQL port
            .withExposedPorts(5432)
            // 3. Set environment variables for the database (user, password, db name)
            .withEnv({
                POSTGRES_USER: "myuser",
                POSTGRES_PASSWORD: "mypassword",
                POSTGRES_DB: "mydatabase"
            })
            // 4. Set a wait strategy to ensure the database is ready
            // We wait for a specific log message indicating readiness
            .withWaitStrategy(Wait.forLogMessage("database system is ready to accept connections", 2))
            // 5. Start the container!
            .start();

        // 6. Get the dynamically mapped port from the container
        const mappedPort = postgresContainer.getMappedPort(5432);
        const host = postgresContainer.getHost();

        // Log connection details for debugging/observation
        console.log(`PostgreSQL running on ${host}:${mappedPort}`);
        console.log(`DB Name: mydatabase, User: myuser, Password: mypassword`);

        // 7. Initialize the PostgreSQL client
        pgClient = new Client({
            host: host,
            port: mappedPort,
            user: "myuser",
            password: "mypassword",
            database: "mydatabase"
        });

        // 8. Connect to the database
        await pgClient.connect();
        console.log("Connected to PostgreSQL successfully!");

    }, 60000); // Give it up to 60 seconds to start and connect

    // After all tests, we'll stop and remove the container
    afterAll(async () => {
        if (pgClient) {
            await pgClient.end();
            console.log("Disconnected from PostgreSQL.");
        }
        if (postgresContainer) {
            await postgresContainer.stop();
            console.log("PostgreSQL container stopped.");
        }
    });

    it("should be able to connect and query PostgreSQL", async () => {
        // 9. Execute a simple query
        const result = await pgClient.query("SELECT 1 AS result_value");

        // 10. Assert the result
        expect(result.rows[0].result_value).toBe(1);
        console.log("Query 'SELECT 1' successful.");
    });
});

What’s happening here?

  • GenericContainer("postgres:15.5"): In Node.js, we often use GenericContainer for any service. We pass the Docker image name and tag.
  • .withExposedPorts(5432): We tell Testcontainers that port 5432 inside the container is relevant, so it should map it to a random host port.
  • .withEnv({...}): We set environment variables that the PostgreSQL image understands to configure the database, user, and password.
  • .withWaitStrategy(Wait.forLogMessage(...)): This is crucial! It tells Testcontainers to wait until PostgreSQL emits a specific log message indicating it’s ready. Without this, your test might try to connect before the database is fully initialized.
  • await ... .start(): Asynchronous operation to launch the container.
  • postgresContainer.getMappedPort(5432) and getHost(): These methods retrieve the dynamically assigned host port and the host IP (usually localhost) that you’ll use to connect.
  • pgClient = new Client(...): We initialize the pg client (a popular Node.js PostgreSQL driver) with the details obtained from Testcontainers.
  • beforeAll and afterAll: Jest hooks are used to manage the container’s lifecycle: beforeAll starts it, and afterAll stops it, ensuring it’s available for all tests in the suite and then cleaned up.

Run your test using npm test or npx jest src/test/postgres.test.ts.

Python Example (with pytest)

For Python, the testcontainers-python library integrates seamlessly with pytest, often leveraging fixtures.

1. Project Setup

Create a new directory, initialize a virtual environment, and install dependencies:

mkdir testcontainers-python-example
cd testcontainers-python-example
python3 -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
pip install pytest testcontainers psycopg2-binary

2. Create Your First Test

Create a test file, e.g., tests/test_postgres.py:

# tests/test_postgres.py
import pytest
from testcontainers.postgres import PostgreSQLContainer
import psycopg2

# 1. Define a pytest fixture that provides a PostgreSQLContainer
# This fixture will be executed once for the test session (scope="session")
# or for each test function (scope="function") if not specified.
# "session" scope is generally better for performance to reuse the container.
@pytest.fixture(scope="session")
def postgres_container():
    # 2. Instantiate PostgreSQLContainer, specifying the Docker image
    # We're using 'postgres:15.5' for a recent LTS version
    # The default username, password, and database are 'test' for PostgreSQLContainer
    with PostgreSQLContainer("postgres:15.5") as postgres:
        # 3. Testcontainers handles starting the container and waiting for it to be ready
        # The 'with' statement ensures the container is stopped and removed automatically
        postgres.start()
        print(f"\nPostgreSQL container started on {postgres.get_container_host_ip()}:{postgres.get_exposed_port(5432)}")
        print(f"Connection string: {postgres.get_connection_url()}")
        yield postgres # Yield the container instance to the tests that use this fixture

# 4. Define another fixture to provide a database connection
@pytest.fixture(scope="function")
def pg_connection(postgres_container):
    # 5. Get the connection URL from the running container
    connection_url = postgres_container.get_connection_url()
    
    # Connect using psycopg2
    conn = psycopg2.connect(connection_url)
    try:
        yield conn # Yield the connection object to the test
    finally:
        conn.close() # Ensure the connection is closed after the test

# 6. Write your test function, using the fixtures
def test_postgres_select_1(pg_connection):
    cursor = pg_connection.cursor()
    cursor.execute("SELECT 1")
    result = cursor.fetchone()[0] # Fetch the result

    assert result == 1 # Assert that the result is 1
    print("Query 'SELECT 1' successful.")
    cursor.close()

What’s happening here?

  • @pytest.fixture(scope="session"): This decorator defines postgres_container as a pytest fixture. scope="session" means this container will be started once for the entire test session and reused across all tests that need it, and then stopped when all tests are done. This is often an optimization for performance.
  • with PostgreSQLContainer("postgres:15.5") as postgres:: This Pythonic with statement is a fantastic feature. It creates the PostgreSQLContainer instance, and the __enter__ method implicitly calls start(). Crucially, when the with block exits (after all tests using the fixture are done), the __exit__ method is called, which handles stop() and cleanup automatically.
  • postgres.get_connection_url(): The PostgreSQLContainer provides a convenient method to get a full connection string (including host, mapped port, user, password, database) for the psycopg2 driver.
  • yield postgres: In a pytest fixture, yield passes the value (our running container) to the test function. Execution then resumes from here when the test finishes.
  • pg_connection(postgres_container): This second fixture demonstrates how you can chain fixtures. It automatically receives the postgres_container instance and uses it to establish a psycopg2 connection.
  • test_postgres_select_1(pg_connection): Our actual test function now simply receives a ready-to-use database connection from the pg_connection fixture and can execute queries.

Run your test using pytest tests/test_postgres.py.

Mini-Challenge: Extend Your PostgreSQL Test

Now that you’ve seen the basics, it’s your turn to practice!

Challenge: Modify the Python example (tests/test_postgres.py) to do the following:

  1. Create a table: After connecting to the database, execute a SQL statement to create a simple table named users with columns id (INT, PRIMARY KEY) and name (VARCHAR(255)).
  2. Insert data: Insert one or two rows into the users table.
  3. Query data: Select all data from the users table and assert that you retrieve the data you just inserted.

Hint:

  • Remember to commit() your changes after INSERT statements if your connection is in auto-commit mode (which psycopg2 connection typically is not by default).
  • You’ll need cursor.execute() for CREATE TABLE and INSERT, and cursor.fetchall() for retrieving multiple rows.

What to Observe/Learn:

  • You are interacting with a real database instance, not a mock.
  • Each time you run the test (if using scope="function" for the container or if you stop/restart the session for scope="session"), you get a clean database. This ensures your tests are isolated and repeatable.
💡 **Need a little help? Click for a hint!**When defining your `pg_connection` fixture, you can execute SQL commands right after `conn = psycopg2.connect(connection_url)` and before `yield conn` to set up your schema. Make sure to call `conn.commit()` after your DDL/DML statements. In your test, you'll open a new cursor to perform the `SELECT` query.

Common Pitfalls & Troubleshooting

Even with Testcontainers’ elegance, you might encounter some bumps. Here are common issues and how to tackle them:

  1. “Cannot connect to the Docker daemon” / Docker Not Running:

    • Symptom: You’ll see an error like org.testcontainers.dockerclient.DockerClientException: Could not connect to Docker daemon (Java) or similar messages in other languages.
    • Fix: Ensure Docker Desktop (or your Docker engine) is running. Check your system tray (Windows/macOS) or run docker info in your terminal. If it’s not running, start it up!
  2. Image Pull Issues / Network Problems:

    • Symptom: Testcontainers tries to pull an image but times out or fails with a network error.
    • Fix: Check your internet connection. Ensure there are no corporate proxies or firewalls blocking Docker Hub access. You might need to configure Docker’s proxy settings. Also, double-check the image name and tag for typos (e.g., postgres:15.5 not postgress:15.5).
  3. Container Startup Timeout / WaitStrategy Issues:

    • Symptom: Your test hangs for a long time (e.g., 60 seconds) and then fails with a message indicating the container didn’t become ready within the timeout period.
    • Fix:
      • Check container logs: Testcontainers usually prints the container logs on failure. Look for clues: Did the service crash? Is there an unexpected configuration error?
      • Review WaitStrategy: For GenericContainer (especially in JS/TS), ensure your Wait.forLogMessage() or other wait strategy correctly identifies when the service is fully operational. Sometimes logs change between image versions.
      • Increase timeout: As a last resort, you can increase the default startup timeout (e.g., withStartupTimeout(Duration.ofSeconds(120)) in Java or 60000 in Jest beforeAll).
  4. Connection Refused / Incorrect Port Mapping:

    • Symptom: Your application code tries to connect to the database, but gets a “connection refused” error.
    • Fix:
      • Always use mapped ports: Remember, Testcontainers maps internal container ports to random host ports. Never hardcode 5432 for localhost unless you’re explicitly binding a fixed port (which is generally discouraged). Always use container.getMappedPort(internalPort) (Java/JS) or container.get_exposed_port(internalPort) (Python).
      • Check host IP: For some network configurations, localhost might not resolve correctly inside the test environment. Testcontainers provides methods like container.getHost() (Java/JS) or container.get_container_host_ip() (Python) to get the correct host IP for connection.

Summary

Congratulations! You’ve successfully launched your first Testcontainer and connected to a real PostgreSQL database, demonstrating the power of disposable, on-demand test environments.

Here are the key takeaways from this chapter:

  • Testcontainers’ Core Workflow: You learned how Testcontainers orchestrates Docker to pull images, start containers, map ports dynamically, check for readiness, and perform automatic cleanup.
  • Real vs. Mock: We reinforced the concept that Testcontainers provides real service instances, not mocks or fakes, leading to higher confidence in your integration tests.
  • Multi-Language Examples: You’ve seen idiomatic implementations for starting a PostgreSQL container in Java (JUnit 5), JavaScript/TypeScript (Jest), and Python (pytest), highlighting both the similarities in approach and differences in API.
  • Hands-on Practice: The mini-challenge allowed you to apply your new knowledge and interact with the database directly.
  • Troubleshooting Basics: You’re now equipped to handle common setup and connection issues.

In the next chapter, we’ll dive deeper into configuring Testcontainers, exploring different container types beyond databases, and learning about more advanced wait strategies and container networking. Get ready to expand your Testcontainers toolkit!


References

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