Your application uses a database like Postgres? So how do you test your persistence layer to ensure it's working properly with a real Postgres database? Right, you need to test against a real Postgres. Since that test requires external infrastructure it's an integration test. You'll learn how easy it is to write integration tests for your Go application using Testcontainers and Docker.
Integration Test Setup
Our application stores users in a Postgres database. It uses the struct UserRepository
with a method FindByUsername
that uses plain SQL to find a user by username. We will write an integration test running against a real Postgres in Docker for the method FindByUsername
.
The integration test for the FindByUsername
of our UserRepository
looks like:
func TestUserRepository(t *testing.T) {
// Setup database
dbContainer, connPool := SetupTestDatabase()
defer dbContainer.Terminate(context.Background())
// Create user repository
repository := NewUserRepository(connPool)
// Run tests against db
t.Run("FindExistingUserByUsername", func(t *testing.T) {
adminUser, err := repository.FindByUsername(
context.Background(),
"admin",
)
is.NoErr(err)
is.Equal(adminUser.Username, "admin")
})
}
First the database is set up. Then a new UserRepository
is created for the test with a reference to the connection pool of the database connPool
. No we run the method to test userRepository.FindByUsername(ctx, "admin")
and verify the result. But wait, where did that database container come from? Right, we'll set that up using Testcontainers and Docker.
Database Setup using Testcontainers
We set up the PostgreSQL database in a Docker container using the Testcontainers library.
As a first step we create a testcontainers.ContainerRequest
where we set the Docker image to postgres:14
with exposed port 5432/tcp
. The database name as well as username and password are set using environment variables. And to make sure the test only starts when the database container is up and running we wait for it using the WaitingFor
option with wait.ForListeningPort("5432/tcp")
.
Now as second step we start the requested container.
Finally in step 3 we use host and port of the running database container in the connection string for the database with fmt.Sprintf("postgres://postgres:postgres@%v:%v/testdb", host, port.Port())
. Now we connect with pgxpool.Connect(context.Background(), dbURI)
.
The whole method SetupTestDatabase
to set up the PostgreSQL container is (errors omitted):
func SetupTestDatabase() (testcontainers.Container, *pgxpool.Pool) {
// 1. Create PostgreSQL container request
containerReq := testcontainers.ContainerRequest{
Image: "postgres:latest",
ExposedPorts: []string{"5432/tcp"},
WaitingFor: wait.ForListeningPort("5432/tcp"),
Env: map[string]string{
"POSTGRES_DB": "testdb",
"POSTGRES_PASSWORD": "postgres",
"POSTGRES_USER": "postgres",
},
}
// 2. Start PostgreSQL container
dbContainer, _ := testcontainers.GenericContainer(
context.Background(),
testcontainers.GenericContainerRequest{
ContainerRequest: containerReq,
Started: true,
})
// 3.1 Get host and port of PostgreSQL container
host, _ := dbContainer.Host(context.Background())
port, _ := dbContainer.MappedPort(context.Background(), "5432")
// 3.2 Create db connection string and connect
dbURI := fmt.Sprintf("postgres://postgres:postgres@%v:%v/testdb", host, port.Port())
connPool, _ := pgxpool.Connect(context.Background(), dbURI)
return dbContainer, connPool
}
Notice that we make sure the PostgreSQL container is terminated after our integration tests with defer dbContainer.Terminate(context.Background())
.
Adding Database Migrations
So far our test starts out with an empty database. That's not very useful since we need the database tables of our application. In our example we need the table users
. We will now set up our database using golang-migrate.
We add the database migrations to the SetupTestDatabase()
method by adding the call MigrateDb(dbURI)
.
func SetupTestDatabase() (testcontainers.Container, *pgxpool.Pool) {
// ... (see before)
// 3.2 Create db connection string and connect
dbURI := fmt.Sprintf("postgres://postgres:postgres@%v:%v/testdb", host, port.Port())
MigrateDb(dbURI)
connPool, _ := pgxpool.Connect(context.Background(), dbURI)
return dbContainer, connPool
}
The method MigrateDb(dbURI)
applies the database migrations to the database using golang-migrate. The migration scripts are read from the directory migrations
which is embedded into the binary of our application.
//go:embed migrations
var migrations embed.FS
func MigrateDb(dbURI string) error {
source, _ := iofs.New(migrations, "migrations")
m, _ := migrate.NewWithSourceInstance("iofs", source, strings.Replace(dbURI, "postgres://", "pgx5://", 1))
defer m.Close()
err = m.Up()
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
return err
}
return nil
}
Wrap Up
We have a working setup for integration tests against a real Postgres database running in Docker using Testcontainers. We can use this setup for integration tests of our persistence layer. But that's not all it's good for.
This setup is a great way for all kinds of integration tests that need infrastructure. E.g. a test that send emails to an mail server running in docker, as in mail_resource_smtp_test.go.
Top comments (1)
I took the liberty to submit your article to HackerNews - as I see it highly relevant for current development teams... TCs should be part of more dev-toolchains, I don't encounter them often enough...
Thanx for your post.