Zachary Blake

Golang + Docker Integration Tests

  ยท   5 min read

I’m currently working on a project of mine and have gotten the codebase for my backend to a point that I want to start writing tests. I recently read an article called Why mocking is a bad idea and I agree with the author’s take. I’ve worked on a few projects in my career that make use of mocks, and it’s always felt like a bit of a gimmick to me. That being said, I think they still can be used, but you have to be careful.

My main worry is not being thourough enough in my testing, perhaps missing an error that I then only find out about from users later down the line, once it’s already done damage to their experience. Enshittification must be avoided at all costs. So with that in mind, I want to make sure that I’ve got things setup for success from the beginning.

My backend is written in Go, which has great support for testing built into the language. While taking stock of my codebase, and deciding where to place tests, I decided on the following:

  • Unit tests ought to test small units of code, not larger pieces of code like a request handler.
  • We can and should write integration tests for testing our API
  • Test files should be placed where they can actually have impact
  • Avoid redundant tests

I could have written unit tests for each handler individually, and indeed in the past I have done that, however like I mentioned above this process made use of mocks, which have always felt cheap to me. It doesn’t do me much good to bypass entire pieces of my application (e.g middleware) by mocking their execution, and mocking database execution also doesn’t help me in validating my schema.

Instead, I can write integration tests for my API and test the entire system, from ingesting a request, passing it through middleware, parsing to structs, the subsequent database operations, and then finally returning the expected response. This way, I can test from the position of a client.

Using Docker

Up to this point, I’ve been using Docker Compose to run my backend and all of the external resources it reaches out to during its operation. This has been fine for manually testing that things are working like they should, but it’s time to start automating these tests. For this, I’ve opted to use github.com/ory/dockertest/v3.

package server_test

import (
	"bytes"
	"context"
	"crypto"
	"database/sql"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"testing"

	"code.posterity.life/srp/v2"
	"github.com/heyztb/lists/internal/log"
	"github.com/heyztb/lists/internal/models"
	"github.com/ory/dockertest/v3"
	"github.com/redis/go-redis/v9"
	"github.com/rs/zerolog"
	migrate "github.com/rubenv/sql-migrate"
	"github.com/stretchr/testify/assert"
	"golang.org/x/crypto/argon2"
)

var (
	db          *sql.DB
	redisClient *redis.Client
	baseUrl     string
	httpClient  = http.Client{
		Transport: &http.Transport{},
	}
	salt      []byte
	srpClient *srp.Client
	triplet   srp.Triplet
)

func TestMain(m *testing.M) {
	log.Logger = zerolog.New(os.Stdout).With().Caller().Timestamp().Logger()

	pool, err := dockertest.NewPool("")
	if err != nil {
		log.Fatal().Err(err).Msg("failed to initialize pool")
	}

	err = pool.Client.Ping()
	if err != nil {
		log.Fatal().Err(err).Msg("failed to ping docker")
	}

	redisContainer, err := pool.RunWithOptions(&dockertest.RunOptions{
		Name:       "backend-redis-test",
		Hostname:   "redis",
		Repository: "redis",
		Tag:        "latest",
		NetworkID:  "f03144698a89",
	})
	if err != nil {
		log.Fatal().Err(err).Msg("failed to create redis container")
	}

	if err := pool.Retry(func() error {
		redisClient = redis.NewClient(&redis.Options{
			Addr: redisContainer.GetHostPort("6379/tcp"),
			DB:   0,
		})
		return redisClient.Ping(context.Background()).Err()
	}); err != nil {
		log.Fatal().Err(err).Msg("failed to connect to redis")
	}

	database, err := pool.RunWithOptions(&dockertest.RunOptions{
		Name:       "backend-db-test",
		Hostname:   "db",
		Repository: "backend-db",
		Tag:        "latest",
		Env: []string{
			"POSTGRES_USER=listsdb-testing",
			"POSTGRES_PASSWORD=testing",
			"POSTGRES_DB=lists-backend-test",
		},
		NetworkID: "f03144698a89",
	})
	if err != nil {
		log.Fatal().Err(err).Msg("failed to create database container")
	}

	if err := pool.Retry(func() error {
		var err error
		db, err = sql.Open("postgres", fmt.Sprintf("user=listsdb-testing password=testing dbname=lists-backend-test host=127.0.0.1 port=%s sslmode=disable", database.GetPort("5432/tcp")))
		if err != nil {
			return err
		}
		return db.Ping()
	}); err != nil {
		log.Fatal().Err(err).Msg("failed to connect to database")
	}

  // ...

In the code snippet above you can see that I’m setting up containers for Redis and our database. In this case, I’m using a custom Postgres image with a UUID v7 extension. This is all fairly straightforward, but now we need a way to get our ephemeral database container up to speed with our database schema. I’ve been using github.com/rubenv/sql-migrate throughout development to run migrations on my database, and fortunately we can use this tool programtically as well. Here’s how that looks:

  // ...

	migrations := migrate.FileMigrationSource{
		Dir: "../../sql",
	}

	migrationSlice, err := migrations.FindMigrations()
	if err != nil {
		log.Fatal().Err(err).Msg("failed to find migrations")
	}

	n, err := migrate.Exec(db, "postgres", migrations, migrate.Up)
	if err != nil {
		log.Fatal().Err(err).Msg("failed to run migrations")
	}

	if n != len(migrationSlice) {
		// wonder if we can ever reach this point, the docs seem to suggest so
		// but question is if the above err wil be nil or not if not all migrations run
		// too many levels of abstraction for me to want to look into it
		log.Fatal().Msg("did not run all migrations")
	}

  // ...

And finally, I’ve got the actual backend server under test running in Docker as well.

  // ...

	backend, err := pool.RunWithOptions(&dockertest.RunOptions{
		Name:       "backend-test",
		Repository: "listsbackend",
		Tag:        "latest",
		Env: []string{/* env variables to power the backend server */},
		Mounts: []string{
			"logs:/var/log/backend",
		},
		NetworkID: "f03144698a89",
	})
	if err != nil {
		log.Fatal().Err(err).Msg("failed to create backend container")
	}

	if err := pool.Retry(func() error {
		addr := backend.GetHostPort("4322/tcp")
		baseUrl = fmt.Sprintf("http://%s", addr)
		_, err := http.Get(baseUrl)
		return err
	}); err != nil {
		log.Fatal().Err(err).Msg("failed to connect to backend")
	}

	code := m.Run()

  // after we get an exit code back we want to clean up the containers we spawned
	if err := redisContainer.Close(); err != nil {
		log.Fatal().Err(err).Msg("could not close redis")
	}

	if err := database.Close(); err != nil {
		log.Fatal().Err(err).Msg("could not close database")
	}

	if err := backend.Close(); err != nil {
		log.Fatal().Err(err).Msg("could not close backend")
	}

	os.Exit(code)
}

So, after all of this, we are able to spin up a full server and all of the backing services. Not a single mock in sight. What a beauty.

At this stage, all that’s left to do is write the tests. I’ve gone ahead and written a few to validate my registration and login logic, and aim to use this suite to test out the rest of my API.