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
- Environment isolation: each test can have its own independent service, avoiding data contamination.
- Dynamic connection: Testcontainers automatically maps Docker to random ports, meaning tests can run concurrently in CI/CD or on multiple developer machines without conflicts.
- 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.