2023-06-15 10:00:00+00:00

In a production-ready microservice ecosystem, automated testing is not an optional safeguard—it is the foundation of continuous deployment. Without comprehensive tests, making a change to one service can trigger silent failures in downstream dependencies. Go provides a robust, built-in testing package, but structuring unit, mock, and integration tests correctly under high concurrency requires specific patterns.

By establishing clear boundaries, isolating database calls with mocks, and automating test coverage audits in your pipelines, you can maintain high developer velocity and stable codebases.


1. Idiomatic Table-Driven Tests

Table-driven tests are the standard in Go. They allow you to define a list of test cases (with inputs and expected outputs) in a slice and iterate over them using t.Run. This isolates test cases and produces clean logs when failures occur:

func TestCalculateDiscount(t *testing.T) {
    tests := []struct {
        name          string
        points        int
        expectedBonus int
        expectError   bool
    }{
        {"Zero points", 0, 0, false},
        {"Standard tier", 500, 50, false},
        {"VIP tier", 2000, 300, false},
        {"Negative points", -10, 0, true},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            bonus, err := CalculateDiscount(tt.points)
            if (err != nil) != tt.expectError {
                t.Errorf("expected error: %v, got: %v", tt.expectError, err)
            }
            if bonus != tt.expectedBonus {
                t.Errorf("expected: %d, got: %d", tt.expectedBonus, bonus)
            }
        })
    }
}

2. Isolating Dependencies with Mocking

Unit tests must be fast and stateless. They should never connect to external database services or make real HTTP requests. In Go, we solve this by defining interfaces (Ports) and generating mock implementations (using tools like mockery or writing them manually):

type MockUserRepo struct {
    mock.Mock
}

func (m *MockUserRepo) GetUser(id string) (*User, error) {
    args := m.Called(id)
    if args.Get(0) == nil {
        return nil, args.Error(1)
    }
    return args.Get(0).(*User), args.Error(1)
}

By injecting the MockUserRepo into your business service handler, you can simulate database failures or specific return values without executing database migrations or managing connections.

3. Coverage Auditing in CI/CD

To measure the effectiveness of your tests, Go provides built-in coverage reporting. We can output coverage profiles during testing and verify that coverage does not drop below a critical threshold (e.g., 70%) before code is merged:

# Run tests and generate coverage profile
go test -race -coverprofile=coverage.out ./...

# Convert coverage profile to visual HTML report
go tool cover -html=coverage.out -o coverage.html

Integrating this step directly into your Makefiles and CI/CD scripts ensures your test suite remains robust as new features are added.

4. Separating Slow Integration Tests

Unlike unit tests, integration tests communicate with database docker instances (e.g., Postgres or Redis) to verify real queries. We keep unit tests fast by separating integration tests using Go build tags:

//go:build integration

package tests

func TestDBWriteIntegration(t *testing.T) {
    // Real PostgreSQL read/write checks
}

When running unit tests locally during daily development, you simply run go test ./.... In your staging CI/CD pipeline, you run go test -tags=integration ./... to run the full suite, achieving both developer speed and deployment safety.