Testing: Investasi yang Terbayar di Production
Saya sudah terlalu sering melihat kode tanpa test yang "works on my machine" tapi crash di production. Testing bukan tentang menulis kode tambahan — ini tentang menulis kode yang bisa diandalkan. Di Go, testing sudah built-in dan powerful. Mari kita manfaatkan sepenuhnya.
1. Table-Driven Tests (Go's Signature)
Table-driven tests adalah pattern testing yang paling "Go" banget. Clean, maintainable, dan scalable:
func TestCalculateDiscount(t *testing.T) {
tests := []struct {
name string
total float64
customer string
want float64
wantErr bool
}{
{
name: "regular customer under threshold",
total: 100,
customer: "regular",
want: 100,
wantErr: false,
},
{
name: "vip customer gets 20pct discount",
total: 500,
customer: "vip",
want: 400,
wantErr: false,
},
{
name: "negative total returns error",
total: -100,
customer: "regular",
want: 0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := CalculateDiscount(tt.total, tt.customer)
if (err != nil) != tt.wantErr {
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("CalculateDiscount() = %v, want %v", got, tt.want)
}
})
}
}
2. Subtests untuk Test Organization
Subtests memudahkan menjalankan test spesifik dan memberikan output yang jelas:
func TestUserService(t *testing.T) {
t.Run("CreateUser", func(t *testing.T) {
// Test user creation
})
t.Run("CreateUser_DuplicateEmail", func(t *testing.T) {
// Test duplicate email handling
})
t.Run("GetUser", func(t *testing.T) {
t.Run("ExistingUser", func(t *testing.T) {
// Test getting existing user
})
t.Run("NonExistingUser", func(t *testing.T) {
// Test getting non-existing user
})
})
}
// go test -run TestUserService/CreateUser_DuplicateEmail
3. Mocking dengan Interfaces
Di Go, interfaces adalah best friend untuk testing. Define interface untuk dependency, mock-nya di test:
type UserRepository interface {
FindByID(ctx context.Context, id string) (*User, error)
Save(ctx context.Context, user *User) error
}
type MockUserRepo struct {
users map[string]*User
err error
}
func (m *MockUserRepo) FindByID(ctx context.Context, id string) (*User, error) {
if m.err != nil {
return nil, m.err
}
user, ok := m.users[id]
if !ok {
return nil, ErrNotFound
}
return user, nil
}
func TestGetUser(t *testing.T) {
mockRepo := &MockUserRepo{
users: map[string]*User{
"123": {ID: "123", Name: "Cecep"},
},
}
svc := NewUserService(mockRepo)
user, err := svc.GetUser(context.Background(), "123")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if user.Name != "Cecep" {
t.Errorf("got name %s, want Cecep", user.Name)
}
}
4. Integration Test dengan Testcontainers
Untuk test yang melibatkan database, saya pakai Testcontainers — test dengan real database tapi diisolasi per test run:
func TestDatabaseIntegration(t *testing.T) {
ctx := context.Background()
pgContainer, _ := postgres.Run(ctx,
"postgres:15-alpine",
postgres.WithDatabase("testdb"),
postgres.WithUsername("test"),
postgres.WithPassword("test"),
)
defer pgContainer.Terminate(ctx)
db, _ := sql.Open("pgx", connStr)
repo := NewPostgresUserRepo(db)
user := &User{Name: "Test User"}
err := repo.Save(ctx, user)
if err != nil {
t.Fatalf("Save failed: %v", err)
}
}
Test Coverage Strategy
Dari pengalaman saya, target coverage 80% itu ideal untuk production. Tapi jangan kejar angka — fokus pada meaningful tests yang mengcover edge cases dan business logic. Unit tests untuk logic, integration tests untuk data layer, dan end-to-end tests untuk critical paths saja.