DEV Community

Cover image for Go Integration Tests using Testcontainers
Jan Stamer
Jan Stamer

Posted on • Updated on

Go Integration Tests using Testcontainers

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")
    })
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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://", "pgx://", 1))
    defer m.Close()

    err = m.Up()
    if err != nil && !errors.Is(err, migrate.ErrNoChange) {
        return err
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

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.

Oldest comments (1)

Collapse
 
gernotstarke profile image
Dr. Gernot Starke

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.