Integration tests in Golang with dockertest

November 09, 2021

Do integration testing (or system testing) usually means to have a database populated with data, services like redis, elasticsearch, etc… working, In general, any infrastructure with which our software interacts.

The most common way to do it is to have a replica of our production infrastructure. Actually, it’s relatively easy to achieve using containers, for example, docker containers.

We can set up and run a container for every service we need to replicate, we can orchestrate it with docker-compose and create some makefiles or just a simple script to prepare the infrastructure and run the integration tests.

If your tests are independent (they should), you must find the way to “restart” the infrastructure services between tests, and this can be hard to get with a separated infrastructure setup and tests (the infra is set up in a script and the tests are in Go files)

dockertest

If you are using Golang, you can use dockertest, a library with which you can manage and orchestrate the containers in your Go test files.

Manage the test infrastructure container from the Go files allow us to control which service we need in each test (for example, some package is using a database but not Redis, makes no sense to run the Redis for this test)

Installing dockertest

To install dockertest, just run

1
go get -u github.com/ory/dockertest/v3

Using dockertest

The simplest way to set up the infrastructure with dockertest is to add the setup code in the TestMain function in your test file.

TestMain is a function is called before running the tests in the package More info

This is an example of how to set up a MySQL service using dockertest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package mypackage_test

import (
"database/sql"
"fmt"
"log"
"os"
"testing"

_ "github.com/go-sql-driver/mysql"
"github.com/ory/dockertest/v3"
)

var db *sql.DB

func TestMain(m *testing.M) {
// uses a sensible default on windows (tcp/http) and linux/osx (socket)
pool, err := dockertest.NewPool("")
if err != nil {
log.Fatalf("Could not connect to docker: %s", err)
}

// pulls an image, creates a container based on it and runs it
resource, err := pool.Run("mysql", "5.7", []string{"MYSQL_ROOT_PASSWORD=secret"})
if err != nil {
log.Fatalf("Could not start resource: %s", err)
}

// exponential backoff-retry, because the application in the container might not be ready to accept connections yet
if err := pool.Retry(func() error {
var err error
db, err = sql.Open("mysql", fmt.Sprintf("root:secret@(localhost:%s)/mysql", resource.GetPort("3306/tcp")))
if err != nil {
return err
}
return db.Ping()
}); err != nil {
log.Fatalf("Could not connect to docker: %s", err)
}

// RESERVED FOR DATABASE MIGRATIONS

code := m.Run()

// You can't defer this because os.Exit doesn't care for defer
if err := pool.Purge(resource); err != nil {
log.Fatalf("Could not purge resource: %s", err)
}

os.Exit(code)
}

Populate database

Now we have the database service working, but this database is empty. dockertest is using a generic MySQL image for the container and nothing related to our app is there.

If you follow my posts, you would remember I wrote a post about database migrations (if not you can take a look at it). In that post I talked about go-migrate a tool to run database migrations but, in it, I focused on the usage as CLI tool, now we will use it in our Go code

In the previous code in the line where we wrote // RESERVED FOR DATABASE MIGRATIONS we will add this code

1
2
3
4
5
6
7
8
m, err := migrate.NewWithDatabaseInstance("file://<path-to-migration-folder>, "mysql", driver)
if err != nil {
log.Fatalf("Error running migrations: %s", err)
}
err = m.Up()
if err != nil {
log.Fatal(err.Error())
}

Then after dockertest ups the database, the migration tool populates the database and our integration tests can run with the same data in the database.

If the app has more than one package (that is the common situation), I put the services’ setup code in an independent file which is called from every package:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// it_utils.go
package it_utils

func IntegrationTestSetup() (*dockertest.Pool, *[]dockertestResource {
// Setup the services
//return the pool and the resources
}

func IntegrationTestTeardown(pool *dockertest.Pool, resources []*dockertest.Resource) {
for _, resource := range resources {
if err := pool.Purge(resource); err != nil {
fmt.Printf("Could not purge resource: %s\n", err)
}
}
}

Then in each package’s test we only need to add

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package my_package

func TestMyTests (t *testing.T) {
if testing.Short() {
t.Skip()
}
pool, resources := itutils.IntegrationTestSetup()
defer itutils.IntegrationTestTeardown(pool, resources)

t.Run("your test", func(t *testing.T) {
...
}
}

func TestOtherTests (t *testing.T) {
if testing.Short() {
t.Skip()
}
pool, resources := itutils.IntegrationTestSetup()
defer itutils.IntegrationTestTeardown(pool, resources)

t.Run("your other test", func(t *testing.T) {
...
}
}

Doing it in that way on every test block the service runs in a new container making the test completely independent.

As a last tip, I recommend putting the integration test in a different package to avoid circular imports.

To show the comments is mandatory accept cookie policy.

Front-end and back-end developer.
#formula1, good conversations and small details lover.