Go Database Integration Test using Docker Programmatically with TestContainers


Testing using docker is becoming more common right now because it offers the possibility to do integration and e2e test in better way, but many are still relying to docker via Docker cli and Dockerfile.

Some devs may use Makefile, bash command to handle docker or wrap docker CLI call inside the code, which in Golang world we may use exec.Command from os/exec library.

I feel all those ways, relying on makefile, bash or wrapping the docker cli command call inside the code are not too reliable and we don’t have proper integration and enough control to Docker in our test.

So I tried to research …… research (in reality…. googling…. googling….) and whohoooo … finally I found testcontainers-go library that provides specialized go library for testing with docker. It is child project from testcontainers that originally built with Java… see here: https://www.testcontainers.org/.

After I read and learned about it, I feel so dumb after remember that docker is built with Go and has Docker Engine SDK for interacting and managing Docker purely from Golang code… (https://docs.docker.com/engine/api/sdk/examples/). How can i forget that thing???? Because surely I can use the docker SDK directly also if I want to use it in test. But testcontainers-go surely better option compare to interacting with docker SDK directly if the reason i want to use docker is only for testing purpose.

Enough for the prologue…. now I want to share what I have done with the testcontainers-go in simple database integration test of my go-starter-kit project.

MySQL Database Docker Setup with TestContainers

So the first thing I want to have is run MySQL docker that provides DB connection and need to be populated already with the test data seed.

I need to write the ‘SetupMySQLContainer‘ function that can be reused which needs to return sqlx.DB and function to terminate the container. For now I don’t care about the credentials again because the User Repository that I want to test doesn’t require the mysql credentials.

So here is the db.go that I put inside pkg/test because I want it to be reused by other test.

package test

import (
	"context"
	"fmt"
	"os"

	_ "github.com/go-sql-driver/mysql"
	"github.com/jmoiron/sqlx"
	"github.com/qreasio/go-starter-kit/pkg/log"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/wait"
)

const (
	dbUsername string = "root"
	dbPassword string = "password"
	dbName     string = "test"
)

var (
	db *sqlx.DB
)

func SetupMySQLContainer(logger log.Logger) (func(), *sqlx.DB, error) {
	logger.Info("setup MySQL Container")
	ctx := context.Background()

	seedDataPath, err := os.Getwd()
	if err != nil {
		logger.Errorf("error get working directory: %s", err)
		panic(fmt.Sprintf("%v", err))
	}
	mountPath := seedDataPath + "/../../test/integration"

	req := testcontainers.ContainerRequest{
		Image:        "mysql:latest",
		ExposedPorts: []string{"3306/tcp", "33060/tcp"},
		Env: map[string]string{
			"MYSQL_ROOT_PASSWORD": dbPassword,
			"MYSQL_DATABASE":      dbName,
		},
		BindMounts: map[string]string{
			mountPath: "/docker-entrypoint-initdb.d",
		},
		WaitingFor: wait.ForLog("port: 3306  MySQL Community Server - GPL"),
	}

	mysqlC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
		ContainerRequest: req,
		Started:          true,
	})

	if err != nil {
		logger.Errorf("error starting mysql container: %s", err)
		panic(fmt.Sprintf("%v", err))
	}

	closeContainer := func() {
		logger.Info("terminating container")
		err := mysqlC.Terminate(ctx)
		if err != nil {
			logger.Errorf("error terminating mysql container: %s", err)
			panic(fmt.Sprintf("%v", err))
		}
	}

	host, _ := mysqlC.Host(ctx)
	p, _ := mysqlC.MappedPort(ctx, "3306/tcp")
	port := p.Int()

	connectionString := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?tls=skip-verify&parseTime=true&multiStatements=true",
		dbUsername, dbPassword, host, port, dbName)

	db, err = sqlx.Connect("mysql", connectionString)
	if err != nil {
		logger.Errorf("error connect to db: %+v\n", err)
		return closeContainer, db, err
	}

	if err = db.Ping(); err != nil {
		logger.Errorf("error pinging db: %+v\n", err)
		return closeContainer, db, err
	}

	return closeContainer, db, nil
}

On code above, you can see that I prepare the docker configuration first with testcontainers.ContainerRequest then pass it to testcontainers.GenericContainer that will start and run the container.

Then here is the user_test.go which is the Go user repository integration test code that uses SetupMySQLContainer function inside TestMain so it can start and terminating container properly. I store the function to terminate container inside variable, that inside it it will call container.Terminate function.

In order to populate the mysql i need to bind the /docker-entrypoint-initdb.d to my folder path that has .sql files that will be called to create schema, tables and insert seed data with this code:

BindMounts: map[string]string{
mountPath: "/docker-entrypoint-initdb.d",
},

After the container is created, next step is I need to get the host, port then construct the mysql dsn connection string so I can pass it to sqlx.Connect.
I think the code is quite simple and readable, so I won’t explain further and you can read it slowly to understand better.

Repository / Database Integration Testing

And below here is the simple integration testing that uses SetupMySQLContainer to have mysql server with populated test data and terminate it after test is finished.

package user_test

import (
	"context"
	"os"
	"testing"

	_ "github.com/go-sql-driver/mysql"
	"github.com/jmoiron/sqlx"
	"github.com/qreasio/go-starter-kit/internal/user"
	"github.com/qreasio/go-starter-kit/pkg/log"
	"github.com/qreasio/go-starter-kit/pkg/test"
)

var (
	db     *sqlx.DB
	logger = log.New()
)

func TestMain(m *testing.M) {
	var err error
	var terminateContainer func() // variable to store function to terminate container
	terminateContainer, db, err = test.SetupMySQLContainer(logger)
	defer terminateContainer() // make sure container will be terminated at the end
	if err != nil {
		logger.Error("failed to setup MySQL container")
		panic(err)
	}
	os.Exit(m.Run())
}

func TestUserRepository_ListIntegration(t *testing.T) {
	repo := user.NewRepository(db, logger)
	ctx := context.Background()
	req := user.NewListUsersRequest()
	users, err := repo.List(ctx, &req)

	if err != nil {
		t.Errorf("error on list users : %s", err)
	}

	if len(users) < 1 {
		t.Errorf("Failed to get list users : %s", err)
	}

	want := "isak"
	got := users[0].Username
	if got != want {
		t.Errorf("Error get user, want : %s, got : %s", want, got)
	}
}

That’s all folks…

If you want to explore further, you can visit https://www.testcontainers.org, https://github.com/testcontainers/testcontainers-go and if you want to see golang code files above, please visit https://github.com/qreasio/go-starter-kit which is my golang rest api starter kit project that provide initial code that can be used for new API project.