Unit Test SQL in Golang, Without Mocking, Using TestContainers (Go, Postgres, Docker, TestContainers)
By: Brandon Atkinson
Tags: Testcontainer, Docker, Go, Testing, Software Testing

The Tests

Before going too far, I should point out the following environment variables used in this code:

DBHOST=localhost DBNAME=postgresTC DBUSER=postgres DBPORT=5432

In my repo these are injected via a Makefile “test” command and used through-out the code. You’ll see them in the test code below.

As I stated before, I don’t want to mock my tests. I want to write plain old Golang tests against both my database code and my handlers.The TestContainers package allows me to do this very easily. Basically, it allows you to spin up a database container, Postgres in our scenario, for use during your tests. You can then tear it down once you’re done, and you have an ephemeral database at your disposal during tests!

The following code shows what that looks like. We’ll look at both of the test files first, then break down what is going on inside them:

Handler Test (Timezones GET)

package handlers import (

"bytes"

"context"

"encoding/json"

"fmt"

"net/http"

"net/http/httptest"

"os"

"strings"

"testing" db "github.com/atkinsonbg/go-gmux-db-testcontainers/database"

"github.com/gorilla/mux"

"github.com/testcontainers/testcontainers-go"

"github.com/testcontainers/testcontainers-go/wait"

) func TestMain(m *testing.M) {

// Work out the path to the 'scripts' directory and set mount strings

packageName := "handlers"

workingDir, _ := os.Getwd()

rootDir := strings.Replace(workingDir, packageName, "", 1)

mountFrom := fmt.Sprintf("%s/scripts/init.sql", rootDir)

mountTo := "/docker-entrypoint-initdb.d/init.sql" // Create the Postgres TestContainer

ctx := context.Background()

req := testcontainers.ContainerRequest{

Image: "postgres:11.6-alpine",

ExposedPorts: []string{"5432/tcp"},

BindMounts: map[string]string{mountFrom: mountTo},

Env: map[string]string{

"POSTGRES_DB": os.Getenv("DBNAME"),

},

WaitingFor: wait.ForLog("database system is ready to accept connections"),

} postgresC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{

ContainerRequest: req,

Started: true,

})

if err != nil {

// Panic and fail since there isn't much we can do if the container doesn't start

panic(err)

} defer postgresC.Terminate(ctx) // Get the port mapped to 5432 and set as ENV

p, _ := postgresC.MappedPort(ctx, "5432")

os.Setenv("DBPORT", p.Port()) db.InitDB() exitVal := m.Run()

os.Exit(exitVal)

} func TestListTimezonesHandler(t *testing.T) {

req, err := http.NewRequest("GET", "/timezones", nil)

if err != nil {

t.Fatal(err)

} rr := httptest.NewRecorder()

handler := http.HandlerFunc(ListTimezonesHandler) handler.ServeHTTP(rr, req) if rr.Code != http.StatusOK {

t.Errorf("handler returned wrong status code: got %v want %v",

rr.Code, http.StatusOK)

} var timezones []db.Timezone

err = json.NewDecoder(rr.Body).Decode(&timezones)

if err != nil {

t.Error(err.Error())

t.Error("Error retreiving list of timezones.")

} if len(timezones) == 0 {

t.Error("Error retreiving list of timezones.")

}

} func TestGetTimezoneHandler(t *testing.T) {

req, err := http.NewRequest("GET", "/timezones/est", nil)

if err != nil {

t.Fatal(err)

} rr := httptest.NewRecorder()

router := mux.NewRouter()

router.HandleFunc("/timezones/{identifier}", GetTimezoneHandler)

router.ServeHTTP(rr, req) if rr.Code != http.StatusOK {

t.Errorf("handler returned wrong status code: got %v want %v",

rr.Code, http.StatusOK)

} var timezone db.Timezone

err = json.NewDecoder(rr.Body).Decode(&timezone)

if err != nil {

t.Error(err.Error())

t.Error("Error retreiving specific of timezone.")

} if timezone.Name != "eastern" {

t.Error("Error retreiving specific of timezone.")

}

} func TestInsertTimezoneHandler(t *testing.T) {

var data = []byte(`

{

"name": "xyz",

"timeoffset": -10,

"identifier": "xyz"

}`) b := bytes.NewBuffer(data) req, err := http.NewRequest("POST", "/timezones", b)

if err != nil {

t.Fatal(err)

} req.Header.Add("Content-Type", "application/json") rr := httptest.NewRecorder()

handler := http.HandlerFunc(InsertTimezoneHandler) handler.ServeHTTP(rr, req) if rr.Code != http.StatusOK {

t.Errorf("handler returned wrong status code: got %v want %v",

rr.Code, http.StatusOK)

}

}

This code makes use of the net/http/httptest package in Go, which allows me to easily call my API endpoints from my test.

Database Test (Timezones GET)

package database import (

"context"

"fmt"

"os"

"strings"

"testing" "github.com/testcontainers/testcontainers-go"

"github.com/testcontainers/testcontainers-go/wait"

) func TestMain(m *testing.M) {

// Work out the path to the 'scripts' directory and set mount strings

packageName := "database"

workingDir, _ := os.Getwd()

rootDir := strings.Replace(workingDir, packageName, "", 1)

mountFrom := fmt.Sprintf("%s/scripts/init.sql", rootDir)

mountTo := "/docker-entrypoint-initdb.d/init.sql" // Create the Postgres TestContainer

ctx := context.Background()

req := testcontainers.ContainerRequest{

Image: "postgres:11.6-alpine",

ExposedPorts: []string{"5432/tcp"},

BindMounts: map[string]string{mountFrom: mountTo},

Env: map[string]string{

"POSTGRES_DB": os.Getenv("DBNAME"),

},

WaitingFor: wait.ForLog("database system is ready to accept connections"),

} postgresC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{

ContainerRequest: req,

Started: true,

})

if err != nil {

// Panic and fail since there isn't much we can do if the container doesn't start

panic(err)

} defer postgresC.Terminate(ctx) // Get the port mapped to 5432 and set as ENV

p, _ := postgresC.MappedPort(ctx, "5432")

os.Setenv("DBPORT", p.Port()) InitDB() exitVal := m.Run()

os.Exit(exitVal)

} func TestGetAllTimezones(t *testing.T) {

tzones, err := GetAllTimezones()

if err != nil {

t.Error("Get All Timezones failed.")

} if len(tzones) == 0 {

t.Error("Timezones did not return any values.")

}

} func TestGetTimezone(t *testing.T) {

tzone, err := GetTimezone("est")

if err != nil {

t.Error("Get a Timezone failed.")

} if tzone.Name != "eastern" {

t.Error("Timezone did not return correct values.")

}

} func TestInsertTimezone(t *testing.T) {

tzone := Timezone{}

tzone.Name = "Test"

tzone.Timeoffset = 10

tzone.Identifier = "tst" rowid, err := InsertTimezone(tzone)

if err != nil {

t.Error("Insert a Timezone failed.")

} if rowid != 6 {

t.Error("Insert timezone failed.")

}

}

At this point you can see, I’m not using any mocks here. This is as vanilla testing code as I could possibly get, and it’s glorious. It’s succinct and to the point. Anyone who’s been writing Go code for any amount of time can read this code and understand what’s it doing. Nothing fancy, just run the tests. However, there is some magic in the TestMain function, so let’s break this down:

// Work out the path to the 'scripts' directory and set mount strings

packageName := "database"

workingDir, _ := os.Getwd()

rootDir := strings.Replace(workingDir, packageName, "", 1)

mountFrom := fmt.Sprintf("%s/scripts/init.sql", rootDir)

mountTo := "/docker-entrypoint-initdb.d/init.sql"

The first thing I need to do is work out my mount paths. If you recall from earlier in the post, we have a init.sql file that creates the database schema and seeds it with data. We need to mount that to the docker-entrypoint-initdb.d folder in the Postgres image. That is in a different folder, and we’ll be doing a Bind mount which needs an absolute path. So we get the current working directory path, then do strings.Replace on it, pulling out the current directory name. Finally, we create a couple of new strings to hold the mount paths.

// Create the Postgres TestContainer

ctx := context.Background()

req := testcontainers.ContainerRequest{

Image: "postgres:11.6-alpine",

ExposedPorts: []string{"5432/tcp"},

BindMounts: map[string]string{mountFrom: mountTo},

Env: map[string]string{

"POSTGRES_DB": os.Getenv("DBNAME"),

},

WaitingFor: wait.ForLog("database system is ready to accept connections"),

} postgresC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{

ContainerRequest: req,

Started: true,

})

if err != nil {

// Panic and fail since there isn't much we can do if the container doesn't start

panic(err)

} defer postgresC.Terminate(ctx)

Once we have our mount paths worked out, we create a test container. This code is fairly boilerplate from their site, but you can see we are doing the following:

Creating a new container based on the postgres:11.6-alpine image

image Exposing port 5432. This will map port 5432 to a dynamically generated port on the container. This will come up in the next section. It does this as to minimize any port conflicts as it does its work.

Adding a Bind mount with our paths. (Note, this was a gotcha for me. I was originally trying to put strings in here, but since I needed the current working directory, $(pwd) just would not work for me here. It was easier to get these and put them into strings)

just would not work for me here. It was easier to get these and put them into strings) Set an environment variable for the Postgres DB name

Setting a “wait for” command of a log message stating the database is ready. TestContainers has a lot of “waiting” strategies you can utilize, which is very nice! (To be fair, I probably don’t need this in my code, as I utilize an exponential backoff, to test if the database is ready before doing anything else.)

// Get the port mapped to 5432 and set as ENV p, _ := postgresC.MappedPort(ctx, "5432") os.Setenv("DBPORT", p.Port())

Now that we have our container created we need to figure out which port it mapped to 5432. We can do this using the MappedPort function, and we’ll set that to our DBPORT environment variable.