Using TestContainers and the Bun ORM in Go for PostgreSQL Testing

June 19, 2023

Photo: https://golang.testcontainers.org/logo.png

Introduction

Testing a codebase that interacts with a database can be challenging. It is important to ensure that your tests are isolated and do not have side-effects that could influence other tests.

…and at times, we simply wish to test or experiment with certain queries to see if they will produce the desired outcome.

One common way to achieve this is by using a dedicated database instance for testing that spins up and tears down for each test.

This article presents an efficient method to implement such a testing strategy in Go using the TestContainers library for managing the test database and the Bun Object-Relational Mapping (ORM) library for interacting with it.

Prerequisite

To run the test codes, Docker needs to be installed and set up on your local machine since the TestContainers library relies on Docker to create and manage containers. If you have docker installed you can skip this step.

Follow these steps to setup Docker:

  1. Install Docker: The first step is to install Docker on your system. The installation process varies depending on your operating system:

    • For Windows and MacOS: You can download Docker Desktop from the official Docker website here and follow the instructions to install it.
    • For Ubuntu: Use the following commands to install Docker:
    sudo apt-get update
    sudo apt-get install apt-transport-https ca-certificates curl gnupg lsb-release
    curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
    echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
    sudo apt-get update
    sudo apt-get install docker-ce docker-ce-cli containerd.io
  2. Verify Docker Installation: To ensure that Docker has been installed correctly, you can run the following command:

    docker --version
  3. Pull the PostgreSQL Docker Image: Before you can use the PostgreSQL container in your tests, you need to pull the PostgreSQL image from Docker Hub. You can do this with the following command:

    docker pull postgres:14.1

Using Docker and TestContainers in Go Tests

Once Docker is set up, you can leverage it in your Go tests. Here, several key Go packages are used. The testcontainers package provides a simple, lightweight API for starting, stopping and interacting with Docker containers. The bun package is a simple, fast, and productive ORM for Go programming language. It supports PostgreSQL, MySQL, SQLite and SQL Server and comes with a bunch of cool features like eager loading, batch insert, updates, deletes, and flexible pagination.

Test Schema setup schema.go

package main

import (
 "context"

 "github.com/uptrace/bun"
)

type Comment struct {
 ID            int64 `bun:",pk,autoincrement"`
 TrackableID   int64
 TrackableType string
 Text          string

 Article *Article `bun:"rel:belongs-to,join:trackable_id=id,polymorphic"`
 Post    *Post    `bun:"rel:belongs-to,join:trackable_id=id,polymorphic"`
}

type User struct {
 ID   int64 `bun:",pk,autoincrement"`
 Name string
}

type Article struct {
 ID       int64 `bun:",pk,autoincrement"`
 Name     string
 Comments []Comment `bun:"rel:has-many,join:id=trackable_id,join:type=trackable_type,polymorphic"`
}

type Post struct {
 ID   int64 `bun:",pk,autoincrement"`
 Name string

 Comments []Comment `bun:"rel:has-many,join:id=trackable_id,join:type=trackable_type,polymorphic"`
}

func createSchema(ctx context.Context, db *bun.DB) error {

 err := db.ResetModel(ctx,
  (*Post)(nil),
  (*User)(nil),
  (*Article)(nil),
  (*Comment)(nil),
 )
 if err != nil {
  return err
 }

 user := User{
  ID:   1,
  Name: "Ayodeji",
 }

 if _, err := db.NewInsert().Model(&user).Exec(ctx); err != nil {
  return err
 }

 posts := []*Article{
  {
   ID:   1,
   Name: "Using TestContainers and the Bun ORM in Go for PostgreSQL Testing",
  },
 }
 if _, err := db.NewInsert().Model(&posts).Exec(ctx); err != nil {
  return err
 }

 comments := []*Comment{
  {
   ID:            1,
   TrackableID:   1,
   TrackableType: "article",
   Text:          "Awesome!",
  },
 }
 if _, err := db.NewInsert().Model(&comments).Exec(ctx); err != nil {
  return err
 }

 return nil
}

Test setup main_test.go

package main

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

 "github.com/google/uuid"
 "github.com/stretchr/testify/assert"
 "github.com/stretchr/testify/require"
 tc "github.com/testcontainers/testcontainers-go"
 "github.com/testcontainers/testcontainers-go/wait"
 "github.com/uptrace/bun"
 "github.com/uptrace/bun/dialect/pgdialect"
 "github.com/uptrace/bun/driver/pgdriver"
 "github.com/uptrace/bun/extra/bundebug"
)

const (
 dbUser     = "user"
 dbPassword = "password"
)

var dbContainer tc.Container

// connectToPostgresForTest connects to a PostgreSQL database using provided connection parameters such as host, user, password, database name, and port. 
// and also instantiates the database schema. 
func connectToPostgresForTest(
 t *testing.T,
 host, user, password, dbname, port string,
) (*bun.DB, error) {
 dsn := fmt.Sprintf("postgresql://%s:%s@%s:%s/%s?sslmode=disable", user, password, host, port, dbname)

 sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(dsn)))
 db := bun.NewDB(sqldb, pgdialect.New())

 db.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true)))

 if err := createSchema(context.Background(), db); err != nil {
  log.Fatalf("Schema creation failed: %v", err)
 }
 log.Print("Successfully connected to database")

 return db, nil
}

// The function initializeDatabase is used to create a database within the PostgreSQL container that's been spun up.
// It employs the Exec function provided by the TestContainers package to execute the createdb command within the container's context.
func initializeDatabase(
 t *testing.T,
 ctx context.Context,
 container tc.Container,
 dbname, user, pass string,
) {
 t.Helper()

 exitCode, _, err := container.Exec(ctx, []string{
  "createdb",
  "-p", "5432",
  "-h", "localhost",
  "-U", user,
  dbname,
 })

 require.NoError(t, err)
 require.Equal(t, 0, exitCode, "Non-zero exit code from 'createdb'")
}

func prepareTestPostgresDatabase(
 t *testing.T,
 container tc.Container,
 user, password, dbname string,
) *bun.DB {
 t.Helper()
 ctx := context.Background()

 // Initialize database
 initializeDatabase(t, ctx, container, dbname, user, password)

 mappedPort, err := container.MappedPort(ctx, "5432")
 require.NoError(t, err)

 // Establish database connection
 pg, err := connectToPostgresForTest(
  t,
  "localhost", user, password, dbname, mappedPort.Port(),
 )
 require.NoError(t, err)

 return pg
}

// startPostgresContainer initializes a postgres container with provided credentials.
func startPostgresContainer(ctx context.Context, user, password string) tc.Container {
 req := tc.ContainerRequest{
  Image: "postgres:14.1",
  Env: map[string]string{
   "POSTGRES_USER":     user,
   "POSTGRES_PASSWORD": password,
  },
  SkipReaper:   true,
  ExposedPorts: []string{"5432/tcp"},
  WaitingFor:   wait.ForListeningPort("5432/tcp"),
 }
 container, err := tc.GenericContainer(
  ctx,
  tc.GenericContainerRequest{ContainerRequest: req, Started: true},
 )


 if err != nil {
  log.Fatal("Failed to start test container")
 }

 return container
}

func TestMain(m *testing.M) {
 // Start a container for all tests; each test will use its own database in this
 // container.
 ctx := context.Background()
 dbContainer = startPostgresContainer(ctx, dbUser, dbPassword)

 exitCode := m.Run()
 os.Exit(exitCode)
}

func TestSelectArticle(t *testing.T) {
 ctx := context.Background()

 db := prepareTestPostgresDatabase(
  t, dbContainer, dbUser, dbPassword, uuid.NewString(),
 )

 article := new(Article)
 err := db.NewSelect().
  Model(article).
  Relation("Comments").
  Where("id = 1").
  Scan(ctx)

 // Sample test assertion
 require.NoError(t, err)
 assert.NotNil(t, article.Comments)
 assert.GreaterOrEqual(t, 1, len(article.Comments))
}

The test setup begins in the TestMain function where a Docker container for PostgreSQL is started and ends when the tests are completed. For each test, a fresh database is created on the running PostgreSQL container which ensures that tests are completely isolated and run in a clean environment.

The TestSelectArticle function is a test case that uses the Bun ORM to fetch an article and its comments from the database, then asserts that the article was fetched successfully and that the comments are not nil and at least one exists.

Run your tests with:

go test .

In summary, the presented Go code demonstrates how to employ Docker, TestContainers, and the Bun ORM library for testing database-related functionality. This setup allows developers to execute automated tests, unit testing or experimental database queries, within a real yet isolated database environment, emulating the actual system conditions more accurately and ensuring the tested code’s quality and reliability.

Useful Links:

TestContainers: https://github.com/testcontainers/testcontainers-go

Bun ORM: https://bun.uptrace.dev/