Introduction

I once heard a quote that went like:

Any software that’s worth writing, is worth writing correctly.

I’ve generally subscribed to this notion for production grade software. I write unit tests to ensure my logic is correct. However, one can validate their logic, and still have their code be wrong. This can happen when you code the wrong this. This often only arises when in comes time to integration. So why not integrate earlier?

Setting The Scene

Let’s pretend we’re working on a Python application, and we’ve recently added a Postgres database. To keep our code organized, let’s put all of our code that interacts with our database into database_service.py. By doing this, we’d be following the Single Responsibility Principle (SRP) from SOLID principles. This principle states that a class or module should have one, and only one, reason to change. This benefits because:

  1. All database interactions are centralized in one place
  2. Changes to how we interact with the database only need to happen in this file
  3. Other parts of our application are shielded from the details of our data storage
  4. Testing becomes easier as we have a single point of entry for database operations

Visually, this would look like this:

connects to

database_service.py

Postgres

Let’s also pretend that we’ve fully unit tested our database_service.py.

However, the first time we deployed our application with our new database, it crashes. The database schema is invalid, or our query is incorrect, or how we interact with the database via some library is malformed, etc. How could we have stumbled upon this earlier?

Introducing Testcontainers

While unit tests are where you test each single “unit” of code for functional correctness. Integration testing is when you test integration between two systems works as expected. This should be done with real integrations, IE without mocks. In our case, we’d be testing the integration between our application, and the database.

Back to our hypothetical, how can we test against a real Postgres database? One way we could achieve this is by running a real Postgres instance ourselves, and starting it, and seeind it before each run of our integration tests. This could be achieved with say a docker-compose.yaml file, and a few scripts. However, this is more complicated than it needs to be.

Testcontainers is an open source library that has implementations in many languages, which exposes the ability to have your tests start a container before the test suite runs, gives you oppurtunties to do things against said container, like seed data a database, and automatically stop the container at the end of the test suite.

Example

Let’s look at a real example using Python, and Postgres. First, let’s install the library:

uv add testcontainers[postgres]

Then let’s write an integration test script:

from testcontainers.postgres import PostgresContainer
from database_service import create_schema, add_user, get_users

def test_database_operations():
    with PostgresContainer("postgres:16") as postgres:
        # Get connection URL from the container
        db_url = postgres.get_connection_url()
        
        # Initialize database schema
        create_schema(db_url)
        
        # Test adding and retrieving a user
        user_id = add_user(db_url, "Test User", "test@example.com")
        users = get_users(db_url)
        assert any(u['email'] == 'test@example.com' for u in users)

This example shows how we can test our database operations against a real Postgres instance. Let’s break down what’s happening:

  1. When the test starts, testcontainers spins up a fresh Postgres container
  2. We get a connection URL that points to this container
  3. We can initialize our schema, just like we would in production
  4. We can perform real database operations and verify they work as expected
  5. When the test completes, the container is automatically cleaned up. This is thanks to the convenient with statement, and it’s lifecycle.

This approach gives us confidence that our database code works with an actual Postgres database, while keeping our tests isolated and reproducible. Each test run starts fresh, and we don’t need to manage a separate test database server.

Testcontainers are for more than just databases. Fortunately, there are many testcontainer modules already written. Overall, I’ve enjoyed my time using this library, and I’d recommend using testcontainers for integration tests.