Backend Integration Testing with Testcontainers

Introduction

Frontend tests primarily focus on interacting with the browser (Jsdom🔗, headless browsers) and usually communicate with a single backend, while backend tests face a very different challenge: distributed services and state.

This article explains from a practical perspective, how I use Testcontainers🔗 to create Docker test environments to achieve a complete backend integration testing workflow.

What is Testcontainers

Build test environments based on Docker, with test code interacting with real dependencies
  • Docker🔗 is a containerization technology that packages applications and their dependencies into lightweight, portable images.
  • Testcontainers🔗 is a library built on top of Docker that provides a simple API for developers to start real dependency services as Docker containers during tests.

Background for introducing testing

When dealing with many CRUD operations, I want both the highest confidence (tests matching the real environment) and ease of setup. The main approaches are:

  • Mock: replace interactions with other services
    • More aligned with unit testing; cannot fully test behavior across services.
  • In-memory: lightweight service replacements
    • For example, MongoDB has official mtest🔗 and drivertest🔗, but APIs may be unstable and behavior can differ from production.
  • Real environment
    • By starting an identical environment via Docker, Testcontainers provides a mature API and ecosystem to create and destroy services on the fly during development.

Practical example

Using a Go Gin project as an example: go-gin-testing-todos🔗 uses MongoDB as the database and follows the Testcontainers documentation🔗 to create an isolated testing environment.

1. Startup boilerplate logic

Define a SetupTestContainer function to handle some boilerplate code to quickly start a Mongo service:

package testhelper
import (
"context"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
func SetupTestContainer(ctx context.Context) (*mongodb.MongoDBContainer, *mongo.Database, error) {
// Start the latest MongoDB container
mongodbContainer, err := mongodb.Run(ctx, "mongo:latest")
if err != nil {
return nil, nil, err
}
// Get the dynamically allocated string of the container
endpoint, err := mongodbContainer.ConnectionString(ctx)
if err != nil {
return nil, nil, err
}
// Connect to MongoDB
client, err := mongo.Connect(ctx, options.Client().ApplyURI(endpoint))
if err != nil {
return nil, nil, err
}
return mongodbContainer, client.Database("test_db"), nil
}

2. Starting the service in tests

In the actual integration test file (e.g., todo_edit_test.go), call SetupTestContainer at the start of the test and ensure the container is terminated after the test:

func TestDeleteTodoIntegration(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
ctx := context.Background()
container, db, err := testhelper.SetupTestContainer(ctx)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := container.Terminate(ctx); err != nil {
t.Fatalf("failed to terminate container: %s", err)
}
}()
svc := service.NewTodoService(db)
ctrl := controller.NewTodoController(svc)
router := gin.Default()
router.DELETE("/todos/:id", ctrl.Delete)
router.GET("/todos/:id", ctrl.GetByID)
// Setup: Inject the data to be deleted before deletion.
todo := &model.Todo{
Title: "To be deleted",
Completed: false,
CreatedAt: time.Now(),
}
collection := db.Collection("todos")
res, err := collection.InsertOne(ctx, todo)
assert.NoError(t, err)
id := res.InsertedID.(primitive.ObjectID)
// Test: Test DELETE API
deleteRes := httptest.NewRecorder()
deleteReq, _ := http.NewRequest("DELETE", "/todos/"+id.Hex(), nil)
router.ServeHTTP(deleteRes, deleteReq)
assert.Equal(t, http.StatusOK, deleteRes.Code)
// Verify: Through GET API
getRes := httptest.NewRecorder()
getReq, _ := http.NewRequest("GET", "/todos/"+id.Hex(), nil)
router.ServeHTTP(getRes, getReq)
assert.Equal(t, http.StatusNotFound, getRes.Code)
}

Advantages Testcontainers provides

  1. Environment isolation: each test can have its own independent service, avoiding data contamination.
  2. Dynamic connection: Testcontainers automatically maps Docker to random ports, meaning tests can run concurrently in CI/CD or on multiple developer machines without conflicts.
  3. Infrastructure as Code (IaC): test environment definitions live in the test code, so there’s no need to maintain a separate shared test database.

Summary

Testcontainers makes cross-service integration testing very easy and is suitable to add around critical logic (major authorization calculations, destructive edits). In my own development I’ve felt the benefits of writing integration tests:

  • Tests serve as a specification contract that helps developers understand
  • Tests are good at handling and recording complex, hard-to-reproduce scenarios
  • Tests bring confidence

Full code can be found at the go-gin-testing-todos🔗 project.

Further reading