Persistence tests in Golang

Status
Published
date
Dec 12, 2021
slug
persistence-tests-in-golang
status
Published
tags
tests
golang
summary
How to easier test databases in Golang projects using Docker containers.
type
Post

Persistence tests in Golang

As containerization and Docker becomes more popular additional new tools rise up on top of them. One of them is libraries that are able to run Docker containers with a simple API perfect to quickly get some system dependencies off the ground.
Today, I'd like to introduce how you can use this concept inside your Golang tests. NOTE: Libraries introduced in this blog post are not designed for container orchestration. Use Kubernetes, Docker Swarm, or other systems designed for that instead.
 

Overview of libraries for writing integration tests with Docker for Golang

  • dockertest - very simple and flexible API to interact with.
  • testcontainers - robust API like dockertest, available also for different programming languages.
  • gnomock - based on developer friendly presets with extra out of the box solutions, but generally API is more limited than the above mentioned.
 

Introducing into library concepts

Libraries like dockertest introduce a simple interface to interact with Docker, for easier and less boilerplate work than the native Docker library. However, please note that under the hood a new instance of Docker client is created with whom we communicate. Connection is realized by dockertest.NewPool(endpoint) where the default endpoint automatically finds connection string based on operating system/environment. If the connection is grabbed from the system then it's unix:///var/run/docker.sock on Linux, and tcp://127.0.0.1:2376 on Windows.
package main

import (
    "database/sql"
    "fmt"
    "github.com/ory/dockertest/v3"
)
func main() {
    // create Docker client and listen od default endpoint
    var pool, err = dockertest.NewPool("")
    if err != nil {
        // return error
    }

    container, err := pool.RunWithOptions(&dockertest.RunOptions{
        Repository: "", // Docker repository e.g "mysql"
        Tag:        "", // Tag of the repository e.g "8.0"
    })

    if err != nil {
        // return error
    }

    hostPort := container.GetPort("<port_number>/tcp") // Get container port and return the host one
}
 
We can easily create a container with RunWithOptions method and pass Repository and Tag fields to find and build Docker Image and then run a container.
We should always remember to close the container after tests are finished (unless you have a very specific use case to not do that). We can do this by calling method Close on container instance like the following:
if err := container.Close(); err != nil {
   // do something with error
}
It's important because if we forgot about that we'll be clogging up the container list unnecessarily even if tests are finished.
I prepared a full example of how to use Golang with dockertest inside your tests here:
package main

import (
	"database/sql"
	"fmt"
	_ "github.com/go-sql-driver/mysql"
	"github.com/google/go-cmp/cmp"
	"github.com/ory/dockertest/v3"
	"testing"
)

// RootUser the default name of MySQL root user. We run tests in root because we don't have to worry about privileges.
// Disclaimer: We run on root because of testing simplicity.
const RootUser = "root"

// DockerRepository is reference to Docker Hub's Repository.
const DockerRepository = "mysql"

// Version is a version of MySQL. Available versions are located on for DockerRepository Docker Hub.
const Version = "8.0"

// AllowEmptyPassword is a simple setup for valid connection for root user.
// Disclaimer: Do not use it on production but for tests it looks fine if you don't have specific requirements.
const AllowEmptyPassword = "MYSQL_ALLOW_EMPTY_PASSWORD=yes"

// Environments are passed into MySQL instance.
var Environments = []string{
	AllowEmptyPassword,
}

// pool holds reference to Docker.
var pool *dockertest.Pool

// init initialize docker pool.
func init() {
	var p, err = dockertest.NewPool("")
	if err != nil {
		panic(err)
	}

	pool = p
}

// mysqlContainer creates Docker container with MySQL and wait until MySQL is ready.
func mysqlContainer() (*sql.DB, *dockertest.Resource, error) {
	rootUser := RootUser

	container, err := pool.RunWithOptions(&dockertest.RunOptions{
		Repository: DockerRepository,
		Tag:        Version,
		Env:        Environments,
	})
	if err != nil {
		return nil, nil, err
	}

	// get mapped port
	mysqlHostPort := container.GetPort("3306/tcp")

	var db *sql.DB

	// wait until MySQL is ready
	if err := pool.Retry(func() error {
		var err error
		// connect with MySQL using port available in host
		db, err = sql.Open("mysql", fmt.Sprintf("%s@(localhost:%s)/mysql", rootUser, mysqlHostPort))
		if err != nil {
			return err
		}
		if err := db.Ping(); err != nil {
			return err
		}

		return nil
	}); err != nil {
		return nil, container, err
	}

	return db, container, nil
}

// getMessages returns messages from database.
func getMessages(db *sql.DB) ([]string, error) {
	messages := make([]string, 0)

	rows, err := db.Query("SELECT message FROM demo")
	if err != nil {
		return nil, err
	}

	defer rows.Close()
	for rows.Next() {
		var msg string

		if err := rows.Scan(&msg); err != nil {
			continue
		}

		messages = append(messages, msg)
	}

	err = rows.Err()
	if err != nil {
		return messages, err
	}

	return messages, nil
}

// seed is useful to prepare database with data before queries.
func seed(db *sql.DB, message string) error {
	if _, err := db.Query(`
		create table demo(
		   id INT NOT NULL AUTO_INCREMENT,
		   message VARCHAR(100) NOT NULL,
		   PRIMARY KEY ( id )
		);
	`); err != nil {
		return err
	}

	query, err := db.Prepare(`
		insert into demo(message) values (?);
	`)
	if err != nil {
		return err
	}
	if _, err := query.Exec(message); err != nil {
		return err
	}

	return nil
}

// beforeEach is a small helper function to run ephemeral environment for each test run.
func beforeEach(tx *testing.T) (db *sql.DB, done func(), err error) {
	var container *dockertest.Resource

	db, container, err = mysqlContainer()
	if err != nil {
		tx.Fatal(err)
		return
	}

	done = func() {
		if err := container.Close(); err != nil {
			tx.Fatal(err)
		}
	}

	return
}

// diff returns error message if expected is different from current.
func diff(expected interface{}, current interface{}) error {
	if d := cmp.Diff(expected, current); d != "" {
		return fmt.Errorf("%s mismatch (-want +got):\n%s", "messages are not equal", d)
	}

	return nil
}

func TestMySQLInsert(tx *testing.T) {
	tx.Run("Insert and query messages from database", func(t *testing.T) {
		db, done, err := beforeEach(tx)
		if done != nil {
			defer done()
		}
		if err != nil {
			tx.Fatal(err)
			return
		}

		if err := seed(db, "hello world"); err != nil {
			t.Fatal(err)
			return
		}
		messages, err := getMessages(db)
		if err != nil {
			t.Log(err)
		}

		expected := []string{"hello world"}

		if err := diff(expected, messages); err != nil {
			t.Fatal(err)
		}
	})
}
NOTE: The above code is far from a perfect one, but it's a copy & paste working solution from which you can start.

Conclusion

Thanks to this concept you could get rid of unnecessary mocks from your code and depends on the real implementation of the system you communicate with.
Unfortunately, this solution also has drawbacks which no doubt is a longer test run (because of cold Docker start and general more complex setup) and a more complicated CI/CD process but it's the motivation for writing the next article how can we oppose it.
 

© Patryk Zdunowski 2021 - 2024