Backend Testing 12 min read

Go Unit Testing with Clean Architecture: Mocks, Table-Driven Tests, and Testify

Hoang Dang Tan Phat (Kane)

Hoang Dang Tan Phat (Kane)

Jan 25, 2026

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

  1. Interfaces in domain layer - Enables mocking without import cycles
  2. Use mockery - Type-safe mocks with EXPECT() syntax
  3. Test happy path first - Then cover error scenarios
  4. Table-driven tests - Efficient for testing multiple scenarios
  5. Test domain errors - Use assert.Equal(t, domain.ErrSomething, err)
  6. Use require for fatal checks - require.NoError stops test on failure
  7. AssertExpectations - Verify all expected mock calls happened
  8. mock.MatchedBy - Validate specific fields in complex arguments
  9. 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

Resources

go golang unit-testing mockery testify clean-architecture tdd
Hoang Dang Tan Phat (Kane)

Hoang Dang Tan Phat (Kane)

Full-stack developer with 8+ years experience. Building scalable systems with Go, TypeScript, and React.