12 · Testing
Unit tests with mocks, table-driven tests, coverage
Test File Convention
Test files live next to the code they test, suffixed with _test.go:
internal/services/
├── coupon_service.go
└── coupon_service_test.go ← same package: "package services"
Same package means tests can access unexported functions — useful for testing internal helpers.
Table-Driven Tests — Real Example
From internal/services/coupon_service_test.go:
func TestGenerateCouponInternalName(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"Welcome Voucher 100 Baht", "WELCOME_VOUCHER_100_BAHT"},
{"simple", "SIMPLE"},
{"Already UPPER", "ALREADY_UPPER"},
{" trimmed ", "TRIMMED"},
{"", ""},
{"a b", "A_B"},
}
for _, tt := range tests {
result := generateCouponInternalName(tt.input)
assert.Equal(t, tt.expected, result, "input: %q", tt.input)
}
}
Add a name string field to the struct to get better failure messages: t.Run(tt.name, func(t *testing.T) { ... })
Mocking Repository Interfaces
Services accept interfaces, so tests inject a mock instead of a real DB connection:
// Mock generated by testify/mock — lives in same package as tests
type MockCouponRepository struct {
mock.Mock
}
func (m *MockCouponRepository) GetByID(ctx context.Context, id string) (*models.Coupon, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.Coupon), args.Error(1)
}
// In a test:
func TestGetCouponByID_Success(t *testing.T) {
mockRepo := new(MockCouponRepository)
mockUserRepo := new(MockUserCouponRepository)
mockCountRepo := new(MockUserCouponsCountRepository)
mockLogger := new(MockLogger)
coupon := &models.Coupon{ID: "abc", Name: "10% Off"}
mockRepo.On("GetByID", mock.Anything, "abc").Return(coupon, nil)
svc := NewCouponService(mockLogger, mockRepo, mockUserRepo, mockCountRepo)
result, err := svc.GetCouponByID(context.Background(), "abc")
assert.NoError(t, err)
assert.Equal(t, "10% Off", result.Name)
mockRepo.AssertExpectations(t)
}
Run Tests
# All tests
make test
# Single package
go test -v ./internal/services/...
# Single test function
go test -v -run TestGetCouponByID ./internal/services/...
# With race detector
go test -race ./...
# Coverage report
make test-coverage
open coverage.html
Integration Tests
Integration tests live in tests/ and use miniredis (in-memory Redis) and go-sqlmock (mock SQL driver) to test the full stack without real infrastructure.
// tests/ use build tags to separate from unit tests
// Run integration tests:
go test -v -tags integration ./tests/...
Key Takeaways
- Test files:
*_test.goin the same directory, same package - Table-driven:
[]struct{input, want}— add cases without adding test functions - Mock repos implement the repository interface — no real DB in unit tests
- Always call
mockRepo.AssertExpectations(t)at the end of each test make test-coverage→ opencoverage.htmlto see what's untested