vahiiiid/go-rest-api-boilerplate

Add gomock Integration for Fast Unit Testing

Open

#17 opened on Oct 6, 2025

 (2 comments) (0 reactions) (0 assignees)Go (23 forks)auto 404
enhancementgood first issuehelp wanted

Repository metrics

Stars
 (60 stars)
PR merge metrics
 (PR metrics pending)

Description

🎯 Goal

Integrate gomock for generating mock implementations of interfaces, enabling fast, isolated unit tests without database dependencies.

📋 Description

Currently, testing the service layer requires a real database connection (SQLite in tests). While integration tests are valuable, we need fast unit tests that can run in milliseconds without external dependencies. gomock will automatically generate mock implementations of our repository interfaces.

Benefits:

  • Fast tests - Run in milliseconds (no database I/O)
  • 🎯 Isolated tests - Test only the service logic
  • 🧪 Easy edge cases - Simulate errors, timeouts, race conditions
  • 🔄 Better CI/CD - Faster feedback loops
  • 📚 Best practices - Industry-standard testing pattern

This follows patterns from successful Go projects like qiangxue/go-rest-api and aligns with our Docker-first philosophy.

✅ Acceptance Criteria

1. Install gomock in Dockerfile

  • Add mockgen to development stage in Dockerfile:
# Development stage
FROM golang:1.23-alpine AS dev

# Install development tools
RUN apk add --no-cache git postgresql-client make

# Install Go development tools
RUN go install github.com/air-verse/air@latest && \
    go install github.com/swaggo/swag/cmd/swag@latest && \
    go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest && \
    go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest && \
    go install github.com/golang/mock/mockgen@latest

WORKDIR /app

# Copy go mod files
COPY go.mod go.sum ./
RUN go mod download

# Copy source code
COPY . .

# Default command for development
CMD ["air", "-c", ".air.toml"]

Important: Do NOT add mockgen to production stage (keeps image minimal).

2. Add gomock Dependency

  • Add to go.mod:
go get github.com/golang/mock@latest

3. Create Mock Directory Structure

  • Create mock directories:
mkdir -p internal/user/mock
touch internal/user/mock/.gitkeep

4. Add Makefile Commands

  • Add mock generation commands to Makefile:
# ==================================================================================== #
# MOCK GENERATION
# ==================================================================================== #

## mock: Generate mocks for testing
.PHONY: mock
mock:
ifeq ($(IS_DOCKER),true)
	@echo "$(BLUE)Generating mocks in Docker...$(NC)"
	mockgen -source=internal/user/repository.go \
	        -destination=internal/user/mock/repository.go \
	        -package=mock
	@echo "$(GREEN)✓ Mocks generated successfully$(NC)"
else
	@echo "$(BLUE)Generating mocks on host...$(NC)"
	@if ! command -v mockgen > /dev/null; then \
		echo "$(YELLOW)mockgen not found. Installing...$(NC)"; \
		go install github.com/golang/mock/mockgen@latest; \
	fi
	mockgen -source=internal/user/repository.go \
	        -destination=internal/user/mock/repository.go \
	        -package=mock
	@echo "$(GREEN)✓ Mocks generated successfully$(NC)"
endif

## mock-all: Generate all mocks (add more as needed)
.PHONY: mock-all
mock-all: mock
	@echo "$(GREEN)✓ All mocks generated$(NC)"

## mock-clean: Remove generated mock files
.PHONY: mock-clean
mock-clean:
	@echo "$(BLUE)Cleaning generated mocks...$(NC)"
	find . -path "*/mock/*.go" ! -name ".gitkeep" -type f -delete
	@echo "$(GREEN)✓ Mocks cleaned$(NC)"

5. Update .gitignore

  • Add mock files to .gitignore:
# Mock files (generated by gomock)
**/mock/*.go
!**/mock/.gitkeep

Note: Some projects commit mocks to git, others regenerate them. For this project, we'll regenerate them (keeps git history clean).

6. Ensure Repository Interface Exists

  • Verify internal/user/repository.go has proper interface:
package user

// Repository defines the interface for user data access
type Repository interface {
	Create(user *User) error
	GetByID(id uint) (*User, error)
	GetByEmail(email string) (*User, error)
	Update(user *User) error
	Delete(id uint) error
}

If the interface doesn't exist, extract it from the concrete implementation.

7. Generate Mocks

  • Run mock generation:
make mock

This should create internal/user/mock/repository.go with content like:

// Code generated by MockGen. DO NOT EDIT.
// Source: internal/user/repository.go

package mock

import (
	gomock "github.com/golang/mock/gomock"
	user "github.com/vahiiiid/go-rest-api-boilerplate/internal/user"
)

// MockRepository is a mock of Repository interface
type MockRepository struct {
	ctrl     *gomock.Controller
	recorder *MockRepositoryMockRecorder
}

// ... generated mock methods

8. Create Example Unit Test with Mocks

  • Create internal/user/service_unit_test.go:
package user

import (
	"errors"
	"testing"

	"github.com/golang/mock/gomock"
	"github.com/stretchr/testify/assert"
	"github.com/vahiiiid/go-rest-api-boilerplate/internal/user/mock"
	"golang.org/x/crypto/bcrypt"
)

func TestService_GetByID_Success(t *testing.T) {
	// Setup
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	mockRepo := mock.NewMockRepository(ctrl)
	service := NewService(mockRepo)

	// Expected user
	expectedUser := &User{
		ID:    1,
		Name:  "Alice Smith",
		Email: "alice@example.com",
	}

	// Mock expectation
	mockRepo.EXPECT().
		GetByID(uint(1)).
		Return(expectedUser, nil).
		Times(1)

	// Execute
	result, err := service.GetByID(1)

	// Assert
	assert.NoError(t, err)
	assert.Equal(t, expectedUser.ID, result.ID)
	assert.Equal(t, expectedUser.Name, result.Name)
	assert.Equal(t, expectedUser.Email, result.Email)
}

func TestService_GetByID_NotFound(t *testing.T) {
	// Setup
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	mockRepo := mock.NewMockRepository(ctrl)
	service := NewService(mockRepo)

	// Mock expectation: return error
	mockRepo.EXPECT().
		GetByID(uint(999)).
		Return(nil, errors.New("user not found")).
		Times(1)

	// Execute
	result, err := service.GetByID(999)

	// Assert
	assert.Error(t, err)
	assert.Nil(t, result)
	assert.Contains(t, err.Error(), "user not found")
}

func TestService_Register_EmailAlreadyExists(t *testing.T) {
	// Setup
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	mockRepo := mock.NewMockRepository(ctrl)
	service := NewService(mockRepo)

	existingUser := &User{
		ID:    1,
		Email: "alice@example.com",
	}

	// Mock: Email check returns existing user
	mockRepo.EXPECT().
		GetByEmail("alice@example.com").
		Return(existingUser, nil).
		Times(1)

	// Execute
	_, err := service.Register(RegisterRequest{
		Name:     "Alice",
		Email:    "alice@example.com",
		Password: "password123",
	})

	// Assert
	assert.Error(t, err)
	assert.Contains(t, err.Error(), "email already exists")
}

func TestService_Register_Success(t *testing.T) {
	// Setup
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	mockRepo := mock.NewMockRepository(ctrl)
	service := NewService(mockRepo)

	// Mock: Email doesn't exist
	mockRepo.EXPECT().
		GetByEmail("newuser@example.com").
		Return(nil, nil).
		Times(1)

	// Mock: Create succeeds
	mockRepo.EXPECT().
		Create(gomock.Any()).
		Return(nil).
		Times(1)

	// Execute
	user, err := service.Register(RegisterRequest{
		Name:     "New User",
		Email:    "newuser@example.com",
		Password: "password123",
	})

	// Assert
	assert.NoError(t, err)
	assert.NotNil(t, user)
	assert.Equal(t, "New User", user.Name)
	assert.Equal(t, "newuser@example.com", user.Email)

	// Verify password is hashed
	err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte("password123"))
	assert.NoError(t, err, "Password should be hashed")
}

func TestService_Delete_Unauthorized(t *testing.T) {
	// Setup
	ctrl := gomock.NewController(t)
	defer ctrl.Finish()

	mockRepo := mock.NewMockRepository(ctrl)
	service := NewService(mockRepo)

	// Mock: User exists but belongs to someone else
	mockRepo.EXPECT().
		GetByID(uint(1)).
		Return(&User{ID: 1}, nil).
		Times(1)

	// Execute: Try to delete as different user
	err := service.Delete(1, 999) // userID=1, requestingUserID=999

	// Assert
	assert.Error(t, err)
	assert.Contains(t, err.Error(), "forbidden")
}

9. Update Test Running

  • Ensure unit tests run separately from integration tests:
## test-unit: Run unit tests with mocks (fast)
.PHONY: test-unit
test-unit:
ifeq ($(IS_DOCKER),true)
	@echo "$(BLUE)Running unit tests in Docker...$(NC)"
	go test -v -short ./internal/...
else
	@echo "$(BLUE)Running unit tests on host...$(NC)"
	go test -v -short ./internal/...
endif

## test-integration: Run integration tests (with database)
.PHONY: test-integration
test-integration:
ifeq ($(IS_DOCKER),true)
	@echo "$(BLUE)Running integration tests in Docker...$(NC)"
	go test -v ./tests/...
else
	@echo "$(BLUE)Running integration tests on host...$(NC)"
	go test -v ./tests/...
endif

## test: Run all tests
.PHONY: test
test: test-unit test-integration

10. Update help Command

  • Add mock commands to Makefile help:
mock                 Generate mocks for testing
mock-all             Generate all mocks
mock-clean           Remove generated mock files
test-unit            Run fast unit tests (with mocks)
test-integration     Run integration tests (with database)

11. Testing the Implementation

  • Verify mockgen is available in Docker:
make up
docker-compose exec app which mockgen
# Should output: /go/bin/mockgen
  • Generate mocks:
make mock
# Should create internal/user/mock/repository.go
  • Run unit tests:
make test-unit
# Should run in < 1 second
  • Run all tests:
make test
# Should run both unit and integration tests

12. Documentation Updates

Update README.md

  • Add testing section:
## 🧪 Testing

### Running Tests

```bash
# Run all tests (unit + integration)
make test

# Run only fast unit tests (with mocks, no database needed)
make test-unit

# Run only integration tests (requires database)
make test-integration

# Generate mocks for testing
make mock

# Clean generated mocks
make mock-clean

Test Types

  • Unit Tests (internal/*/service_unit_test.go) - Fast tests with mocked dependencies
  • Integration Tests (tests/*_test.go) - Full-stack tests with real database

Generating Mocks

This project uses gomock for generating test mocks:

# Generate all mocks
make mock

# Mocks are auto-generated from interfaces and placed in */mock/ directories
# They are gitignored and regenerated as needed

#### Update Development Guide
- [ ] Add section on writing tests with mocks
- [ ] Provide examples of different mock scenarios
- [ ] Explain when to use unit vs integration tests

### 13. GitHub Actions CI Update
- [ ] Update `.github/workflows/ci.yml` to generate mocks before tests:

```yaml
- name: Generate mocks
  run: make mock

- name: Run unit tests
  run: make test-unit

- name: Run integration tests
  run: make test-integration

💡 Usage Examples

Before (Integration Test Only)

func TestUserService(t *testing.T) {
    // Need real database
    db := setupTestDB()  // Slow (100ms+)
    repo := user.NewRepository(db)
    service := user.NewService(repo)
    // Test with real DB queries
}

After (Fast Unit Test with Mock)

func TestUserService(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    
    mockRepo := mock.NewMockRepository(ctrl)  // Fast (<1ms)
    mockRepo.EXPECT().GetByID(1).Return(&user, nil)
    
    service := user.NewService(mockRepo)
    // Test only service logic
}

📊 Performance Comparison

Test Type Time Dependencies What It Tests
Integration 5-10s Docker, PostgreSQL Full stack
Unit (with mocks) <1s None Service logic only

📚 Resources

🎓 Difficulty Level

Intermediate - Requires understanding of interfaces, dependency injection, and testing patterns.

🔗 Related PRs/Issues

  • Complements existing integration tests in tests/
  • Works with Docker-first development philosophy
  • Prepares for structured error handling (#12)

✅ Definition of Done

  • mockgen installed in Dockerfile dev stage
  • Makefile commands (mock, mock-clean, test-unit) working
  • Mock directory structure created
  • At least 3 example unit tests with mocks
  • Unit tests run in < 1 second
  • Integration tests still work
  • Documentation updated (README + Development Guide)
  • CI pipeline generates mocks and runs tests
  • All existing tests still pass
  • make help shows new commands

🚀 Quick Start (After Implementation)

# Start development environment
make quick-start

# Generate mocks
make mock

# Run fast unit tests (no database needed!)
make test-unit

# Run all tests
make test

Note: This follows the Docker-first philosophy of GRAB - all tools pre-installed, make commands auto-detect environment, and everything works out of the box. Keep both unit tests (fast feedback) and integration tests (confidence) for best coverage!

Contributor guide