Unit testing in Go follows simple principles but requires discipline in a clean architecture codebase. This guide covers practical patterns from a production crypto-tracker API: interface-driven design, mockery-generated mocks, table-driven tests, and testify assertions.
Project Structure
Clean architecture separates concerns into layers:
modules/{module}/
├── domain/
│ ├── entities/ # Domain objects
│ ├── interfaces/ # ALL interfaces (services, repos)
│ └── errors.go # Domain errors
├── application/
│ └── services/ # Business logic (implements interfaces)
└── infrastructure/
└── repositories/ # Data access (implements interfaces)
Key principle: Interfaces live in domain/interfaces/, implementations live elsewhere. This enables mocking without import cycles.
Interface-First Design
Define interfaces in the domain layer:
// domain/interfaces/auth_service.go
package interfaces
type AuthService interface {
Signup(ctx context.Context, email, name, password string) (*entities.User, string, error)
Login(ctx context.Context, email, password string) (*entities.User, string, error)
GetMe(ctx context.Context, userID int) (*entities.User, error)
}
type UserRepository interface {
Create(ctx context.Context, user *entities.User) error
FindByEmail(ctx context.Context, email string) (*entities.User, error)
FindByID(ctx context.Context, id int) (*entities.User, error)
}
type PasswordService interface {
Hash(password string) (string, error)
Verify(hashedPassword, password string) bool
}
type JWTService interface {
GenerateToken(user *entities.User) (string, string, error)
ValidateToken(token string) (*Claims, error)
GetExpirySeconds() int
}
type SessionRepository interface {
SetUserToken(ctx context.Context, userID int, token, subToken string, expiry int) error
GetUserToken(ctx context.Context, userID int, subToken string) (string, error)
}
Services depend only on interfaces:
// application/services/auth_service.go
package services
type authService struct {
userRepo interfaces.UserRepository
passwordSvc interfaces.PasswordService
jwtSvc interfaces.JWTService
sessionRepo interfaces.SessionRepository
log logger.Logger
}
func NewAuthService(
userRepo interfaces.UserRepository,
passwordSvc interfaces.PasswordService,
jwtSvc interfaces.JWTService,
sessionRepo interfaces.SessionRepository,
log logger.Logger,
) interfaces.AuthService {
return &authService{
userRepo: userRepo,
passwordSvc: passwordSvc,
jwtSvc: jwtSvc,
sessionRepo: sessionRepo,
log: log,
}
}
Mock Generation with Mockery
Configure mockery in .mockery.yaml:
with-expecter: true
mockname: 'Mock{{.InterfaceName}}'
outpkg: 'mocks'
packages:
crypto-tracker/modules/auth/domain/interfaces:
config:
dir: 'mocks/auth'
interfaces:
AuthService:
JWTService:
PasswordService:
SessionRepository:
UserRepository:
crypto-tracker/modules/token/domain/interfaces:
config:
dir: 'mocks/token'
interfaces:
TokenRepository:
TokenService:
PriceHistoryRepository:
RealtimePriceRepository:
crypto-tracker/pkg/logger:
config:
dir: 'mocks/logger'
interfaces:
Logger:
Generate mocks:
mockery
This creates type-safe mocks with EXPECT() methods:
mocks/
├── auth/
│ ├── mock_auth_service.go
│ ├── mock_jwt_service.go
│ ├── mock_password_service.go
│ ├── mock_session_repository.go
│ └── mock_user_repository.go
├── token/
│ └── ...
└── logger/
└── mock_logger.go
Writing Unit Tests
Basic Test Structure
package services
import (
"context"
"errors"
"testing"
"crypto-tracker/modules/auth/domain"
"crypto-tracker/modules/auth/domain/entities"
authMocks "crypto-tracker/mocks/auth"
loggerMocks "crypto-tracker/mocks/logger"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestAuthService_Signup_Success(t *testing.T) {
ctx := context.Background()
// Create mocks
mockUserRepo := authMocks.NewMockUserRepository(t)
mockPasswordSvc := authMocks.NewMockPasswordService(t)
mockJWTSvc := authMocks.NewMockJWTService(t)
mockSessionRepo := authMocks.NewMockSessionRepository(t)
mockLog := loggerMocks.NewMockLogger(t)
// Setup expectations
mockUserRepo.EXPECT().FindByEmail(ctx, "test@example.com").Return(nil, errors.New("not found"))
mockPasswordSvc.EXPECT().Hash("password123").Return("hashedpassword", nil)
mockUserRepo.EXPECT().Create(ctx, mock.MatchedBy(func(u *entities.User) bool {
return u.Email == "test@example.com" && u.PasswordHash == "hashedpassword"
})).Run(func(ctx context.Context, u *entities.User) {
u.ID = 1 // Simulate DB setting the ID
}).Return(nil)
mockJWTSvc.EXPECT().GenerateToken(mock.AnythingOfType("*entities.User")).Return("jwt-token", "sub-token", nil)
mockJWTSvc.EXPECT().GetExpirySeconds().Return(3600)
mockSessionRepo.EXPECT().SetUserToken(ctx, 1, "jwt-token", "sub-token", 3600).Return(nil)
// Execute
svc := NewAuthService(mockUserRepo, mockPasswordSvc, mockJWTSvc, mockSessionRepo, mockLog)
user, token, err := svc.Signup(ctx, "test@example.com", "Test User", "password123")
// Assert
require.NoError(t, err)
assert.Equal(t, "test@example.com", user.Email)
assert.Equal(t, "jwt-token", token)
}
Testing Error Paths
Test each failure scenario:
func TestAuthService_Signup_EmailExists(t *testing.T) {
ctx := context.Background()
mockUserRepo := authMocks.NewMockUserRepository(t)
mockPasswordSvc := authMocks.NewMockPasswordService(t)
mockJWTSvc := authMocks.NewMockJWTService(t)
mockSessionRepo := authMocks.NewMockSessionRepository(t)
mockLog := loggerMocks.NewMockLogger(t)
// User already exists
existingUser := &entities.User{ID: 1, Email: "test@example.com"}
mockUserRepo.EXPECT().FindByEmail(ctx, "test@example.com").Return(existingUser, nil)
svc := NewAuthService(mockUserRepo, mockPasswordSvc, mockJWTSvc, mockSessionRepo, mockLog)
_, _, err := svc.Signup(ctx, "test@example.com", "Test User", "password123")
assert.Error(t, err)
assert.Equal(t, domain.ErrEmailExists, err)
}
func TestAuthService_Signup_HashError(t *testing.T) {
ctx := context.Background()
mockUserRepo := authMocks.NewMockUserRepository(t)
mockPasswordSvc := authMocks.NewMockPasswordService(t)
mockJWTSvc := authMocks.NewMockJWTService(t)
mockSessionRepo := authMocks.NewMockSessionRepository(t)
mockLog := loggerMocks.NewMockLogger(t)
mockUserRepo.EXPECT().FindByEmail(ctx, "test@example.com").Return(nil, errors.New("not found"))
mockPasswordSvc.EXPECT().Hash("password123").Return("", errors.New("hash error"))
svc := NewAuthService(mockUserRepo, mockPasswordSvc, mockJWTSvc, mockSessionRepo, mockLog)
_, _, err := svc.Signup(ctx, "test@example.com", "Test User", "password123")
assert.Error(t, err)
}
func TestAuthService_Login_WrongPassword(t *testing.T) {
ctx := context.Background()
mockUserRepo := authMocks.NewMockUserRepository(t)
mockPasswordSvc := authMocks.NewMockPasswordService(t)
mockJWTSvc := authMocks.NewMockJWTService(t)
mockSessionRepo := authMocks.NewMockSessionRepository(t)
mockLog := loggerMocks.NewMockLogger(t)
user := &entities.User{ID: 1, Email: "test@example.com", PasswordHash: "hashedpassword"}
mockUserRepo.EXPECT().FindByEmail(ctx, "test@example.com").Return(user, nil)
mockPasswordSvc.EXPECT().Verify("hashedpassword", "wrongpassword").Return(false)
svc := NewAuthService(mockUserRepo, mockPasswordSvc, mockJWTSvc, mockSessionRepo, mockLog)
_, _, err := svc.Login(ctx, "test@example.com", "wrongpassword")
assert.Error(t, err)
assert.Equal(t, domain.ErrInvalidCredentials, err)
}
Using mock.MatchedBy for Complex Arguments
When you need to verify specific fields in struct arguments:
mockUserRepo.EXPECT().Create(ctx, mock.MatchedBy(func(u *entities.User) bool {
return u.Email == "test@example.com" &&
u.PasswordHash == "hashedpassword" &&
u.Name == "Test User"
})).Return(nil)
Using Run() to Modify Arguments
Simulate database behavior like auto-generated IDs:
mockUserRepo.EXPECT().Create(ctx, mock.AnythingOfType("*entities.User")).
Run(func(ctx context.Context, u *entities.User) {
u.ID = 1 // Simulate DB auto-increment
u.CreatedAt = time.Now()
}).Return(nil)
Table-Driven Tests
For testing multiple scenarios efficiently:
func TestTokenService_GetTokenBySymbol(t *testing.T) {
tests := []struct {
name string
symbol string
setupMocks func(*MockTokenRepository, *MockRealtimePriceRepository)
expectedError bool
expectedPrice float64
}{
{
name: "success with price data",
symbol: "BTC",
setupMocks: func(tokenRepo *MockTokenRepository, priceRepo *MockRealtimePriceRepository) {
token := &entities.Token{Symbol: "BTC", Name: "Bitcoin"}
price := &interfaces.PriceData{Symbol: "BTC", Price: 45000.0, Change24h: 2.5}
tokenRepo.On("FindBySymbol", mock.Anything, "BTC").Return(token, nil)
priceRepo.On("GetPrice", mock.Anything, "BTC").Return(price, nil)
},
expectedError: false,
expectedPrice: 45000.0,
},
{
name: "token not found",
symbol: "INVALID",
setupMocks: func(tokenRepo *MockTokenRepository, priceRepo *MockRealtimePriceRepository) {
tokenRepo.On("FindBySymbol", mock.Anything, "INVALID").Return(nil, errors.New("not found"))
},
expectedError: true,
expectedPrice: 0,
},
{
name: "price unavailable - returns zero",
symbol: "ETH",
setupMocks: func(tokenRepo *MockTokenRepository, priceRepo *MockRealtimePriceRepository) {
token := &entities.Token{Symbol: "ETH", Name: "Ethereum"}
tokenRepo.On("FindBySymbol", mock.Anything, "ETH").Return(token, nil)
priceRepo.On("GetPrice", mock.Anything, "ETH").Return(nil, errors.New("price not available"))
},
expectedError: false,
expectedPrice: 0.0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockTokenRepo := new(MockTokenRepository)
mockPriceRepo := new(MockRealtimePriceRepository)
mockLog := logger.NewMock()
tt.setupMocks(mockTokenRepo, mockPriceRepo)
svc := NewTokenService(mockTokenRepo, mockPriceRepo, nil, mockLog)
resp, err := svc.GetTokenBySymbol(context.Background(), tt.symbol)
if tt.expectedError {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, tt.expectedPrice, resp.Price)
}
mockTokenRepo.AssertExpectations(t)
mockPriceRepo.AssertExpectations(t)
})
}
}
Manual Mocks (When Needed)
Sometimes you need custom mock behavior. Define mocks inline:
type MockTokenRepository struct {
mock.Mock
}
func (m *MockTokenRepository) FindAll(ctx context.Context) ([]entities.Token, error) {
args := m.Called(ctx)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]entities.Token), args.Error(1)
}
func (m *MockTokenRepository) FindBySymbol(ctx context.Context, symbol string) (*entities.Token, error) {
args := m.Called(ctx, symbol)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*entities.Token), args.Error(1)
}
Then use in tests:
func TestTokenService_GetAllTokens_Success(t *testing.T) {
ctx := context.Background()
mockTokenRepo := new(MockTokenRepository)
mockPriceRepo := new(MockRealtimePriceRepository)
mockLog := logger.NewMock()
tokens := []entities.Token{
{Symbol: "BTC", Name: "Bitcoin"},
{Symbol: "ETH", Name: "Ethereum"},
}
prices := map[string]*interfaces.PriceData{
"BTC": {Symbol: "BTC", Price: 45000.0},
"ETH": {Symbol: "ETH", Price: 2500.0},
}
mockTokenRepo.On("FindAll", ctx).Return(tokens, nil)
mockPriceRepo.On("GetAllPrices", ctx).Return(prices, nil)
svc := NewTokenService(mockTokenRepo, mockPriceRepo, nil, mockLog)
resp, err := svc.GetAllTokens(ctx)
require.NoError(t, err)
assert.Len(t, resp, 2)
mockTokenRepo.AssertExpectations(t)
mockPriceRepo.AssertExpectations(t)
}
Domain Errors
Define domain-specific errors for consistent error checking:
// domain/errors.go
package domain
import "errors"
var (
ErrEmailExists = errors.New("email already exists")
ErrInvalidCredentials = errors.New("invalid credentials")
ErrUserNotFound = errors.New("user not found")
ErrTokenExpired = errors.New("token expired")
ErrInvalidToken = errors.New("invalid token")
)
Test for specific errors:
func TestAuthService_Login_UserNotFound(t *testing.T) {
// ... setup mocks ...
mockUserRepo.EXPECT().FindByEmail(ctx, "notfound@example.com").Return(nil, errors.New("not found"))
svc := NewAuthService(mockUserRepo, mockPasswordSvc, mockJWTSvc, mockSessionRepo, mockLog)
_, _, err := svc.Login(ctx, "notfound@example.com", "password123")
assert.Error(t, err)
assert.Equal(t, domain.ErrInvalidCredentials, err)
}
Testing Services with Multiple Dependencies
Real services often have 4-5+ dependencies. Create a helper to reduce boilerplate:
type authTestSuite struct {
userRepo *authMocks.MockUserRepository
passwordSvc *authMocks.MockPasswordService
jwtSvc *authMocks.MockJWTService
sessionRepo *authMocks.MockSessionRepository
log *loggerMocks.MockLogger
service interfaces.AuthService
}
func newAuthTestSuite(t *testing.T) *authTestSuite {
suite := &authTestSuite{
userRepo: authMocks.NewMockUserRepository(t),
passwordSvc: authMocks.NewMockPasswordService(t),
jwtSvc: authMocks.NewMockJWTService(t),
sessionRepo: authMocks.NewMockSessionRepository(t),
log: loggerMocks.NewMockLogger(t),
}
suite.service = NewAuthService(
suite.userRepo,
suite.passwordSvc,
suite.jwtSvc,
suite.sessionRepo,
suite.log,
)
return suite
}
func TestAuthService_Signup_Suite(t *testing.T) {
ctx := context.Background()
suite := newAuthTestSuite(t)
suite.userRepo.EXPECT().FindByEmail(ctx, "test@example.com").Return(nil, errors.New("not found"))
suite.passwordSvc.EXPECT().Hash("password123").Return("hashed", nil)
suite.userRepo.EXPECT().Create(ctx, mock.Anything).Run(func(ctx context.Context, u *entities.User) {
u.ID = 1
}).Return(nil)
suite.jwtSvc.EXPECT().GenerateToken(mock.Anything).Return("token", "sub", nil)
suite.jwtSvc.EXPECT().GetExpirySeconds().Return(3600)
suite.sessionRepo.EXPECT().SetUserToken(ctx, 1, "token", "sub", 3600).Return(nil)
user, token, err := suite.service.Signup(ctx, "test@example.com", "Test", "password123")
require.NoError(t, err)
assert.Equal(t, 1, user.ID)
assert.Equal(t, "token", token)
}
Running Tests
# Run all unit tests
go test ./...
# Run tests with coverage
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
# Run specific test
go test -run TestAuthService_Signup ./modules/auth/application/services/
# Verbose output
go test -v ./modules/auth/application/services/
Best Practices Summary
- Interfaces in domain layer - Enables mocking without import cycles
- Use mockery - Type-safe mocks with
EXPECT()syntax - Test happy path first - Then cover error scenarios
- Table-driven tests - Efficient for testing multiple scenarios
- Test domain errors - Use
assert.Equal(t, domain.ErrSomething, err) - Use require for fatal checks -
require.NoErrorstops test on failure - AssertExpectations - Verify all expected mock calls happened
- mock.MatchedBy - Validate specific fields in complex arguments
- Run() for side effects - Simulate database auto-generated values
Common Pitfalls
❌ Don’t test implementation details - Test behavior, not internal logic
❌ Don’t over-mock - If it’s a simple value object, use the real thing
❌ Don’t ignore context - Always pass ctx through the call chain
❌ Don’t use mock.Anything everywhere - Be specific where it matters
❌ Don’t forget cleanup - Pass t to mock constructors for automatic verification