Testing Go Microservices in 2026: Test Pyramid, Mocks, and Integration Tests with Docker
Introduction: Why Testing Microservices Is Harder Than Testing a Monolith
Microservice architecture offers flexibility and scalability, but it also multiplies the complexity of testing. With a monolith, you can spin up a single database, start the service, and run all your tests. In a microservices world, each service interacts with PostgreSQL, Redis, message brokers, third-party REST APIs, and other services. Any of these components can become a source of instability in your tests.
This is where the test pyramid comes in. The concept is simple: the base of the pyramid consists of fast, cheap unit tests; the middle layer contains integration tests; and the top is reserved for slow end-to-end tests. In the context of Go microservices, the pyramid looks like this:
- Unit tests — test isolated domain logic with no external dependencies.
- Integration tests — verify interactions with real or containerized dependencies (PostgreSQL, Redis).
- End-to-end tests — spin up the full stack and validate user-facing scenarios.
In this article, we'll walk through the entire journey: from writing table-driven tests to spinning up PostgreSQL in Docker directly from test code — and we'll show how to wire it all into a CI/CD pipeline.
Unit Tests in Go: testify and the Table-Driven Approach
Go ships with the testing package from the standard library, which is often sufficient for basic tests. However, in real-world projects, testify is almost universally used — it provides convenient assertions and suite-based testing utilities.
Table-Driven Tests
The table-driven test pattern is the idiomatic way to write tests in Go. Instead of duplicating code for each scenario, you define a table of inputs and expected outputs:
package calculator_test
import (
"testing"
"github.com/stretchr/testify/assert"
"myapp/internal/calculator"
)
func TestCalculate(t *testing.T) {
tests := []struct {
name string
a, b int
op string
expected int
wantErr bool
}{
{"addition", 2, 3, "+", 5, false},
{"subtraction", 10, 4, "-", 6, false},
{"division by zero", 5, 0, "/", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := calculator.Calculate(tt.a, tt.b, tt.op)
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
})
}
}
Testing Domain Logic
Pure domain logic is the best candidate for unit tests. If your service is properly layered (domain, usecase, repository), testing business rules requires no I/O whatsoever. Aim to keep domain structures free of framework dependencies and external packages — this makes tests instant and reliable.
Mocks and Stubs: Code Generation with mockery
In microservices, nearly every use case depends on interfaces: a repository for PostgreSQL, a Redis cache, an HTTP client for an external API. Testing use cases against real dependencies is expensive and unreliable. This is where mocks come in.
Generating Mocks with mockery
The mockery tool automatically generates mock implementations of Go interfaces. Installation and basic usage:
# Install
go install github.com/vektra/mockery/v2@latest
# Generate mocks for all interfaces in a package
mockery --all --keeptree --output=./mocks
Suppose you have a user repository interface:
// internal/domain/user.go
package domain
type UserRepository interface {
GetByID(ctx context.Context, id int64) (*User, error)
Save(ctx context.Context, user *User) error
}
After generation, mockery will create a MockUserRepository struct. A use case test looks like this:
package usecase_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"myapp/internal/domain"
"myapp/internal/usecase"
"myapp/mocks"
)
func TestGetUser_Success(t *testing.T) {
mockRepo := mocks.NewUserRepository(t)
expected := &domain.User{ID: 1, Name: "Alice"}
mockRepo.On("GetByID", context.Background(), int64(1)).
Return(expected, nil)
uc := usecase.NewUserUsecase(mockRepo)
user, err := uc.GetUser(context.Background(), 1)
assert.NoError(t, err)
assert.Equal(t, expected.Name, user.Name)
mockRepo.AssertExpectations(t)
}
When to Use a Stub Instead of a Mock
A stub is a simpler object that just returns a pre-configured response without verifying calls. Use stubs when you only care about the result, and mocks when you need to verify that a dependency was called with specific arguments a specific number of times.
Integration Tests with Docker: testcontainers-go
Mocks are great for unit tests, but they don't verify real interactions with a database. SQL queries may contain bugs, migrations may not be applied, and transactions may behave differently than expected. To validate this layer, you need integration tests with real dependencies.
The testcontainers-go library lets you spin up Docker containers directly from your test code. No docker-compose files, no manual setup — the container starts before the test and stops afterward.
Integration Test with PostgreSQL
package repository_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
_ "github.com/lib/pq"
"database/sql"
"myapp/internal/repository"
)
func TestUserRepository_Save(t *testing.T) {
ctx := context.Background()
// Start a PostgreSQL container
pgContainer, err := postgres.RunContainer(ctx,
testcontainers.WithImage("postgres:16-alpine"),
postgres.WithDatabase("testdb"),
postgres.WithUsername("testuser"),
postgres.WithPassword("testpass"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2),
),
)
require.NoError(t, err)
defer pgContainer.Terminate(ctx)
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
require.NoError(t, err)
db, err := sql.Open("postgres", connStr)
require.NoError(t, err)
defer db.Close()
// Apply migrations
_, err = db.Exec(`
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL
)
`)
require.NoError(t, err)
repo := repository.NewUserRepository(db)
user := &domain.User{Name: "Bob", Email: "bob@example.com"}
err = repo.Save(ctx, user)
assert.NoError(t, err)
assert.NotZero(t, user.ID)
}
Testing with Redis
Similarly, you can spin up a Redis container to test caching behavior:
package cache_test
import (
"context"
"testing"
"github.com/testcontainers/testcontainers-go/modules/redis"
"github.com/testcontainers/testcontainers-go"
"github.com/stretchr/testify/require"
)
func TestCacheRepository(t *testing.T) {
ctx := context.Background()
redisContainer, err := redis.RunContainer(ctx,
testcontainers.WithImage("redis:7-alpine"),
)
require.NoError(t, err)
defer redisContainer.Terminate(ctx)
endpoint, err := redisContainer.Endpoint(ctx, "")
require.NoError(t, err)
// Create Redis client and test the cache repository
// ...
}
The key advantage of testcontainers-go is that tests are self-contained. A developer clones the repository, runs go test ./..., and everything just works — no pre-installed services required on the local machine.
Testing REST APIs: httptest and Contract Verification
Testing HTTP handlers in Go is handled through the standard net/http/httptest package. It allows you to create an in-memory HTTP server without real network connections.
package handler_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"myapp/internal/handler"
"myapp/mocks"
)
func TestCreateUserHandler(t *testing.T) {
mockUsecase := mocks.NewUserUsecase(t)
mockUsecase.On("CreateUser", mock.Anything, mock.AnythingOfType("*domain.User")).
Return(nil)
h := handler.NewUserHandler(mockUsecase)
body := `{"name":"Alice","email":"alice@example.com"}`
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.CreateUser(w, req)
res := w.Result()
assert.Equal(t, http.StatusCreated, res.StatusCode)
var response map[string]interface{}
json.NewDecoder(res.Body).Decode(&response)
assert.Equal(t, "Alice", response["name"])
}
For more rigorous API contract verification between services, consider tools like Pact, which let you lock in consumer expectations and verify them on the provider side.
Testing Concurrent Code
Go is a language where concurrency is built into the syntax. Goroutines and channels are powerful, but they're also a source of hard-to-find bugs. To catch these, Go includes a built-in race detector.
Race Detector
Run your tests with the -race flag and Go will instrument the code to detect data races:
go test -race ./...
The race detector operates at runtime and slows execution by roughly 5–10x, but it finds real races. Always enable it in CI.
Testing a Worker Pool
func TestWorkerPool_ProcessesAllJobs(t *testing.T) {
pool := worker.NewPool(5) // 5 workers
var processed int64
for i := 0; i < 100; i++ {
pool.Submit(func() {
atomic.AddInt64(&processed, 1)
})
}
pool.Wait()
assert.Equal(t, int64(100), atomic.LoadInt64(&processed))
}
Using sync/atomic and sync.WaitGroup in concurrent code tests is essential practice.
Organizing Tests in Your Project
Proper test organization in a Go project saves the team time and frustration. Here's a recommended structure:
- Unit tests live alongside the code they test:
user_service_test.goin the same package or with the_testsuffix. - Integration tests are placed in a directory like
internal/repository/integration/or tagged with a build tag. - E2E tests live in a separate
test/e2e/directory.
Build Tags for Separating Tests
//go:build integration
// +build integration
package repository_test
// Integration tests with Docker only run when the build tag is present
Makefile for Convenient Test Execution
.PHONY: test test-unit test-integration test-race
test-unit:
go test ./... -short -count=1
test-integration:
go test ./... -tags=integration -count=1 -timeout=120s
test-race:
go test -race ./... -count=1
test-coverage:
go test ./... -coverprofile=coverage.out
go tool cover -html=coverage.out -o coverage.html
CI/CD Integration
Tests should run automatically on every commit. In 2026, the standard is GitHub Actions or GitLab CI with parallel test package execution.
Example GitHub Actions Workflow
name: Go Tests
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23'
cache: true
- name: Run unit tests with race detector
run: go test -race -short -coverprofile=coverage.out ./...
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: ./coverage.out
integration-tests:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.23'
cache: true
- name: Run integration tests
run: go test -tags=integration -timeout=120s ./...
env:
TESTCONTAINERS_RYUK_DISABLED: "false"
Note the use of the Go module cache (cache: true) — it significantly speeds up dependency loading. testcontainers-go manages Docker containers on its own, so CI only needs a Docker daemon, which is available on all standard GitHub Actions runners.
Test Parallelism
Use t.Parallel() in unit tests that don't share state. This significantly reduces overall execution time:
func TestSomething(t *testing.T) {
t.Parallel()
// test body
}
Parallel execution is also possible for integration tests with testcontainers, but requires care: each test must either spin up its own container or share a common one via TestMain.
Common Anti-Patterns in Go Service Testing
- Testing implementation instead of behavior. If a test verifies that a specific repository method was called exactly 3 times, it's fragile. Tests should verify business outcomes.
- Global state in tests. Using global variables or singletons makes tests dependent on execution order. Always create fresh dependency instances in each test.
- Too many mocks. If a use case test mocks 7 dependencies, that's a signal the use case is doing too much. Decompose the logic.
- Missing edge case tests. Zero values, empty slices, very long strings — these are exactly where production bugs hide.
- Ignoring the race detector. Running tests without
-racein CI means potential data races in production. This is unacceptable for microservices with concurrent workers. - Tests that depend on external networks. Integration tests that call real external services (AWS, Stripe) are unreliable. Use testcontainers or WireMock to isolate them.
- Poor separation of unit and integration tests. Without build tags or the
-shortflag, developers end up running slow integration tests alongside fast unit tests, losing rapid feedback.
Conclusion
Comprehensive testing of Go microservices isn't about hitting 100% coverage for its own sake. It's about confidence in deployments: you change repository code, CI runs the tests, and within minutes you know that PostgreSQL queries work, Redis cache invalidation is correct, and HTTP handlers return the right status codes.
Key principles to put into practice:
- Follow the test pyramid: the majority of tests should be fast unit tests, with integration tests covering critical paths.
- Use mockery to generate interface mocks and avoid writing fakes by hand.
- Spin up real PostgreSQL and Redis instances via testcontainers-go — this eliminates an entire class of bugs that mocks will never catch.
- Always run the race detector in your CI/CD pipeline.
- Separate tests with build tags and Makefile targets for fast feedback during development.
The investment in thoughtful testing pays off many times over: fewer production incidents, faster onboarding of new developers, and the confidence to refactor without fear of breaking everything.
Technologies
Tags
Ruslan Ismailov
Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →