Testcontainers and Testcontainers-go

Published on
-
5 mins read
Authors

In a typical large-scale backend system, you own a microservice which again depends on some downstream services including various database, caching & messaging queue, etc. components. Setting those up locally for the development definitely takes some manual efforts reducing our development focus. Also, writing mocks for unit testing our service is a hassle and don't give us an exact idea of how those integrations would behave in an actual production environment.

Let's explore Testcontainers to solve these concerns and improve upon our testing/development experience!

Introduction :-

Testcontainers is a library that provides easy and lightweight APIs for bootstrapping local development and test dependencies with real services wrapped in Docker containers. Using Testcontainers, you can write tests that depend on the same services you use in production, so no more need for mocks or complicated setup configurations. All you need is, Docker.

Q. Okay, we get it. But, what kind of dependencies/services we can run/test using Testcontainers?
Answer: You can test anything you can containerize - Databases, cache, message brokers, web servers and many more! Using testcontainers, we can define our dependencies as a code. Then simply run our tests and containers will be created and terminated during the clean up process at the end of our tests execution. Let's take an example -

In the below system diagram, Our service My Service depends on some downstream services (like Kafka, Service B, etc.)

Example system diagram from official docs

With the help of Testcontainers, we can run those dependencies as a containerized applications through code without the need of manual setup or pre-provisioned environment/infrastructure. Good thing is containers can be auto cleaned up as well once the purpose of testing/local development is served!

Note: Testcontainer supports all the major languages including - Java, Golang, Node.js, Python, Ruby, etc.

Let's see an example with Testcontainers-go, the package for Go!

Testcontainers-go:-

Testcontainers provides a programmatic abstraction called GenericContainer representing a Docker container. You can use GenericContainer to start a Docker container, get any container information such as hostname (the host under which the mapped ports are reachable), mapped ports, and stop the container, etc.

Let's take an example of Redis integration within our service -

cache_service_test.go
import (
    "context"

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

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

    // Spin up Redis container using testcontainers
    req := testcontainers.ContainerRequest{
        Image:        "redis:latest",   // docker imagename
        ExposedPorts: []string{"6379/tcp"},
        WaitingFor:   wait.ForLog("Ready to accept connections"),
    }
    redisC, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
        ContainerRequest: req,
        Started:          true,
    })
    if err != nil {
        log.Fatalf("Could not start redis: %s", err)
    }

    // Get the redis connection URL
    connStr, err := redisC.ConnectionString(ctx)
	require.NoError(t, err)

    // Auto termination of redis container at the end of our tests
    t.Cleanup(func() {
		if err := redisC.Terminate(ctx); err != nil {
			t.Fatalf("failed to terminate redis container: %s", err)
		}
	})

    // Write your test cases
    t.Run("<TESTCASE NAME>", func(t *testing.T) {
        // test code...
    })
}

Important thing to note here is - we can use any test framework/libray supported by the language. Also, there's no restriction on the usage of any client libraries you would want to choose for dealing with your service dependencies like Redis, Databases, Message broker, etc.

Testcontainers modules:-

Testcontainers also provides modules for a wide range of commonly used dependencies like relational or NoSQL databases, web servers, message brokers, etc. These are higher-level abstraction on top of GenericContainer which help configure and run these technologies without any boilerplate.
For example, we can replace Redis container creation logic from above code snippet to this -

// add this into imports at top
import "github.com/testcontainers/testcontainers-go/modules/redis"

// Replace GenericContainer call with this
redisContainer, err := redis.RunContainer(ctx, testcontainers.WithImage("docker.io/redis:6-alpine"))
if err != nil {
    log.Fatalf("Could not start redis: %s", err)
}

Similarily, there are modules for PostgreSQL, Kafka, ElasticSearch, Cassandra, MongoDB and many more!

Outro:-

This was just to introduce you to Testcontainers and get you started with it. You can read through the official docs to dive deep into various features we get with respect to container configurations, wait strategies and dependency management, etc.

You can check out my demo github repository as a reference on how to use Testcontainers-go for as-a-code dependency management while running/testing your application locally. Showcases PostgreSQL and Redis integrations, And how we can perform integration as well as end-to-end testing with those dependencies.

References: