← Back to Cecep Azhar
Cecep Azhar #golang#testing#unit-test#benchmark#best-practices

Testing Go Applications: Unit, Integration, and Benchmark Tests

Cecep Saeful Azhar • 16 Oct 2026
Testing Go Applications: Unit, Integration, and Benchmark Tests
Most Go developers write minimal tests. Here's how to write tests that actually catch bugs, measure performance, and give you confidence to ship to production every day.

Why Your Tests Are Probably Bad

Most Go tests I review are happy-path-only garbage. They test the basic case, pass, and give zero confidence that the code actually works in production.

Real testing means: testing edge cases, error conditions, concurrent access, and performance characteristics. Here's how to do it right.

Level 1: Unit Tests That Actually Matter

Here's a real example from a production service — a price calculator for an e-commerce platform:

package service

import (
    "testing"
    "github.com/stretchr/testify/assert"
)

func TestCalculatePrice(t *testing.T) {
    svc := NewPriceService()
    
    tests := []struct {
        name     string
        basePrice float64
        quantity  int
        discount  float64
        tax       float64
        expected  float64
    }{
        {"single item no discount", 100000, 1, 0, 11, 110000},
        {"bulk with discount", 100000, 10, 10, 11, 990000},
        {"zero quantity", 100000, 0, 0, 11, 0},
        {"100% discount", 100000, 5, 100, 11, 0},
        {"large order", 50000, 1000, 15, 11, 429250000},
        {"fractional discount", 100000, 3, 7.5, 11, 321750},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := svc.CalculatePrice(tt.basePrice, tt.quantity, tt.discount, tt.tax)
            assert.InDelta(t, tt.expected, result, 0.01, "price mismatch")
        })
    }
}

// Test edge cases that break things
func TestCalculatePrice_EdgeCases(t *testing.T) {
    svc := NewPriceService()
    
    t.Run("negative price", func(t *testing.T) {
        result := svc.CalculatePrice(-100000, 1, 0, 11)
        assert.Equal(t, 0.0, result, "negative price should return 0")
    })
    
    t.Run("very large quantity", func(t *testing.T) {
        result := svc.CalculatePrice(100000, 1000000, 0, 11)
        assert.True(t, result > 0, "should handle large quantities")
        assert.True(t, result < 1e12, "should not overflow")
    })
}

func TestCalculatePrice_ErrorConditions(t *testing.T) {
    svc := NewPriceService()
    
    t.Run("invalid tax rate", func(t *testing.T) {
        assert.Panics(t, func() {
            svc.CalculatePrice(100000, 1, 0, -5)
        })
    })
    
    t.Run("discount over 100%", func(t *testing.T) {
        assert.Panics(t, func() {
            svc.CalculatePrice(100000, 1, 150, 11)
        })
    })
}

Level 2: Integration Tests

Test the real thing — database, external services, the whole stack:

func TestProductRepository_Integration(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping integration test in short mode")
    }
    
    // Real database connection
    db, err := sqlx.Connect("postgres", os.Getenv("TEST_DB_URL"))
    if err != nil {
        t.Fatalf("failed to connect to test database: %v", err)
    }
    defer db.Close()
    
    // Clean state
    db.MustExec("TRUNCATE products CASCADE")
    
    repo := NewProductRepository(db)
    ctx := context.Background()
    
    // Create
    product := &Product{Name: "Test Product", SKU: "TEST-001", Price: 100000}
    err = repo.Create(ctx, product)
    assert.NoError(t, err)
    assert.NotEmpty(t, product.ID)
    
    // Read
    fetched, err := repo.GetByID(ctx, product.ID)
    assert.NoError(t, err)
    assert.Equal(t, product.Name, fetched.Name)
    
    // List with filter
    products, total, err := repo.List(ctx, ListParams{
        TenantID: "test-tenant",
        Search:   "Test",
        Limit:    10,
        Offset:   0,
    })
    assert.NoError(t, err)
    assert.Equal(t, 1, total)
    assert.Len(t, products, 1)
    
    // Update
    fetched.Name = "Updated Product"
    err = repo.Update(ctx, fetched)
    assert.NoError(t, err)
    
    updated, _ := repo.GetByID(ctx, product.ID)
    assert.Equal(t, "Updated Product", updated.Name)
    
    // Delete
    err = repo.Delete(ctx, product.ID)
    assert.NoError(t, err)
    
    _, err = repo.GetByID(ctx, product.ID)
    assert.Error(t, err)
}

Level 3: Benchmark Tests

Find performance bottlenecks before your users do:

func BenchmarkProductList(b *testing.B) {
    db := setupBenchDB(b)
    defer db.Close()
    
    // Seed data
    repo := NewProductRepository(db)
    for i := 0; i < 1000; i++ {
        repo.Create(context.Background(), &Product{
            Name:  fmt.Sprintf("Product %d", i),
            SKU:   fmt.Sprintf("SKU-%05d", i),
            Price: float64(i * 1000),
        })
    }
    
    b.ResetTimer()
    
    b.Run("List-10", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            repo.List(context.Background(), ListParams{Limit: 10, Offset: 0})
        }
    })
    
    b.Run("List-100", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            repo.List(context.Background(), ListParams{Limit: 100, Offset: 0})
        }
    })
    
    b.Run("Search", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            repo.List(context.Background(), ListParams{Search: "Product 500", Limit: 10})
        }
    })
}

func BenchmarkCalculatePrice(b *testing.B) {
    svc := NewPriceService()
    b.ResetTimer()
    
    for i := 0; i < b.N; i++ {
        svc.CalculatePrice(100000, 10, 15, 11)
    }
}

Running Tests Like a Pro

# Unit tests only (fast)
go test -short ./...

# All tests including integration
go test ./...

# With coverage report
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out

# Benchmarks
go test -bench=. -benchmem ./...

# Race condition detection
go test -race ./...

# Verbose output
go test -v -count=1 ./...

The Test Pyramid for Go

CI/CD Integration

Here's my GitHub Actions config for Go testing:

name: Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: test
          POSTGRES_DB: testdb
        ports: [5432:5432]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: { go-version: '1.22' }
      - run: go test -race -coverprofile=coverage.out ./...
      - run: go test -bench=. -benchmem -run=^$ ./...

The Takeaway

Testing isn't overhead — it's insurance. Every hour you spend writing good tests saves you 5-10 hours of debugging production issues at 3 AM. Write tests that give you the confidence to deploy on Friday afternoon.

← More articles from Cecep Azhar Central Content Hub Home →