During my first year with Golang, I developed a complete development stack that finally satisfies me. The process took approximately two months of exploration and continuous refinement. However, it wasn't easy to reach this point, as I had to overcome several challenges, and one of the biggest was unit testing and the mocking pattern in Go.
In my professional experience, I've worked with various languages such as C, C++, Java, C#, JavaScript, and TypeScript, and I miss some features from these languages when writing tests in Go. One thing that strikes me most is that while in languages with mocking frameworks (like Jest in JavaScript or NSubstitute in C#) creating a stub/mock is more straightforward (one-liners), in Go, due to its statically-typed nature and the absence of dynamic runtime reflection, you need to generate code that explicitly implements each interface you want to mock.
This characteristic makes tests in Go more verbose initially, although safer at compile-time. Sometimes it leads to tests looking messy if you don't follow a good development strategy. However, I never lost hope of finding a way to make tests cleaner and more maintainable in Go.
For example, in TypeScript with jest-mock-extended, creating a stub is as simple as:
// contracts
interface UserRepository {
getById(id: string): Promise<{ id: string; name: string, superPower: string }>;
}
// test file
import { mock } from "jest-mock-extended";
// Create a stub
const userRepository = mock<UserRepository>();
// Define the behavior of the mock method
userRepository.getById.mockResolvedValue({ id: "unique-user-id", name: "John Doe", superPower: "Invisibility" });
// Example usage
const user = await userRepository.getById("unique-user-id");
console.log(user.name); // "John Doe"
And if we want to verify that a method was called with certain parameters (mock):
// Verify that the method was called with the expected ID
expect(userRepository.getById).toHaveBeenCalledWith("unique-user-id");
My Experience with testify/mock
At the organization where I collaborate, the testing stack used testify/mock. During the first three weeks working with this library, I identified that, although it's very popular and has many features, mock setup can be verbose, especially when configuring multiple expectations. While there are tools like mockery (a code generator that creates mocks using testify/mock internally) and recent versions have improved significantly, the usage pattern based on strings and runtime assertions didn't align with my preference for compile-time safety.
Limitations Found
After working with testify/mock, I identified the following aspects that didn't align with my preferences:
-
Limited type safety: Use of strings for method names and
mock.Anythingthat postpones errors to runtime - Refactoring: When renaming interface methods, tests continue compiling giving false positives
- IDE autocomplete: Limited due to string usage and generic types
- Compile-time verification: Personal preference for detecting errors at compilation
This led me to propose improvements through a PR that included:
- Type-safe method call verification
- Better IDE autocomplete support
- Compile-time validation of mock expectations
Although the PR added type-safety through generics, it wasn't accepted due to philosophical differences because apparently testify prioritizes simplicity and stable API over type-safety, while my proposal added generic complexity that, although technically valid, didn't align with the project's direction. This decision helped me understand that different projects have different valid trade-offs.
My Solution: Modern Architecture-Focused Testing Stack
Seeing that I needed a solution more suited to my needs for a new project, I decided to create my own stack from scratch, based on:
Hexagonal Architecture with DDD
cmd/ # Entry point for applications (Apis, Functions)
src/
├── application/ # Business logic layer
│ ├── contracts/ # Interface definitions (ports)
│ ├── mocks/ # Mock implementations for domain (using builder pattern)
│ ├── services/ # Application services
│ ├── shared/ # Shared application utilities
│ └── useCases/ # Use case implementations (by context and dtos)
├── domain/ # Domain entities and business rules
├── infrastructure/ # External adapters and implementations
│ ├── config/ # Project setup and settings
│ ├── controllers/ # HTTP API controllers
│ ├── core/ # Infrastructure core utilities (app, crun, http, openapi, web)
│ ├── db/ # Database connections and migrations
│ ├── handlers/ # Cloud Run/Cloud Functions handlers
│ ├── providers/ # Service implementations (adapters)
│ └── repositories/ # Data access implementations
Advantages of this architecture for testing:
- Interfaces are well-defined in
application/contracts/ - Use cases are easy to test in isolation
- Adapters can be easily mocked
- Domain logic is free from external dependencies
GCP Deployment Strategy
The project supports two deployment strategies under the same codebase:
- Cloud Run (containerized services): For stateful services, WebSockets, or those requiring greater control over the runtime
- Cloud Functions (serverless): For event-driven functions, asynchronous processing, or simple endpoints
Example main.go for API Cloud Run Service:
package main
func main() {
app := app.AppInstance // App instance based on Web framework
// Register controllers (Controllers by context in microservices architecture strategy)
loadControllers()
// Or for monolithic
// controllers.Index()
if err := app.Setup(); err != nil {
fmt.Fprintf(os.Stderr, "Failed to setup server: %v\n", err)
os.Exit(1)
}
// WebSocket configuration for real-time features (Using Gorilla WebSocket)
app.UseWebSockets(
[]string{"GET", "POST", "PUT", "DELETE", "PATCH"},
infraConfig.WEB_SOCKET_SERVER_PATH,
)
port := appSharedSettings.AppSettingsInstance().PORT
app.Listen(port)
}
Technology Stack
For project development, I selected the following technologies:
API Framework
Instead of Gin, I selected Web for:
- Superior performance (benchmarks)
- Reduced footprint
- Minimalist philosophy that promotes best practices and separation of concerns
Personally, I opted to have a copy of the framework within the project adapted to my specific needs, and what was in the project's philosophy I shared as improvement proposals to the original repository.
I contributed improvements to the project:
Dependency Management
For DI I implemented a thread-safe container pattern based on:
- Use of interfaces to decouple implementations
- Singleton pattern for shared instances
- Lazy initialization to optimize startup time
Real-time & Notifications
- Gorilla WebSocket: Stable and well-maintained WebSocket protocol
To use WebSockets with the Web framework, I needed to modify the framework's Request Context to facilitate WebSocket support and operation in a simpler and cleaner way.
Swagger / OpenAPI
For API documentation, instead of swaggo (which uses comment annotations), I implemented dynamic OpenAPI spec generation:
Stack:
- kin-openapi: Library to manipulate OpenAPI 3.1 specs
- Swagger UI: Web interface (static resources)
Implementation:
// Generate OpenAPI spec from route registry
func (app *App) GenerateOpenAPISpec(serverHost string) error {
wd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get working directory: %w", err)
}
port := appSharedSettings.AppSettingsInstance().PORT
routes := openApi.GetRoutesRegistry().GetRoutes()
generator := openApi.CreateDocumentation(
serverHost,
port,
app.ApiRootPath,
openApi.CreateDocumentInfo(infraConfig.ApiDocInfoInstance),
routes,
)
generator.AddServerUrl("/", "Current server")
outputPath := filepath.Join(wd, "../../openapi.json")
generator.SaveApiDoc("", outputPath)
generator.Finish()
app.logger.LogInfo("OpenAPI documentation generated successfully")
return nil
}
Advantages:
- ✅ Doesn't depend on comments (DRY principle)
- ✅ Type changes automatically reflect in the spec
- ✅ Supports OpenAPI 3.1 (most recent)
- ✅ Generation using reflection from route definitions (Only at Application startup)
- ✅ Full control over the generation process
Input Validations
I use the DTO pattern with Ozzo Validation for contextual validations:
// DTOs with explicit validations per use case
type CreateUserDTO struct {
Name string
Email string
Age int
SuperPower string
}
func (dto CreateUserDTO) Validate() error {
return validation.ValidateStruct(dto,
validation.Field(&dto.Name, validation.Required, validation.Length(3, 50)),
validation.Field(&dto.Email, validation.Required, is.Email),
validation.Field(&dto.Age, validation.Required, validation.Min(18)),
validation.Field(&dto.SuperPower, validation.Required),
)
}
type UpdateUserDTO struct {
Name *string // Optional in update
Email *string
}
func (dto UpdateUserDTO) Validate() error {
return validation.ValidateStruct(&dto,
validation.Field(&dto.Name, validation.Length(3, 50)),
validation.Field(&dto.Email, is.Email),
)
}
Advantages:
- ✅ Contextual validations (Create vs Update have different rules)
- ✅ Clear separation: validation in DTO, logic in domain
- ✅ Declarative and testable validations
- ✅ Doesn't pollute domain model with validation tags
- ✅ Handled from application layer, not from infrastructure
ORM / Database
- GORM: Mature ORM with support for multiple databases
Testing Stack: The Solution (Finally)
After evaluating various options and experiments with different combinations, I arrived at a solution that adapts to my personal preferences and project needs. The final combination includes:
Result Pattern for Response Handling
Before showing the tests, it's important to understand the Result[T] pattern I use to unify use case responses:
// application/shared/result.go
package shared
type Result[T any] struct {
data T
err error
success bool
}
func NewResult[T any]() *Result[T] {
return &Result[T]{}
}
func (r *Result[T]) SetData(data T) *Result[T] {
r.data = data
r.success = true
return r
}
func (r *Result[T]) SetError(err error) *Result[T] {
r.err = err
r.success = false
return r
}
func (r *Result[T]) IsSuccessful() bool { return r.success }
func (r *Result[T]) GetData() T { return r.data }
func (r *Result[T]) GetError() error { return r.err }
func (r *Result[T]) GetErrorMessage() string {
if r.err != nil {
return r.err.Error()
}
return ""
}
Advantages:
- ✅ Explicit success/error handling
- ✅ Type-safe for return data
- ✅ Facilitates testing (verify success flag)
- ✅ Avoids nil pointer panic
- ✅ Fluent API for construction
Now, let's see how tests look using this pattern along with generated mocks.
1. Counterfeiter for Mock Generation
Counterfeiter automatically generates mocks from interfaces:
# Generate mocks for all interfaces in contracts/
go generate ./...
Advantages over testify/mock:
- ✅ Type-safe by default
- ✅ Clean and readable generated code
- ✅ Complete IDE autocomplete
- ✅ Compilation fails if interface changes
- ✅ Built-in spy pattern to verify calls
2. Minimalist Assertion Library
- Assert for simple and expressive assertions:
assert.True(t, result.IsSuccessful())
assert.Equal(t, expected, actual)
assert.Contains(t, slice, item)
3. Generic Factory for Mocks
I implemented a factory pattern using generics to simplify mock creation:
// contractsfakes/factory.go
package contractsfakes
import "sync"
type fakeContainer struct {
Logger FakeLoggerProvider
// Other shared fakes...
}
var (
FakeContainer *fakeContainer
initOnce sync.Once
)
func init() {
initOnce.Do(func() {
FakeContainer = &fakeContainer{
Logger: FakeLoggerProvider{},
}
})
}
// Get returns a new instance of fake type T
func Get[T any]() *T {
return new(T)
}
Advantages:
- ✅ Type-safe fake creation
- ✅ Centralized management for shared fakes
- ✅ Simple and straightforward
Complete Test Example
Tests combine interface mocks (counterfeiter) with domain builders to create clean and expressive tests:
// getUserById_test.go
func TestErrorGettingUserByIdWithInvalidId(t *testing.T) {
t.Parallel()
// Arrange
invalidID := "" // Empty ID
errorMessage := "user id cannot be empty"
fakeUserRepository := contractsfakes.Get[contractsfakes.FakeUserRepository]()
fakeUserRepository.GetByIdReturns(nil, errors.New(errorMessage))
logger := contractsfakes.FakeContainer.Logger
useCase := NewGetUserByIdUseCase(logger, fakeUserRepository)
// Act
result := useCase.Execute(context.Background(), sharedLocales.LOCAL_EN_US, invalidID)
// Assert
assert.False(t, result.IsSuccessful())
assert.Equal(t, errorMessage, result.GetErrorMessage())
// Verify mock was called with invalid ID
assert.Equal(t, 1, fakeUserRepository.GetByIdCallCount())
_, actualID := fakeUserRepository.GetByIdArgsForCall(0)
assert.Equal(t, invalidID, actualID)
}
func TestSuccessGettingUserById(t *testing.T) {
t.Parallel()
// Arrange
userID := "user-123"
expectedUser := mocks.NewUserBuilder().
WithId(userID).
WithName("John Doe").
WithSuperPower("Invisibility").
Build()
fakeUserRepository := contractsfakes.Get[contractsfakes.FakeUserRepository]()
fakeUserRepository.GetByIdReturns(expectedUser, nil)
logger := contractsfakes.FakeContainer.Logger
useCase := NewGetUserByIdUseCase(logger, fakeUserRepository)
// Act
result := useCase.Execute(context.Background(), sharedLocales.LOCAL_EN_US, userID)
// Assert
assert.True(t, result.IsSuccessful())
user := result.GetData()
assert.Equal(t, "user-123", user.ID)
assert.Equal(t, "John Doe", user.Name)
assert.Equal(t, "Invisibility", user.SuperPower)
}
func TestCreateUser_MultipleScenarios(t *testing.T) {
t.Parallel()
tests := []struct {
name string
userBuilder *mocks.UserMockBuilder
repoError error
expectedOK bool
expectedError string
}{
{
name: "success creating user",
userBuilder: mocks.NewUserBuilder().
WithId("user-456").
WithName("Jane Smith").
WithSuperPower("Telekinesis"),
repoError: nil,
expectedOK: true,
},
{
name: "error creating user without name",
userBuilder: mocks.NewUserBuilder().
WithId("user-789").
WithSuperPower("Flight"),
repoError: errors.New("name is required"),
expectedOK: false,
expectedError: "name is required",
},
}
for _, tt := range tests {
tt := tt // capture range variable
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Arrange
user := tt.userBuilder.Build()
fakeUserRepository := contractsfakes.Get[contractsfakes.FakeUserRepository]()
fakeUserRepository.CreateReturns(tt.repoError)
logger := contractsfakes.FakeContainer.Logger
useCase := NewCreateUserUseCase(logger, fakeUserRepository)
// Act
result := useCase.Execute(context.Background(), sharedLocales.LOCAL_EN_US, user)
// Assert
assert.Equal(t, tt.expectedOK, result.IsSuccessful())
if !tt.expectedOK {
assert.Equal(t, tt.expectedError, result.GetErrorMessage())
}
// Verify repository interaction
assert.Equal(t, 1, fakeUserRepository.CreateCallCount())
})
}
}
// Test cleanup to ensure test isolation
func TestMain(m *testing.M) {
// Setup before tests (if needed)
setup()
// Run tests
code := m.Run()
// Cleanup after tests
cleanup()
os.Exit(code)
}
func setup() {
// Common initialization if needed
}
func cleanup() {
// Reset shared state if needed
contractsfakes.FakeContainer = nil
}
Advantages of this combination:
- ✅ Interface mocks (counterfeiter): For port/adapter behavior
- ✅ Domain builders: To build entity state in a fluent and readable way
- ✅ Clear separation: Business logic in domain, I/O behavior in mocks
- ✅ Expressive tests: Builder pattern makes test code self-documenting
Implemented Best Practices
-
Test Isolation: Each test uses
t.Parallel()for concurrent execution - AAA Pattern: Arrange-Act-Assert clearly separated
- Mock Verification: Verify not only results but also interactions
- Table-Driven Tests for multiple cases
Results and Metrics
- Code coverage: >85% in application layer
- Test execution time: <2s for entire suite with parallelization
- Maintainability: Interface changes detected at compile-time
- Developer experience: 60% reduction in test setup code lines
When to Consider testify/mock
Despite my choice of counterfeiter for this project, testify/mock remains an excellent option in many scenarios:
Advantages of testify/mock:
-
Adoption and Ecosystem:
- More GitHub stars than
counterfeiter(This is community) - Greater amount of examples, tutorials, and Stack Overflow content
- Better support in large teams with personnel rotation
- More GitHub stars than
Advanced Matchers (⚠️ Code Smell):
testify/mock allows complex matchers with logic:
mockRepo.On("Get",
mock.MatchedBy(func(ctx context.Context) bool {
deadline, ok := ctx.Deadline()
return ok && time.Until(deadline) > time.Second
}),
"user-123",
).Return(&User{}, nil)
However, this is an architectural antipattern. If you need to validate complex logic in a mock, it's a signal that may indicate:
- ❌ Business logic is in the wrong place
- ❌ Your interface (port) is doing too much
- ❌ Single Responsibility Principle violation
Correct design: Mocks should be dumb stubs (input → output). Logic should live in the domain:
// ✅ Logic in domain, not in mock
type RequestValidator struct {
minTimeout time.Duration
}
func (v *RequestValidator) ValidateContext(ctx context.Context) error {
deadline, ok := ctx.Deadline()
if !ok || time.Until(deadline) < v.minTimeout {
return ErrInvalidTimeout
}
return nil
}
// ✅ Simple repository mock
fakeRepo.GetReturns(&User{}, nil)
- Behavior Verification:
// Specific assertions about interactions
mockRepo.AssertNumberOfCalls(t, "Get", 3)
mockRepo.AssertCalled(t, "Update", mock.Anything, expectedUser)
mockRepo.AssertExpectations(t) // Verify all expectations
- Mockery v2.20+ with Expecters (partial improvement):
Mockery is a code generator that creates mocks using testify/mock internally. Since v2.20+, with --with-expecter=true it generates additional type-safe code:
// Code generated by mockery with --with-expecter=true
mockRepo.EXPECT().Get(mock.Anything, "123").Return(&User{}, nil)
Advantages over traditional testify/mock:
- ✅ Type-safety in method names (safe refactoring)
- ✅ Better IDE autocomplete
- ✅ No reflection overhead (code generated at compile-time)
Limitations that persist:
- ❌ Still requires
mock.Anythingfor variable arguments - ❌ No compile-time verification of argument types
- ❌ No compile-time verification of return types
It's an intermediate step between traditional testify/mock and counterfeiter, but doesn't completely resolve the type-safety problem that motivated my search for alternatives.
-
Runtime Flexibility:
- Useful for complex integration testing (Being careful not to mix domain logic in mocks)
- Simulation of race conditions
- Change behavior during test
Ideal use cases for testify/mock:
- ✅ Enterprise projects with large teams
- ✅ Behavior testing complex with multiple interactions
- ✅ Teams with prior experience in testify (organizational inertia)
- ✅ Projects that value API stability over strict type-safety
- ✅ Integration testing where call order verification is needed
⚠️ Note: If you find yourself frequently using complex matchers, consider evaluating whether your architecture respects the separation between ports (interfaces) and domain logic.
Why Complex Matchers Are Problematic
When you find yourself needing this:
// 🚩 RED FLAG: Business logic in the mock
mockValidator.On("Validate", mock.MatchedBy(func(u User) bool {
return u.Age >= 18 && u.HasConsent && u.Country == "US"
})).Return(nil)
You're mixing responsibilities. Correct design separates:
// ✅ CORRECT: Logic in domain
type User struct {
Age int
HasConsent bool
Country string
}
func (u User) CanPurchase() error {
if u.Age < 18 {
return ErrUnderage
}
if !u.HasConsent {
return ErrNoConsent
}
if u.Country != "US" {
return ErrInvalidCountry
}
return nil
}
// ✅ Simple mock - only port behavior
fakeRepo.GetReturns(&User{Age: 25, HasConsent: true, Country: "US"}, nil)
// ✅ Separate domain test
func TestUser_CanPurchase(t *testing.T) {
user := User{Age: 25, HasConsent: true, Country: "US"}
assert.NoError(t, user.CanPurchase())
}
Fundamental principle:
Mocks model port behavior (interfaces), not domain logic.
This separation makes:
- ✅ Tests simpler and more readable
- ✅ Domain logic independently testable
- ✅ Mocks truly "dumb stubs"
- ✅ Refactoring safer
- ✅
Port And AdaptersArchitecture respected
My Choice: counterfeiter
For this specific project I chose counterfeiter because:
- ✅ Preference for compile-time safety
- ✅ Frequent interface refactoring
- ✅ Prioritization of IDE autocomplete and type-safety
- ✅ Enforces good design: Doesn't allow complex matchers (feature, not limitation)
- ✅ Clean interfaces following
Ports And Adaptersprinciples
The choice between testify/mock and counterfeiter is a valid architectural preference, not a right vs wrong decision. Both tools are excellent and the best option depends on the project context and team priorities.
Conclusions
This custom testing stack has proven effective for the specific needs of most of our new projects because it offers:
- ✅ Type-safe: Errors detected at compilation
- ✅ Maintainable: Interface changes automatically reflect in tests
- ✅ Clean: Readable and expressive test code
- ✅ Fast: Parallel test execution
- ✅ Scalable: Easy to add new tests following the same pattern
Lessons Learned
-
There's no one-size-fits-all solution:
testify/mockandcounterfeitersolve the same problem with different trade-offs - Context matters: The best tool depends on team size, experience, and project requirements
- Type-safety vs Flexibility: It's a spectrum, not a dichotomy
- Community matters: Sometimes it's worth sacrificing personal preferences for ecosystem adoption
- Limitations can be features: The "impossibility" of creating complex matchers in counterfeiter enforces better architectural design
- Simple mocks = Clean design: If your mocks are complex, your application probably has domain logic where it shouldn't be, and your domain obviously needs refactoring
The combination of counterfeiter + generic factory + minimalist assertions has resulted in a testing experience that fits my architectural preferences, maintaining the expressiveness necessary for clean tests. However, I recognize that testify/mock remains the industry standard for good reasons, and I would recommend evaluating both options according to the specific context of each project.
I hope this detailed explanation of my experience helps other developers make informed decisions about their testing stack in Go, but above all, to reflect on the importance of architectural design in the quality and maintainability of tests.
If you found this explanation useful, don't hesitate to share it with other developers who might benefit from these ideas and experiences, and I also invite you to share your own experiences and preferences in developing tests in Go because it's always valuable to learn from others' experiences.
Top comments (0)