Testing Strategies for Go Backend Services
A comprehensive guide to testing Go backend services, covering unit tests, integration tests, end-to-end tests, table-driven patterns, test fixtures, and strategies for testing gRPC and NATS-based systems.
Testing backend services is fundamentally different from testing a library or a CLI tool. Backend services have dependencies: databases, message brokers, external APIs, and other services. They have concurrent behavior: multiple requests in flight, background workers, and event handlers. And they have contracts: gRPC interfaces and event schemas that other services depend on. A testing strategy that does not account for these realities will either be too superficial to catch real bugs or too slow and brittle to run in CI.
In Andromeda, we have evolved a testing strategy that balances thoroughness with speed. This article describes the layers of our test pyramid, the patterns we use at each layer, and the tooling that makes it all work.
The Test Pyramid
Our test pyramid has three layers:
Unit tests form the base. They test individual functions, methods, and types in isolation. Dependencies are replaced with fakes or stubs. Unit tests are fast (milliseconds), numerous (thousands), and run on every commit. They validate business logic, data transformations, and error handling.
Integration tests form the middle. They test a service's interaction with real infrastructure: a PostgreSQL database, a NATS server, or a Redis cache. Integration tests use Docker containers spun up by testcontainers-go and run with the integration build tag so they can be excluded from fast CI runs. They validate queries, migrations, and message serialization.
End-to-end tests form the top. They test complete user flows across multiple services. A request enters the gateway, flows through the accounts and payments services, and produces observable side effects. End-to-end tests run in a staging environment and are slower but catch integration issues that lower layers miss.
The ratio is roughly 70% unit, 25% integration, and 5% end-to-end. This balance gives us high confidence with fast feedback.
Table-Driven Unit Tests
Table-driven tests are the backbone of our unit testing strategy. They are concise, readable, and make it easy to add new cases:
// internal/accounts/domain/money_test.go
package domain_test
import (
"testing"
"github.com/klivvr/andromeda/internal/accounts/domain"
)
func TestMoney_Add(t *testing.T) {
tests := []struct {
name string
a domain.Money
b domain.Money
expected domain.Money
}{
{
name: "add positive amounts",
a: domain.NewMoney(100, domain.CurrencyEGP),
b: domain.NewMoney(250, domain.CurrencyEGP),
expected: domain.NewMoney(350, domain.CurrencyEGP),
},
{
name: "add zero",
a: domain.NewMoney(100, domain.CurrencyEGP),
b: domain.NewMoney(0, domain.CurrencyEGP),
expected: domain.NewMoney(100, domain.CurrencyEGP),
},
{
name: "add to zero",
a: domain.NewMoney(0, domain.CurrencyEGP),
b: domain.NewMoney(500, domain.CurrencyEGP),
expected: domain.NewMoney(500, domain.CurrencyEGP),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tt.a.Add(tt.b)
if result.MinorUnits() != tt.expected.MinorUnits() {
t.Errorf("MinorUnits() = %d, want %d",
result.MinorUnits(), tt.expected.MinorUnits())
}
if result.Currency() != tt.expected.Currency() {
t.Errorf("Currency() = %s, want %s",
result.Currency(), tt.expected.Currency())
}
})
}
}
func TestMoney_Subtract(t *testing.T) {
tests := []struct {
name string
a domain.Money
b domain.Money
expected domain.Money
expectErr bool
}{
{
name: "subtract smaller from larger",
a: domain.NewMoney(500, domain.CurrencyEGP),
b: domain.NewMoney(200, domain.CurrencyEGP),
expected: domain.NewMoney(300, domain.CurrencyEGP),
},
{
name: "insufficient funds",
a: domain.NewMoney(100, domain.CurrencyEGP),
b: domain.NewMoney(200, domain.CurrencyEGP),
expectErr: true,
},
{
name: "subtract to zero",
a: domain.NewMoney(100, domain.CurrencyEGP),
b: domain.NewMoney(100, domain.CurrencyEGP),
expected: domain.NewMoney(0, domain.CurrencyEGP),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := tt.a.Subtract(tt.b)
if tt.expectErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.MinorUnits() != tt.expected.MinorUnits() {
t.Errorf("MinorUnits() = %d, want %d",
result.MinorUnits(), tt.expected.MinorUnits())
}
})
}
}Table-driven tests scale well. When we discover a new edge case or fix a bug, adding a test case is a single struct literal. The test runner output groups cases under the parent test name, making failures easy to locate.
Testing Application Services with Fakes
Application services are the most important layer to test because they contain the business logic orchestration. We test them with hand-written fakes rather than mocking frameworks:
// internal/payments/app/service_test.go
package app_test
import (
"context"
"errors"
"testing"
"log/slog"
"github.com/klivvr/andromeda/internal/payments/app"
"github.com/klivvr/andromeda/internal/payments/domain"
)
type fakeRepo struct {
payments map[string]*domain.Payment
saveErr error
}
func newFakeRepo() *fakeRepo {
return &fakeRepo{payments: make(map[string]*domain.Payment)}
}
func (f *fakeRepo) FindByID(_ context.Context, id string) (*domain.Payment, error) {
p, ok := f.payments[id]
if !ok {
return nil, domain.ErrNotFound
}
return p, nil
}
func (f *fakeRepo) Save(_ context.Context, p *domain.Payment) error {
if f.saveErr != nil {
return f.saveErr
}
f.payments[p.ID()] = p
return nil
}
func (f *fakeRepo) ListByAccount(_ context.Context, accountID string, limit, offset int) ([]*domain.Payment, error) {
var result []*domain.Payment
for _, p := range f.payments {
if p.AccountID() == accountID {
result = append(result, p)
}
}
// Simple pagination
if offset >= len(result) {
return nil, nil
}
end := offset + limit
if end > len(result) {
end = len(result)
}
return result[offset:end], nil
}
type fakeAccounts struct {
debitErr error
creditErr error
}
func (f *fakeAccounts) Debit(_ context.Context, _ string, _ domain.Money) error {
return f.debitErr
}
func (f *fakeAccounts) Credit(_ context.Context, _ string, _ domain.Money) error {
return f.creditErr
}
type fakePublisher struct {
completedIDs []string
failedIDs []string
}
func (f *fakePublisher) PaymentCompleted(_ context.Context, paymentID, _ string, _ int64) error {
f.completedIDs = append(f.completedIDs, paymentID)
return nil
}
func (f *fakePublisher) PaymentFailed(_ context.Context, paymentID, _ string, _ string) error {
f.failedIDs = append(f.failedIDs, paymentID)
return nil
}
type staticIDGen struct{ id string }
func (g *staticIDGen) New() string { return g.id }
func TestProcessPayment_DebitFailure(t *testing.T) {
repo := newFakeRepo()
accounts := &fakeAccounts{debitErr: errors.New("insufficient funds")}
publisher := &fakePublisher{}
idGen := &staticIDGen{id: "pay_001"}
svc := app.NewPaymentService(repo, accounts, publisher, idGen, slog.Default())
err := svc.ProcessPayment(context.Background(), app.ProcessPaymentRequest{
AccountID: "acc_123",
Amount: 5000,
Currency: "EGP",
})
if err == nil {
t.Fatal("expected error, got nil")
}
// Payment should not be saved
if _, findErr := repo.FindByID(context.Background(), "pay_001"); !errors.Is(findErr, domain.ErrNotFound) {
t.Error("payment should not have been saved after debit failure")
}
// Failure event should be published
if len(publisher.failedIDs) != 1 || publisher.failedIDs[0] != "pay_001" {
t.Errorf("expected failure event for pay_001, got %v", publisher.failedIDs)
}
}
func TestProcessPayment_SaveFailure(t *testing.T) {
repo := newFakeRepo()
repo.saveErr = errors.New("database unavailable")
accounts := &fakeAccounts{}
publisher := &fakePublisher{}
idGen := &staticIDGen{id: "pay_002"}
svc := app.NewPaymentService(repo, accounts, publisher, idGen, slog.Default())
err := svc.ProcessPayment(context.Background(), app.ProcessPaymentRequest{
AccountID: "acc_123",
Amount: 1000,
Currency: "EGP",
})
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, repo.saveErr) {
t.Errorf("expected save error to be propagated, got: %v", err)
}
}Hand-written fakes have several advantages over generated mocks. They are explicit: you can see exactly what the fake does by reading its code. They are flexible: you can add custom behavior (like the saveErr field) for specific test scenarios. And they are stable: they do not break when the mocking library is updated.
Integration Tests with testcontainers-go
Integration tests verify that our infrastructure adapters work correctly with real dependencies. We use testcontainers-go to spin up Docker containers for PostgreSQL, NATS, and Redis:
//go:build integration
// internal/accounts/infra/postgres_repository_test.go
package infra_test
import (
"context"
"database/sql"
"testing"
"time"
"github.com/klivvr/andromeda/internal/accounts/domain"
"github.com/klivvr/andromeda/internal/accounts/infra"
_ "github.com/lib/pq"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
)
func setupPostgres(t *testing.T) *sql.DB {
t.Helper()
ctx := context.Background()
container, err := postgres.Run(ctx,
"postgres:16-alpine",
postgres.WithDatabase("testdb"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(30*time.Second),
),
)
if err != nil {
t.Fatalf("starting postgres container: %v", err)
}
t.Cleanup(func() { container.Terminate(ctx) })
connStr, err := container.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("getting connection string: %v", err)
}
db, err := sql.Open("postgres", connStr)
if err != nil {
t.Fatalf("opening database: %v", err)
}
t.Cleanup(func() { db.Close() })
// Run migrations
runMigrations(t, db)
return db
}
func runMigrations(t *testing.T, db *sql.DB) {
t.Helper()
_, err := db.Exec(`
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
currency TEXT NOT NULL,
balance_minor_units BIGINT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
if err != nil {
t.Fatalf("running migrations: %v", err)
}
}
func TestPostgresAccountRepository_SaveAndFind(t *testing.T) {
db := setupPostgres(t)
repo := infra.NewPostgresAccountRepository(db)
ctx := context.Background()
// Create and save an account
account, err := domain.NewAccount("acc_integration_1", "owner_1", domain.CurrencyEGP)
if err != nil {
t.Fatalf("creating account: %v", err)
}
if err := repo.Save(ctx, account); err != nil {
t.Fatalf("saving account: %v", err)
}
// Find it by ID
found, err := repo.FindByID(ctx, "acc_integration_1")
if err != nil {
t.Fatalf("finding account: %v", err)
}
if found.ID() != account.ID() {
t.Errorf("ID = %q, want %q", found.ID(), account.ID())
}
if found.OwnerID() != account.OwnerID() {
t.Errorf("OwnerID = %q, want %q", found.OwnerID(), account.OwnerID())
}
if found.Currency() != account.Currency() {
t.Errorf("Currency = %q, want %q", found.Currency(), account.Currency())
}
}
func TestPostgresAccountRepository_FindByID_NotFound(t *testing.T) {
db := setupPostgres(t)
repo := infra.NewPostgresAccountRepository(db)
ctx := context.Background()
_, err := repo.FindByID(ctx, "nonexistent")
if err == nil {
t.Fatal("expected error, got nil")
}
}Integration tests are tagged with //go:build integration so they are skipped during normal go test ./... runs. In CI, we run them in a dedicated step with go test -tags=integration ./.... This keeps the fast feedback loop for unit tests while still validating infrastructure code.
Testing gRPC Handlers
gRPC handlers can be tested at two levels. At the unit level, we call the handler methods directly with test requests. At the integration level, we start a real gRPC server and connect to it with a client.
Unit-level testing is simpler and faster:
func TestGRPCServer_GetAccount(t *testing.T) {
repo := newFakeRepo()
account, _ := domain.NewAccount("acc_grpc_1", "owner_1", domain.CurrencyEGP)
repo.Save(context.Background(), account)
publisher := &fakePublisher{}
idGen := &staticIDGen{id: "unused"}
svc := app.NewAccountService(repo, publisher, idGen)
server := ports.NewGRPCServer(svc)
resp, err := server.GetAccount(context.Background(), &accountsv1.GetAccountRequest{
AccountId: "acc_grpc_1",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Account.Id != "acc_grpc_1" {
t.Errorf("Account.Id = %q, want %q", resp.Account.Id, "acc_grpc_1")
}
}
func TestGRPCServer_GetAccount_NotFound(t *testing.T) {
repo := newFakeRepo()
publisher := &fakePublisher{}
idGen := &staticIDGen{id: "unused"}
svc := app.NewAccountService(repo, publisher, idGen)
server := ports.NewGRPCServer(svc)
_, err := server.GetAccount(context.Background(), &accountsv1.GetAccountRequest{
AccountId: "nonexistent",
})
if err == nil {
t.Fatal("expected error, got nil")
}
st, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status error, got %T", err)
}
if st.Code() != codes.NotFound {
t.Errorf("code = %v, want NotFound", st.Code())
}
}For integration-level gRPC testing, we use grpc.NewServer with bufconn to create an in-memory transport:
import (
"net"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/test/bufconn"
)
func startTestServer(t *testing.T, svc *app.AccountService) accountsv1.AccountsServiceClient {
t.Helper()
lis := bufconn.Listen(1024 * 1024)
srv := grpc.NewServer()
accountsv1.RegisterAccountsServiceServer(srv, ports.NewGRPCServer(svc))
go func() {
if err := srv.Serve(lis); err != nil {
t.Logf("server exited: %v", err)
}
}()
t.Cleanup(func() { srv.Stop() })
conn, err := grpc.NewClient("passthrough:///bufconn",
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
return lis.DialContext(ctx)
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("dialing: %v", err)
}
t.Cleanup(func() { conn.Close() })
return accountsv1.NewAccountsServiceClient(conn)
}The bufconn approach is valuable because it tests the full gRPC middleware stack (interceptors, serialization, status codes) without network overhead or port allocation.
Testing NATS Event Handlers
NATS event handlers are tested by publishing test messages to an embedded NATS server:
//go:build integration
func TestAccountEventHandler_AccountCreated(t *testing.T) {
// Start embedded NATS server
ns, err := server.NewServer(&server.Options{
JetStream: true,
StoreDir: t.TempDir(),
})
if err != nil {
t.Fatalf("starting NATS server: %v", err)
}
ns.Start()
t.Cleanup(ns.Shutdown)
nc, err := nats.Connect(ns.ClientURL())
if err != nil {
t.Fatalf("connecting to NATS: %v", err)
}
defer nc.Close()
js, err := nc.JetStream()
if err != nil {
t.Fatalf("creating JetStream context: %v", err)
}
// Create the stream
_, err = js.AddStream(&nats.StreamConfig{
Name: "events",
Subjects: []string{"events.>"},
})
if err != nil {
t.Fatalf("creating stream: %v", err)
}
// Set up the handler
notifier := &fakeNotifier{}
handler := infra.NewAccountEventHandler(slog.Default(), notifier)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := handler.Start(ctx, js); err != nil {
t.Fatalf("starting handler: %v", err)
}
// Publish a test event
event := &eventsv1.AccountCreatedEvent{
AccountId: "acc_test_1",
OwnerId: "owner_1",
Currency: "EGP",
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
data, _ := proto.Marshal(event)
_, err = js.Publish("events.accounts.created", data)
if err != nil {
t.Fatalf("publishing event: %v", err)
}
// Wait for the handler to process the event
deadline := time.After(5 * time.Second)
for {
select {
case <-deadline:
t.Fatal("timed out waiting for event to be processed")
default:
if len(notifier.welcomesSent) > 0 {
if notifier.welcomesSent[0] != "owner_1" {
t.Errorf("welcome sent to %q, want %q",
notifier.welcomesSent[0], "owner_1")
}
return
}
time.Sleep(50 * time.Millisecond)
}
}
}Using NATS's embedded server for tests is far more reliable than mocking the NATS client. It tests real message serialization, real JetStream consumer behavior, and real acknowledgment semantics.
Conclusion
Testing Go backend services requires a layered strategy that matches the complexity of the system. Unit tests with table-driven patterns and hand-written fakes form the fast, reliable base. Integration tests with real databases and message brokers verify infrastructure interactions. End-to-end tests validate complete user flows. Each layer catches a different class of bugs, and together they provide high confidence that the system works correctly.
The patterns described here, table-driven tests, hand-written fakes, testcontainers, bufconn for gRPC, and embedded NATS, are not revolutionary. They are straightforward applications of Go's testing idioms to the specific challenges of backend services. The key is consistency: every service in Andromeda follows these patterns, which means that any engineer can read and write tests for any service. That consistency, more than any individual pattern, is what makes our testing strategy effective.
Related Articles
How Monorepos Boost Team Productivity
An exploration of how monorepo architecture improves developer velocity, code quality, and cross-team collaboration, based on real-world experience with Andromeda.
Observability in Go: Tracing, Metrics, and Logging
A practical guide to implementing observability in Go backend services using OpenTelemetry for tracing, Prometheus for metrics, and structured logging with log/slog.
Scaling Go Services: From Startup to Enterprise
A business-oriented guide to scaling Go backend services, covering horizontal scaling strategies, performance optimization, and the organizational practices that enable sustainable growth.