Skip to main content
The Address API uses Go’s built-in testing framework along with testcontainers for integration tests.

Testing philosophy: Test-Driven Development (TDD)

We follow Test-Driven Development (TDD) for all microservices, including the Address API. This means:
  1. Write tests first: Before implementing a feature, write tests that define the expected behavior
  2. Watch tests fail: Run the tests and verify they fail (proving they’re testing something)
  3. Implement the feature: Write the minimum code needed to make the tests pass
  4. Refactor: Clean up the code while keeping tests green

Why TDD for microservices?

  • Clear requirements: Tests document what the API should do
  • Confidence in changes: Refactor without fear of breaking things
  • Better design: Writing tests first leads to more testable, modular code
  • Regression prevention: Bugs become test cases that prevent future regressions
  • Integration safety: Microservices have many integration points - tests catch breaking changes

TDD workflow example

# 1. Write a failing test
vim tests/integration/your_feature_test.go

# 2. Run tests (they should fail)
go run . tests

# 3. Implement the feature
vim internal/your_feature/handler.go

# 4. Run tests again (they should pass)
go run . tests

# 5. Refactor if needed
# 6. Commit
git add .
git commit -m "feat: add your feature"

Test structure

tests/
├── integration/          # Integration tests with real database
│   ├── authorization_tests.go
│   ├── geoencode_tests.go
│   ├── ingestion_tests.go
│   ├── integration_test.go
│   └── test_helpers.go
└── unit/                 # Unit tests
    └── health_test.go

Running tests

Run all tests

go test -v ./...

Run integration tests only

go test -v ./tests/integration/...

Run unit tests only

go test -v ./tests/unit/...

Using the CLI command

# Run integration tests
go run . tests

# Run unit tests
go run . tests unit-tests

Integration tests

Integration tests use testcontainers-go to spin up a real PostgreSQL database in Docker.

Prerequisites

  • Docker must be running
  • GOOGLE_GEOCODING_API_KEY environment variable must be set

How integration tests work

  1. Container startup: Testcontainers starts a PostgreSQL 15 container
  2. Migration: Atlas applies all migrations to the test database
  3. Test execution: Tests run against the real database
  4. Cleanup: Container is automatically destroyed after tests complete

Test coverage

Test fileCoverage
authorization_tests.goAPI key authentication and role-based access control
geoencode_tests.goGeocoding endpoint with caching and Google API integration
ingestion_tests.goISO 3166 content ingestion with upsert logic

Example integration test

func TestGeocodeEndpoint(t *testing.T) {
    // Setup test server
    e, store := setupTestServer(t)
    defer store.ConnPool.Close()

    // Create test API key
    apiKey := createTestAPIKey(t, store, []string{"GEOCODE"})

    // Make request
    req := httptest.NewRequest(http.MethodPost, "/api/v1/geoencode", 
        strings.NewReader(`{"address":"94108, CA, US"}`))
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("x-commenda-key", apiKey)
    
    rec := httptest.NewRecorder()
    e.ServeHTTP(rec, req)

    // Assert response
    assert.Equal(t, http.StatusOK, rec.Code)
}

Unit tests

Unit tests test individual functions without external dependencies.

Example unit test

func TestHealthEndpoint(t *testing.T) {
    e := echo.New()
    req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
    rec := httptest.NewRecorder()
    
    c := e.NewContext(req, rec)
    
    err := healthz(c)
    
    assert.NoError(t, err)
    assert.Equal(t, http.StatusOK, rec.Code)
}

Test helpers

The test_helpers.go file provides utilities for integration tests:
// Setup test server with database
func setupTestServer(t *testing.T) (*echo.Echo, database.Store)

// Create test API key
func createTestAPIKey(t *testing.T, store database.Store, roles []string) string

// Make authenticated request
func makeAuthenticatedRequest(e *echo.Echo, method, path, body, apiKey string) *httptest.ResponseRecorder

Running tests in CI

Tests run automatically on every pull request via GitHub Actions.

PR checks workflow

integration-tests:
  name: Integration Tests
  runs-on: ubuntu-latest
  environment: staging
  steps:
    - uses: actions/checkout@v4
    - name: Set up Go
      uses: actions/setup-go@v4
      with:
        go-version: "1.23.1"
    - name: Setup Atlas
      uses: ariga/setup-atlas@v0
    - name: Run Integration Tests
      env:
        GOOGLE_GEOCODING_API_KEY: ${{ secrets.GOOGLE_GEOCODING_API_KEY }}
      run: go test -v -timeout 2m ./tests/integration/...

Testing practices

We follow these testing practices for the Address API:

1. Test-Driven Development (TDD)

  • Write tests before implementation
  • Start with the simplest test case
  • Add more complex scenarios incrementally
  • Refactor only when tests are green

2. Integration over unit tests

For microservices, we prioritize integration tests over unit tests because:
  • They test the full request/response cycle
  • They catch integration issues with databases and external APIs
  • They provide more confidence in production behavior
  • They’re closer to how the API is actually used

3. Test real dependencies

  • Use testcontainers for PostgreSQL (not mocks)
  • Use real Google Geocoding API (with test key)
  • Test actual HTTP requests and responses
  • Verify database state after operations

4. Test error scenarios

Don’t just test happy paths:
  • Missing required fields
  • Invalid data formats
  • Database failures
  • External API failures
  • Authorization failures

5. Keep tests independent

  • Each test should set up its own data
  • Don’t rely on test execution order
  • Clean up after tests (testcontainers handles this)

6. Use descriptive test names

// Good: Describes what is being tested
func TestGeocodeEndpoint_WithValidAddress_ReturnsCoordinates(t *testing.T)
func TestGeocodeEndpoint_WithMissingAPIKey_Returns401(t *testing.T)

// Avoid: Vague names
func TestGeocodeEndpoint(t *testing.T)
func Test1(t *testing.T)

Example table-driven test

func TestValidateAddress(t *testing.T) {
    tests := []struct {
        name    string
        address string
        wantErr bool
    }{
        {"valid address", "123 Main St", false},
        {"empty address", "", true},
        {"whitespace only", "   ", true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            err := validateAddress(tt.address)
            if (err != nil) != tt.wantErr {
                t.Errorf("validateAddress() error = %v, wantErr %v", err, tt.wantErr)
            }
        })
    }
}

Test coverage

Generate test coverage report:
# Generate coverage
go test -coverprofile=coverage.out ./...

# View coverage in browser
go tool cover -html=coverage.out

Troubleshooting

Error: “Docker daemon not running”

Cause: Testcontainers requires Docker to be running. Fix:
# macOS
open -a Docker

# Linux
sudo systemctl start docker

Error: “GOOGLE_GEOCODING_API_KEY not set”

Cause: Integration tests require Google API key for geocoding tests. Fix:
export GOOGLE_GEOCODING_API_KEY=your_api_key
go test -v ./tests/integration/...

Error: “port already in use”

Cause: Previous test container wasn’t cleaned up. Fix:
# Stop all containers
docker stop $(docker ps -aq)

# Remove all containers
docker rm $(docker ps -aq)

Tests timing out

Cause: Slow network or Docker image pull. Fix: Increase timeout:
go test -v -timeout 5m ./tests/integration/...

Next steps