The significance of integration testing in assuring the reliability and seamless operation of software systems cannot be understated. This guide will help you get started with using containers for integration testing and learn how to set up and execute test cases effectively.

1. Use container for testing

Let’s consider a small example program where I use postgres as the underlying database to store product records:

create table product  
(  
    id    serial8      not null primary key,  
    name  varchar(100) not null,  
    type  varchar(50)  not null,  
    code  varchar(50)  not null,  
    price int4         not null  
);

The program can create, update and list products via a repository:

type Product struct {  
    ID    int64  `db:"id"`  
    Name  string `db:"name"`  
    Type  string `db:"type"`  
    Code  string `db:"code"`  
    Price int64  `db:"price"`  
}
...
type ProductPersistenceRepository struct {  
    db *sqlx.DB  
}

func (r *ProductPersistenceRepository) Create(product Product) (Product, error) {
    created := Product{}
    rows, err := r.db.NamedQuery(
        "INSERT INTO product (name, type, code, price) VALUES (:name, :type, :code, :price) RETURNING *", product,
    )
    if err != nil {
        return created, fmt.Errorf("ProductPersistenceRepository Create: %w", err)
    }
    for rows.Next() {
        if err := rows.StructScan(&created); err != nil {
            return created, fmt.Errorf("ProductPersistenceRepository Create: %w", err)
        }
    }
    return created, nil
}
...
// update and list functions

To test these functions against a real database, we’ll need a clean database instance.
With a bit of research, we can find that starting and cleaning up containerized dependencies can be done with ease by running database within a container as part of the test itself.
I have come across some libraries that help to effectively spin up testing container, such as dockertest or testcontainer, which I will utilize in this guide. To begin, let’s write some code that runs a postgres container and returns the container address and port for later connection.

func NewTestDatabase(t *testing.T) (string, string) {
    ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
    defer cancel()
    req := testcontainers.ContainerRequest{
        Image:        "postgres:12",
        ExposedPorts: []string{"5432/tcp"},
        HostConfigModifier: func(config *container.HostConfig) {
            config.AutoRemove = true
        },
        Env: map[string]string{
            "POSTGRES_USER":     "denishoang",
            "POSTGRES_PASSWORD": "pgpassword",
            "POSTGRES_DB":       "products",
        },
        WaitingFor: wait.ForListeningPort("5432/tcp"),
    }
    postgres, err := testcontainers.GenericContainer(
        ctx, testcontainers.GenericContainerRequest{
            ContainerRequest: req,
            Started:          true,
        },
    )
    require.NoError(t, err)
    mappedPort, err := postgres.MappedPort(ctx, "5432")
    require.NoError(t, err)
    
    hostIP, err := postgres.Host(ctx)
    require.NoError(t, err)
    
    return hostIP, mappedPort.Port()
}

The function above runs a postgres container like you normally do with docker cli by interacting with the docker api.

Notice the request’s param WaitingFor: wait.ForListeningPort("5432/tcp"), with this configuration, our tests will check if the container is listening to a specific port (5432/tcp in this case) before proceeding to the next part, makes sure that the database is ready before performing any tests. You can take a look at other wait strategies supported by testcontainer here.

This way, we can spin up a clean database for each test and remove it after the test is done.

Let’s get our hands dirty with the integration test, we’ll use the address and port returned from the NewTestDatabase function to connect to the database and perform the tests.

func Test_Product(t *testing.T) {
    host, port := NewTestDatabase(t)
    db, err := sqlx.Connect(
        "postgres",
        fmt.Sprintf(
            "postgres://denishoang:pgpassword@%s:%s/products?sslmode=disable", host,
            port,
        ),
    )
    require.Nil(t, err)
    repo := repository.NewProductPersistenceRepository(db)
    t.Run(
        "test create product", func(t *testing.T) {
            created, err := repo.Create(
                repository.Product{
                    Name:  "cake",
                    Type:  "food",
                    Code:  "c1",
                    Price: 100,
                },
            )
            require.Nil(t, err)
            require.Equal(t, "cake", created.Name)
        },
    )
}

try to run the test, and you’ll see in the logs that the postgres container is being created and started, and the test is being executed.

✅ Container created: ec1c30ac431b
🐳 Starting container: ec1c30ac431b
✅ Container started: ec1c30ac431b
🚧 Waiting for container id ec1c30ac431b image: postgres:12. Waiting for: &{Port:5432/tcp timeout:<nil> PollInterval:100ms}

then the test failed, is there something wrong?

=== RUN   Test_Product/test_create_product
    product_test.go:34: 
        	Error Trace:	product_test.go:34
        	Error:      	Expected nil, but got: &fmt.wrapError{msg:"ProductPersistenceRepository Create: pq: relation \"product\" does not exist", err:(*pq.Error)(0x14000442b40)}
        	Test:       	Test_Product/test_create_product
    --- FAIL: Test_Product/test_create_product (0.00s)

The error message tells us that the table product does not exist, since we are using a clean database, it’s necessary to perform some migrations before running the test.

I’m using golang-migrate to do the migrations, this involves placing all the migration files in a directory and applying them before the tests.

//go:embed "migrations"
var EmbeddedFiles embed.FS
package persistence

func MigrationUp(completeDsn string) error {
    iofsDriver, err := iofs.New(EmbeddedFiles, "migrations")
    if err != nil {
        return err
    }
    
    migrator, err := migrate.NewWithSourceInstance("iofs", iofsDriver, completeDsn)
    if err != nil {
        return err
    }
    
    return migrator.Up()
}

use this function after the database is ready:

func NewTestDatabase(t *testing.T) (string, string) {
    ...
    err = persistence.MigrationUp(
        fmt.Sprintf(
            "postgres://denishoang:pgpassword@%s:%s/products?sslmode=disable", hostIP,
            mappedPort.Port(),
        ),
    )
    require.NoError(t, err)
    return hostIP, mappedPort.Port()
}

Now, the test should pass.

2. Database per test

However, there are still some things to consider: We don’t really need an entire database instance for each test as it can be resource-consuming and unnecessary.

Ideally, we can reuse the previously created database instance and create a new database for each test.

Let’s rewrite the NewTestDatabase function, instead of directly return the address and port of the database instance, we just create a new database instance that can be reused later.

var postgresContainer testcontainers.Container

func StartDatabase() {
    ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
    defer cancel()
    req := testcontainers.ContainerRequest{
        Image:        "postgres:12",
        ExposedPorts: []string{"5432/tcp"},
        HostConfigModifier: func(config *container.HostConfig) {
            config.AutoRemove = true
        },
        Env: map[string]string{
            "POSTGRES_USER":     "denishoang",
            "POSTGRES_PASSWORD": "pgpassword",
            "POSTGRES_DB":       "products",
        },
        WaitingFor: wait.ForListeningPort("5432/tcp"),
    }
    postgres, err := testcontainers.GenericContainer(
        ctx, testcontainers.GenericContainerRequest{
            ContainerRequest: req,
            Started:          true,
        },
    )
    if err != nil {
        os.Exit(1)
    }
    postgresContainer = postgres
}

And a function to create a new database for each test:

func NewDatabase(t *testing.T) *sqlx.DB {
    ctx := context.Background()
    if postgresContainer == nil {
        t.Fatal("postgres is not yet started")
    }
    mappedPort, err := postgresContainer.MappedPort(ctx, "5432")
    if err != nil {
        t.Fatal("err get mapped port from container")
    }
    
    hostIP, err := postgresContainer.Host(ctx)
    // open connection to postgres instance in order to create other databases
    baseDb, err := sqlx.Open(
        "postgres", fmt.Sprintf(
            "postgres://denishoang:pgpassword@%s:%s/products?sslmode=disable", hostIP, mappedPort.Port(),
        ),
    )
    defer func() {
        if err := baseDb.Close(); err != nil {
            t.Fatal("err close connection to db")
        }
    }()
    // a naive scheme to generate random database names
    dbName := fmt.Sprintf("%s_%d", "products", rand.Int63())
    if _, err := baseDb.Exec(fmt.Sprintf("CREATE DATABASE %s;", dbName)); err != nil {
        t.Fatal("err creating postgres database")
    }
    connString := fmt.Sprintf(
        "postgres://denishoang:pgpassword@%s:%s/%s?sslmode=disable", hostIP, mappedPort.Port(), dbName,
    )
    // apply migrations to the newly created database
    if err = persistence.MigrationUp(connString); err != nil {
        t.Fatal("err connect postgres database")
    }
    // connect to new database
    db, err := sqlx.Open("postgres", connString)
    if err != nil {
        t.Fatal("err connect postgres database")
    }
    return db
}

As you can see, there is quite a bit happening here; let me explain:

  • We have started the postgres container in the StartDatabase function, and stored the container instance in a global variable.
  • The NewDatabase function will create a new database for each test, by connecting to the postgres instance and creating a new database with a random name. It then applies migrations to the new database and returns the connection to the new database.
  • The test that calls NewDatabase function will have its own database so, there is no need to worry about cleaning up the database after each test.
  • Another positive aspect is that our tests can run in parallel without any issues.

But we still have a problem, how to run StartDatabase before any tests?
Luckily, go’s build in test utility provides the TestMain function, which allows us to run some setup code before or after all the tests residing in a package.

func TestMain(m *testing.M) {
    StartDatabase()
    code := m.Run()
    // go test will decide whether the tests failed or not by exit code
    os.Exit(code)

Put everything together by modifying our tests:

func Test_Product(t *testing.T) {
    t.Parallel()
    t.Run(
        "test create product", func(t *testing.T) {
            t.Parallel()
            db := NewDatabase(t)
            repo := repository.NewProductPersistenceRepository(db)
            created, err := repo.Create(
                repository.Product{
                    Name:  "cake",
                    Type:  "food",
                    Code:  "c1",
                    Price: 100,
                },
            )
            require.Nil(t, err)
            require.Equal(t, "cake", created.Name)
        },
    )
    
    t.Run(
        "test get all products", func(t *testing.T) {
            t.Parallel()
            db := NewDatabase(t)
            repo := repository.NewProductPersistenceRepository(db)
            _, err := repo.Create(
                repository.Product{
                    Name:  "cake",
                    Type:  "food",
                    Code:  "c1",
                    Price: 100,
                },
            )
            require.Nil(t, err)
            products, err := repo.GetAll()
            require.Nil(t, err)
            require.Len(t, products, 1)
        },
    )
    
    t.Run(
        "test update product", func(t *testing.T) {
            t.Parallel()
            db := NewDatabase(t)
            repo := repository.NewProductPersistenceRepository(db)
            created, err := repo.Create(
                repository.Product{
                    Name:  "cake",
                    Type:  "food",
                    Code:  "c1",
                    Price: 100,
                },
            )
            require.Nil(t, err)
            created.Name = "new cake"
            updated, err := repo.Update(created)
            require.Nil(t, err)
            require.Equal(t, "new cake", updated.Name)
        },
    )
}

We have learned how to use container for integration testing, and how to set up and execute test cases effectively.
You can see the full code here

3. References