During my path learning go so far I have come across some amazing libraries and utilites, one of my favourite for integration testing is dockertest.
Whenever I am using a service backed by postgres, mongo, mysql or other services that are not part of my codebase, I generally create a docker-compose file so that my development environments are isolated. Then when I’m working in a particular project, all I have to do is docker-compose up -d
to get going, and docker-compose down
when I’m finished for the day.
For example if I just need a postgres database I would usually have something like
services:
postgres:
image: postgres:15
ports:
- 5432:5432
volumes:
- pg-db:/var/lib/postgresql/data
environment:
POSTGRES_HOST_AUTH_METHOD: trust
POSTGRES_DB: example
volumes:
pg-data:
This is great for development, but when running integration tests, if I want a clean database is means I need some additional steps. I can either:
- Manually drop and create the database
- Drop and create the database as part of the integration test
- Many other options
Instead of creating some automation to drop and create databases or tables, dockertest allows us to create a completley clean isolated instance of the service, so we know every time we are starting from nothing. This follows the cattle not pets idealology from devops.
We are just discussing postgres here, but some other docker services might require a LOT manual steps to get into a good condition for test runs.
Example Link to heading
I created an example project here to show everything in action, but I will work through each step I took.
The project I created is very simple and the main work is in the following code:
package database
import (
"fmt"
"log"
"os"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
type (
Person struct {
gorm.Model
Name string
Age uint
}
)
var db *gorm.DB
func Connect() {
log.Println("Setting up the database")
pgUrl := fmt.Sprintf("postgresql://postgres@127.0.0.1:%s/example", os.Getenv("POSTGRES_PORT"))
log.Printf("Connecting to %s\n", pgUrl)
var err error
db, err = gorm.Open(postgres.Open(pgUrl), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}
// Migrate the schema
db.AutoMigrate(&Person{})
}
func CreatePerson() {
log.Println("Creating a new person in the database")
person := Person{Name: "Danny", Age: 42}
db.Create(&person)
log.Println("Trying to write a new person to the database")
}
func CountPeople() int {
var count int64
db.Model(&Person{}).Count(&count)
return int(count)
}
So we have a method to connect, a method to create a new person record and a method to count the records. These are all called from the main entry point
func main() {
database.Connect()
database.CreatePerson()
count := database.CountPeople()
log.Printf("Database has %d people", count)
}
If I run this from the command line (once docker compose is up) the result increments the count on every run (as it should)
go-dockertest-example λ git main → go run main.go
2023/09/03 13:41:06 Setting up the database
2023/09/03 13:41:06 Connecting to postgresql://postgres@127.0.0.1:5432/example
2023/09/03 13:41:06 Creating a new person in the database
2023/09/03 13:41:06 Trying to write a new person to the database
2023/09/03 13:41:06 Database has 3 people
go-dockertest-example λ git main → go run main.go
2023/09/03 13:41:07 Setting up the database
2023/09/03 13:41:07 Connecting to postgresql://postgres@127.0.0.1:5432/example
2023/09/03 13:41:08 Creating a new person in the database
2023/09/03 13:41:08 Trying to write a new person to the database
2023/09/03 13:41:08 Database has 4 people
The Test Link to heading
Now lets look at the test. First there is the setup of dockertest:
func TestMain(m *testing.M) {
// Start a new docker pool
pool, err := dockertest.NewPool("")
if err != nil {
log.Fatalf("Could not construct pool: %s", err)
}
// Uses pool to try to connect to Docker
err = pool.Client.Ping()
if err != nil {
log.Fatalf("Could not connect to Docker: %s", err)
}
pg, err := pool.RunWithOptions(&dockertest.RunOptions{
Repository: "postgres",
Tag: "15",
Env: []string{
"POSTGRES_DB=example",
"POSTGRES_HOST_AUTH_METHOD=trust",
"listen_addresses = '*'",
},
}, func(config *docker.HostConfig) {
// set AutoRemove to true so that stopped container goes away by itself
config.AutoRemove = true
config.RestartPolicy = docker.RestartPolicy{
Name: "no",
}
})
if err != nil {
log.Fatalf("Could not start resource: %s", err)
}
pg.Expire(10)
// Set this so our app can use it
postgresPort := pg.GetPort("5432/tcp")
os.Setenv("POSTGRES_PORT", postgresPort)
// Wait for the Postgres to be ready
if err := pool.Retry(func() error {
_, connErr := gorm.Open(postgres.Open(fmt.Sprintf("postgresql://postgres@localhost:%s/example", postgresPort)), &gorm.Config{})
if connErr != nil {
return connErr
}
return nil
}); err != nil {
panic("Could not connect to postgres: " + err.Error())
}
code := m.Run()
os.Exit(code)
}
So first we create a new docker pool, we make sure the pool responds to a ping then start a postgres instance. When you start a new instance it will grab a random port, so you need a way to pass this to the service, this is why we have the POSTGRES_PORT
env var. At the very end of the module setup we set that env so that test will use it.
// Set this so our app can use it
postgresPort := pg.GetPort("5432/tcp")
os.Setenv("POSTGRES_PORT", postgresPort)
There is a section for waiting for postgres to be ready, you can use this in different ways, but basically you are using some condition to test the instance is ready. This could be anything from checking a port is addressable, to calling a http healthcheck.
// Wait for the Postgres to be ready
if err := pool.Retry(func() error {
_, connErr := gorm.Open(postgres.Open(fmt.Sprintf("postgresql://postgres@localhost:%s/example", postgresPort)), &gorm.Config{})
if connErr != nil {
return connErr
}
return nil
}); err != nil {
panic("Could not connect to postgres: " + err.Error())
}
Then there is:
// Make sure we expire the instance after 10 seconds
postgres.Expire(10)
this ensures if cleanup does not succeed we will remove the docker instance after 10 seconds regardless.
Finally we have the actual test
func TestCreatePerson(t *testing.T) {
// Connect to the database
database.Connect()
// Create a person in the database
database.CreatePerson()
// Check that the person was created
count := database.CountPeople()
if count != 1 {
t.Errorf("Expected 1 person to be in the database, got %d", count)
}
}
Each time the test runs there will only be one record in the database.