testcontainers-python is actually a wrapper around the Java testcontainers library, which is the core engine for managing container lifecycles and exposing their ports to your local test environment.

Here’s how you can spin up a PostgreSQL database using testcontainers-python and run some integration tests against it.

import pytest
import docker
import testcontainers.postgres
import psycopg2

@pytest.fixture(scope="module")
def postgres_container():
    # Define the Docker image to use
    image = "postgres:13"

    # Create a PostgreSQL container instance
    # The `docker.services.latest()` finds the latest version of the PostgreSQL image
    # `container_kwargs` allows passing specific Docker container configuration
    # `ports` maps a host port to the container's default PostgreSQL port (5432)
    with testcontainers.postgres.PostgresContainer(image=image) as postgres:
        # Wait until the container is ready and the database is accessible
        # `get_connection_url()` returns a string like:
        # "postgresql://user:password@host:port/database"
        connection_url = postgres.get_connection_url()
        yield connection_url

@pytest.fixture(scope="module")
def db_connection(postgres_container):
    # Extract connection details from the URL
    from urllib.parse import urlparse
    result = urlparse(postgres_container)
    db_params = {
        "database": result.path[1:],
        "user": result.username,
        "password": result.password,
        "host": result.hostname,
        "port": result.port,
    }

    # Establish a connection to the PostgreSQL database
    conn = psycopg2.connect(**db_params)
    yield conn
    conn.close()

def test_database_connection(db_connection):
    """Tests if we can successfully connect to the PostgreSQL database."""
    assert db_connection is not None
    assert not db_connection.closed

def test_create_and_query_table(db_connection):
    """Tests creating a table, inserting data, and querying it."""
    cursor = db_connection.cursor()

    # Create a sample table
    cursor.execute("CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name VARCHAR(50));")
    db_connection.commit()

    # Insert data
    cursor.execute("INSERT INTO users (name) VALUES (%s);", ("Alice",))
    cursor.execute("INSERT INTO users (name) VALUES (%s);", ("Bob",))
    db_connection.commit()

    # Query data
    cursor.execute("SELECT name FROM users WHERE name = %s;", ("Alice",))
    result = cursor.fetchone()
    assert result is not None
    assert result[0] == "Alice"

    cursor.execute("SELECT COUNT(*) FROM users;")
    count = cursor.fetchone()[0]
    assert count == 2

    cursor.close()

When you run pytest in your terminal in the same directory as this file, testcontainers-python will:

  1. Pull the postgres:13 Docker image if it’s not already present on your system.
  2. Start a new PostgreSQL container from that image.
  3. Expose the PostgreSQL port (5432) to a random available port on your host machine.
  4. Wait for the PostgreSQL service inside the container to become ready (i.e., accept connections).
  5. Generate a connection URL that points to this dynamically assigned host port.
  6. Pass this connection URL to your postgres_container fixture.
  7. Your db_connection fixture then uses this URL to establish a psycopg2 connection.
  8. Your tests (test_database_connection, test_create_and_query_table) execute against this live, isolated PostgreSQL instance.
  9. Once all tests in the module are complete, the container is automatically stopped and removed.

This provides a clean, isolated, and reproducible environment for your integration tests, ensuring they don’t interfere with each other or your local development database.

The core mechanism behind testcontainers is its ability to orchestrate Docker containers. It leverages the Docker SDK to pull images, create containers, manage their lifecycle (start, stop, remove), and crucially, to map container ports to host ports. When you request a port from the container (like PostgreSQL’s 5432), testcontainers asks the Docker daemon to allocate an ephemeral port on your host and forward traffic from that host port to the container’s port. This dynamic port mapping is key because it avoids port conflicts with services already running on your host or other containers. The library then provides convenient methods to retrieve these mapped host ports or full connection URLs, abstracting away the low-level Docker API calls.

A common pitfall is forgetting to commit() transactions in your database tests. If you INSERT or CREATE TABLE and don’t commit, the changes won’t be persisted within the container’s database session, and subsequent queries will appear to fail or return no data, even though your code logic is correct. The db_connection.commit() call ensures that the changes made by your test are finalized within the running PostgreSQL instance before your test proceeds or finishes.

The next step is to explore how to manage multiple containers for more complex scenarios, such as testing an application that depends on both a database and a message queue.

Want structured learning?

Take the full Pytest course →