🚨 AtomicJar is now part of Docker 🐋! Read the blog

Getting started with Testcontainers for Go

  • Go
  • PostgreSQL
Get the code

In this guide, you will learn how to

  • Create a Go application with modules support.

  • Implement a Repository to manage customers data in PostgreSQL database using pgx driver.

  • Test the database interactions using testcontainers-go.

Prerequisites

What we are going to achieve in this guide

We are going to create a Go project and implement a Repository to save and retrieve the customer details from a PostgreSQL database. Then we will test this repository using the testcontainers-go postgres module.

Getting Started

Let’s start with creating a Go project.

$ mkdir testcontainers-go-demo
$ cd testcontainers-go-demo
$ go mod init github.com/testcontainers/testcontainers-go-demo

We are going to use the jackc/pgx PostgreSQL Driver to interact with the Postgres database and the testcontainers-go postgres module to spin up a Postgres docker instance for testing. Also, we are going to use testify for running multiple tests as a suite and for writing assertions.

NoteIf you are new to Testcontainers, then please visit Testcontainers Getting Started page to learn more about Testcontainers and the benefits of using it.

Let’s install these dependencies.

$ go get github.com/jackc/pgx/v5
$ go get github.com/testcontainers/testcontainers-go
$ go get github.com/testcontainers/testcontainers-go/modules/postgres
$ go get github.com/stretchr/testify

After installing these dependencies, your go.mod file should look like this:

module github.com/testcontainers/testcontainers-go-demo

go 1.19

require (
   github.com/jackc/pgx/v5 v5.3.1
   github.com/stretchr/testify v1.8.3
   github.com/testcontainers/testcontainers-go v0.20.1
   github.com/testcontainers/testcontainers-go/modules/postgres v0.20.1
)

require (
   // indirect dependencies here
)

Create Customer struct

First, let us start with creating a types.go file in customer package and define the Customer struct to model the customer details as follows:

package customer

type Customer struct {
	Id    int
	Name  string
	Email string
}

Create Repository

Next, create customer/repo.go file, define the Repository struct and then add methods to create a new customer and get a customer by email as follows:

package customer

import (
	"context"
	"fmt"
	"os"

	"github.com/jackc/pgx/v5"
)

type Repository struct {
	conn *pgx.Conn
}

func NewRepository(ctx context.Context, connStr string) (*Repository, error) {
	conn, err := pgx.Connect(ctx, connStr)
	if err != nil {
		_, _ = fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err)
		return nil, err
	}
	return &Repository{
		conn: conn,
	}, nil
}

func (r Repository) CreateCustomer(ctx context.Context, customer Customer) (Customer, error) {
	err := r.conn.QueryRow(ctx,
		"INSERT INTO customers (name, email) VALUES ($1, $2) RETURNING id",
		customer.Name, customer.Email).Scan(&customer.Id)
	return customer, err
}

func (r Repository) GetCustomerByEmail(ctx context.Context, email string) (Customer, error) {
	var customer Customer
	query := "SELECT id, name, email FROM customers WHERE email = $1"
	err := r.conn.QueryRow(ctx, query, email).
		Scan(&customer.Id, &customer.Name, &customer.Email)
	if err != nil {
		return Customer{}, err
	}
	return customer, nil
}

Let’s understand what is going on here:

  • We have defined a Repository struct with a field of type *pgc.Conn which will be used for performing database operations.

  • We have defined a helper function NewRepository(connStr) that takes a database connection string and initializes Repository.

  • Then we have implemented CreateCustomer() and GetCustomerByEmail() methods on the Repository receiver.

Write test for Repository using testcontainers-go

We have our Repository implementation ready, but for testing we need a PostgreSQL database. We can use testcontainers-go to spin up a Postgres database in a Docker container and run our tests connecting to that database.

In real applications we might use some database migration tool, but for this guide let us use a simple script to initialize our database.

Create a testdata/init-db.sql file to create CUSTOMERS table and insert the sample data as follows:

CREATE TABLE IF NOT EXISTS customers (id serial, name varchar(255), email varchar(255));

INSERT INTO customers(name, email) VALUES ('John', '[email protected]');

The testcontainers-go library provides the generic Container abstraction that can be used to run any containerised service. To further simplify, testcontainers-go provides technology specific modules that will reduce the boilerplate and also provides a functional options pattern to easily construct the container instance.

For example, PostgresContainer provides WithImage(), WithDatabase(), WithUsername(), WithPassword() etc functions to set various properties of Postgres containers easily.

Now, let’s create the customer/repo_test.go file and implement the test as follows:

package customer

import (
	"context"
	"path/filepath"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/modules/postgres"
	"github.com/testcontainers/testcontainers-go/wait"
)

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

	pgContainer, err := postgres.RunContainer(ctx,
		testcontainers.WithImage("postgres:15.3-alpine"),
		postgres.WithInitScripts(filepath.Join("..", "testdata", "init-db.sql")),
		postgres.WithDatabase("test-db"),
		postgres.WithUsername("postgres"),
		postgres.WithPassword("postgres"),
		testcontainers.WithWaitStrategy(
			wait.ForLog("database system is ready to accept connections").
				WithOccurrence(2).WithStartupTimeout(5*time.Second)),
	)
	if err != nil {
		t.Fatal(err)
	}

	t.Cleanup(func() {
		if err := pgContainer.Terminate(ctx); err != nil {
			t.Fatalf("failed to terminate pgContainer: %s", err)
		}
	})

	connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
	assert.NoError(t, err)

	customerRepo, err := NewRepository(ctx, connStr)
	assert.NoError(t, err)

	c, err := customerRepo.CreateCustomer(ctx, Customer{
		Name:  "Henry",
		Email: "[email protected]",
	})
	assert.NoError(t, err)
	assert.NotNil(t, c)

	customer, err := customerRepo.GetCustomerByEmail(ctx, "[email protected]")
	assert.NoError(t, err)
	assert.NotNil(t, customer)
	assert.Equal(t, "Henry", customer.Name)
	assert.Equal(t, "[email protected]", customer.Email)
}

Let’s understand what is going on here:

  • We have created an instance of PostgresContainer by specifying the Docker image postgres:15.3-alpine, from which the container needs to be created.

  • We have configured the initialization scripts using WithInitScripts(…​) so that after the database starts, the CUSTOMERS table will be created and sample data will be inserted.

  • Next, we have specified the username, password and database name for the Postgres container.

  • We have configured the WaitStrategy that will help to determine whether the Postgres container is fully ready to use or not.

  • Then, we have defined the test cleanup function using t.Cleanup(…​) so that at the end of the test the Postgres container will be removed.

  • Next, we obtained the database ConnectionString from PostgresContainer and initialized Repository.

  • Then, we have created a new customer with the email [email protected] and verified that a customer with the email [email protected] exists in our database.

Reusing the containers and running multiple tests as a suite

In the previous section, we saw how to spin up a Postgres Docker container for a single test. But usually we might have multiple tests in a single file, and we may want to reuse the same Postgres Docker container for all the tests in that file.

We can use testify suite package to implement common test setup and teardown actions.

First, let us extract PostgresContainer creation logic into a separate file called testhelpers/containers.go.

package testhelpers

import (
	"context"
	"path/filepath"
	"time"

	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/modules/postgres"
	"github.com/testcontainers/testcontainers-go/wait"
)

type PostgresContainer struct {
	*postgres.PostgresContainer
	ConnectionString string
}

func CreatePostgresContainer(ctx context.Context) (*PostgresContainer, error) {
	pgContainer, err := postgres.RunContainer(ctx,
		testcontainers.WithImage("postgres:15.3-alpine"),
		postgres.WithInitScripts(filepath.Join("..", "testdata", "init-db.sql")),
		postgres.WithDatabase("test-db"),
		postgres.WithUsername("postgres"),
		postgres.WithPassword("postgres"),
		testcontainers.WithWaitStrategy(
			wait.ForLog("database system is ready to accept connections").
				WithOccurrence(2).WithStartupTimeout(5*time.Second)),
	)
	if err != nil {
		return nil, err
	}
	connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
	if err != nil {
		return nil, err
	}

	return &PostgresContainer{
		PostgresContainer: pgContainer,
		ConnectionString:  connStr,
	}, nil
}

In containers.go, we have defined PostgresContainer struct which extends testcontainers-go PostgresContainer struct to provide easy access to ConnectionString and created CreatePostgresContainer() function to instantiate PostgresContainer.

Now, let’s create customer/repo_suite_test.go file and implement tests for creating a new customer and getting customer by email by using testify suite package as follows:

package customer

import (
	"context"
	"log"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/suite"
	"github.com/testcontainers/testcontainers-go-demo/testhelpers"
)

type CustomerRepoTestSuite struct {
	suite.Suite
	pgContainer *testhelpers.PostgresContainer
	repository  *Repository
	ctx         context.Context
}

func (suite *CustomerRepoTestSuite) SetupSuite() {
	suite.ctx = context.Background()
	pgContainer, err := testhelpers.CreatePostgresContainer(suite.ctx)
	if err != nil {
		log.Fatal(err)
	}
	suite.pgContainer = pgContainer
	repository, err := NewRepository(suite.ctx, suite.pgContainer.ConnectionString)
	if err != nil {
		log.Fatal(err)
	}
	suite.repository = repository
}

func (suite *CustomerRepoTestSuite) TearDownSuite() {
	if err := suite.pgContainer.Terminate(suite.ctx); err != nil {
		log.Fatalf("error terminating postgres container: %s", err)
	}
}

func (suite *CustomerRepoTestSuite) TestCreateCustomer() {
	t := suite.T()

	customer, err := suite.repository.CreateCustomer(suite.ctx, Customer{
		Name:  "Henry",
		Email: "[email protected]",
	})
	assert.NoError(t, err)
	assert.NotNil(t, customer.Id)
}

func (suite *CustomerRepoTestSuite) TestGetCustomerByEmail() {
	t := suite.T()

	customer, err := suite.repository.GetCustomerByEmail(suite.ctx, "[email protected]")
	assert.NoError(t, err)
	assert.NotNil(t, customer)
	assert.Equal(t, "John", customer.Name)
	assert.Equal(t, "[email protected]", customer.Email)
}

func TestCustomerRepoTestSuite(t *testing.T) {
	suite.Run(t, new(CustomerRepoTestSuite))
}

Let’s understand what is going on here:

  • We have created CustomerRepoTestSuite by extending suite.Suite struct and added fields which will be used across multiple tests in that suite.

  • In the SetupSuite() function which will be executed only once before executing the tests, we have created PostgresContainer and initialized Repository.

  • In TearDownSuite() function which will be executed only once after all the tests in that suite are executed, we are terminating the container which will destroy the Postgres Docker container.

  • Next, we have created the tests TestCreateCustomer() and TestGetCustomerByEmail() as receiver functions on the suite.

  • Finally, we have created the test function TestCustomerRepoTestSuite(t *testing.T) which will run the test suite when we execute the tests using go test.

TipFor the purpose of this guide, we are not resetting the data in the database. But it is a good practice to reset the database in a known state before running any test.

Run tests

You can run all the tests using go test ./…​ and optionally add the flag "-v" for displaying verbose output.

$ go test -v ./...

=== RUN   TestCustomerRepoTestSuite
...
...
2023/06/13 09:27:11 🐳 Creating container for image docker.io/testcontainers/ryuk:0.4.0
2023/06/13 09:27:11 ✅ Container created: 2881f4e311a2
2023/06/13 09:27:11 🐳 Starting container: 2881f4e311a2
2023/06/13 09:27:12 🚧 Waiting for container id 2881f4e311a2 image: docker.io/testcontainers/ryuk:0.4.0
2023/06/13 09:27:12 ✅ Container started: 2881f4e311a2
2023/06/13 09:27:12 🐳 Creating container for image postgres:15.3-alpine
2023/06/13 09:27:12 ✅ Container created: a98029633d02
2023/06/13 09:27:12 🐳 Starting container: a98029633d02
2023/06/13 09:27:13 🚧 Waiting for container id a98029633d02 image: postgres:15.3-alpine
2023/06/13 09:27:14 ✅ Container started: a98029633d02
=== RUN   TestCustomerRepoTestSuite/TestCreateCustomer
=== RUN   TestCustomerRepoTestSuite/TestGetCustomerByEmail
2023/06/13 09:27:14 🐳 Terminating container: a98029633d02
2023/06/13 09:27:15 🚫 Container terminated: a98029633d02
--- PASS: TestCustomerRepoTestSuite (3.66s)
    --- PASS: TestCustomerRepoTestSuite/TestCreateCustomer (0.00s)
    --- PASS: TestCustomerRepoTestSuite/TestGetCustomerByEmail (0.00s)
=== RUN   TestCustomerRepository
2023/06/13 09:27:15 🐳 Creating container for image postgres:15.3-alpine
2023/06/13 09:27:15 ✅ Container created: fcf4241a61ab
2023/06/13 09:27:15 🐳 Starting container: fcf4241a61ab
2023/06/13 09:27:15 🚧 Waiting for container id fcf4241a61ab image: postgres:15.3-alpine
2023/06/13 09:27:16 ✅ Container started: fcf4241a61ab
2023/06/13 09:27:16 🐳 Terminating container: fcf4241a61ab
2023/06/13 09:27:17 🚫 Container terminated: fcf4241a61ab
--- PASS: TestCustomerRepository (1.94s)
PASS
ok  	github.com/testcontainers/testcontainers-go-demo/customer	6.177s
?   	github.com/testcontainers/testcontainers-go-demo/testhelpers	[no test files]

You should see two Postgres docker containers automatically started: one for the suite and its two tests, and the other for the initial test we created, and all those tests should PASS. You can also notice that after tests are executed, the containers are stopped and removed automatically.

Summary

The Testcontainers for Go library helped us to write integration tests by using the same type of database, Postgres, that we use in production as opposed to using mocks. As we are not using mocks and talking to the real services, we are free to do any code refactoring and still ensure that the application is working as expected.

To learn more about Testcontainers visit http://testcontainers.com