DEV Community

Cover image for Professional Testing Experience in Golang
Vıɔk A Hıƃnıʇɐ C.
Vıɔk A Hıƃnıʇɐ C.

Posted on

Professional Testing Experience in Golang

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"
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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.Anything that 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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Cloud Run (containerized services): For stateful services, WebSockets, or those requiring greater control over the runtime
  2. 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)
}
Enter fullscreen mode Exit fullscreen mode

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:

  • PR #1: Context propagation
  • PR #12: Middleware composition improvements

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

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:

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
}
Enter fullscreen mode Exit fullscreen mode

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),
    )
}
Enter fullscreen mode Exit fullscreen mode

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 ""
}
Enter fullscreen mode Exit fullscreen mode

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 ./...
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Test Isolation: Each test uses t.Parallel() for concurrent execution
  2. AAA Pattern: Arrange-Act-Assert clearly separated
  3. Mock Verification: Verify not only results but also interactions
  4. 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:

  1. 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
  2. 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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
  1. Behavior Verification:
   // Specific assertions about interactions
   mockRepo.AssertNumberOfCalls(t, "Get", 3)
   mockRepo.AssertCalled(t, "Update", mock.Anything, expectedUser)
   mockRepo.AssertExpectations(t) // Verify all expectations
Enter fullscreen mode Exit fullscreen mode
  1. 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)
Enter fullscreen mode Exit fullscreen mode

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.Anything for 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.

  1. 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)
Enter fullscreen mode Exit fullscreen mode

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())
}
Enter fullscreen mode Exit fullscreen mode

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 Adapters Architecture 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 Adapters principles

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

  1. There's no one-size-fits-all solution: testify/mock and counterfeiter solve the same problem with different trade-offs
  2. Context matters: The best tool depends on team size, experience, and project requirements
  3. Type-safety vs Flexibility: It's a spectrum, not a dichotomy
  4. Community matters: Sometimes it's worth sacrificing personal preferences for ecosystem adoption
  5. Limitations can be features: The "impossibility" of creating complex matchers in counterfeiter enforces better architectural design
  6. 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)