DEV Community

Cover image for Experiencia profesional de testing en Golang
Vıɔk A Hıƃnıʇɐ C.
Vıɔk A Hıƃnıʇɐ C.

Posted on

Experiencia profesional de testing en Golang

Durante mi primer año con Golang, desarrollé un stack completo de desarrollo que finalmente me satisface. El proceso tomó aproximadamente dos meses de exploración y refinamiento continuo. Sin embargo, no fue fácil llegar a este punto, ya que tuve que superar varios retos y uno de los más grandes fue el tema de las pruebas unitarias (unit tests) y el patrón de mocking en Go.

En mi experiencia profesional he trabajado con varios lenguajes como C, C++, Java, C#, JavaScript y TypeScript, y echo de menos algunas características de algunos de estos lenguajes cuando escribo tests en Go. Una de las cosas que más me llama la atención es que mientras en lenguajes con frameworks de mocking (como Jest en JavaScript o NSubstitute en C#) crear un stub/mock es más directo (cosas de una línea), en Go, debido a su naturaleza estáticamente tipada y la ausencia de reflection dinámico en runtime, se necesita generar código que implemente explícitamente cada interfaz que se quiere mockear.

Esta característica hace que los tests en Go sean más verbosos inicialmente, aunque más seguros en tiempo de compilación. A veces lleva a que los tests se vean desordenados si no se sigue una buena estrategia de desarrollo. Sin embargo, nunca perdí la esperanza de encontrar una forma de hacer tests más limpios y mantenibles en Go.

Por ejemplo, en TypeScript con jest-mock-extended, crear un stub es tan simple como:

// 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

Y si queremos verificar que un método fue llamado con ciertos parámetros (mock):

// Verify that the method was called with the expected ID
expect(userRepository.getById).toHaveBeenCalledWith("unique-user-id");
Enter fullscreen mode Exit fullscreen mode

Mi experiencia con testify/mock

En la organización donde colaboro, el stack de testing utilizaba testify/mock. Durante las primeras tres semanas trabajando con esta librería, identifiqué que, aunque es muy popular y tiene muchas funcionalidades, el setup de mocks puede ser verboso, especialmente al configurar múltiples expectations. Si bien existen herramientas como mockery (un generador de código que crea mocks usando testify/mock internamente) y las versiones recientes han mejorado significativamente, el patrón de uso basado en strings y runtime assertions no se ajustaba a mi preferencia por compile-time safety.

Limitaciones encontradas

Después de estar trabajando con testify/mock, identifiqué los siguientes aspectos que no se alineaban con mis preferencias:

  • Type safety limitada: Uso de strings para nombres de métodos y mock.Anything que pospone errores a runtime
  • Refactorización: Al renombrar métodos de interfaces, los tests siguen compilando dando falsos positivos
  • Autocomplete en IDEs: Limitado debido al uso de strings y tipos genéricos
  • Verificación en compile-time: Preferencia personal por detectar errores en compilación

Esto me llevó a proponer mejoras mediante un PR que incluía:

  • Type-safe method call verification
  • Better IDE autocomplete support
  • Compile-time validation of mock expectations

Aunque el PR agregaba type-safety mediante generics, no fue aceptado debido a diferencias filosóficas porque al parecer testify prioriza simplicidad y API estable sobre type-safety, mientras mi propuesta añadía complejidad genérica que, si bien técnicamente es válida, no se alineaba con la dirección del proyecto. Esta decisión me ayudó a entender que diferentes proyectos tienen diferentes trade-offs válidos.

Mi solución: Stack moderno de testing enfocado en Arquitectura

Al ver que necesitaba una solución más adecuada a mis necesidades para un nuevo proyecto, decidí crear mi propio stack desde cero, basándome en:

Arquitectura Hexagonal con 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

Ventajas de esta arquitectura para testing:

  • Las interfaces están bien definidas en application/contracts/
  • Los casos de uso son fáciles de probar en aislamiento
  • Los adapters pueden ser fácilmente mocked
  • La lógica de dominio está libre de dependencias externas

Estrategia de despliegue en GCP

El proyecto soporta dos estrategias de deployment bajo el mismo codebase:

  1. Cloud Run (containerized services): Para servicios con estado, WebSockets o que requieren mayor control sobre el runtime
  2. Cloud Functions (serverless): Para funciones event-driven, procesamiento asíncrono o endpoints simples

Ejemplo de main.go para 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

Stack tecnológico

Para el desarrollo del proyecto, seleccioné las siguientes tecnologías:

API Framework

En lugar de Gin, seleccioné Web por:

  • Performance superior (benchmarks)
  • Footprint reducido
  • Filosofía minimalista que promueve buenas prácticas y separación de responsabilidades

Personalmente opté por tener una copia del framework dentro del proyecto adaptada a mis necesidades específicas y lo que estaba en la filosofía del proyecto lo compartí como propuestas de mejora al repositorio original.

Contribuí con mejoras al proyecto:

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

Gestión de dependencias

Para DI implementé un patron container thread-safe basado en:

  • Uso de interfaces para desacoplar implementaciones
  • Patrón Singleton para instancias compartidas
  • Lazy initialization para optimizar startup time

Real-time & Notificaciones

Para poder usar Websockets con Web framework, tuve la necesidad de modificar el Request Context del framework para facilitar el soporte y operación de WebSockets de manera más sencilla y limpia.

Swagger / OpenAPI

Para documentación de API, en lugar de swaggo (que usa anotaciones en comentarios), implementé generación dinámica de OpenAPI specs:

Stack:

Implementación:

// 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

Ventajas:

  • ✅ No depende de comentarios (DRY principle)
  • ✅ Cambios en tipos se reflejan automáticamente en la spec
  • ✅ Soporta OpenAPI 3.1 (más reciente)
  • ✅ Generación usando reflection desde definiciones de rutas (Sólo en el arranque de la Aplicación)
  • ✅ Control total sobre el proceso de generación

Validaciones de entradas

Uso el patrón DTO con Ozzo Validation para validaciones contextuales:

// 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

Ventajas:

  • ✅ Validaciones contextuales (Create vs Update tienen reglas diferentes)
  • ✅ Separación clara: validación en DTO, lógica en dominio
  • ✅ Validaciones declarativas y testeables
  • ✅ No poluta el modelo de dominio con tags de validación
  • ✅ Manejadas desde la capa de aplicación, no desde infraestructura

ORM / Database

  • GORM: ORM maduro con soporte para múltiples bases de datos

Stack de Testing: La solución (finalmente)

Después de evaluar varias opciones y experimentos con diferentes combinaciones, llegué a una solución que se adapta a mis preferencias personales y necesidades del proyecto. La combinación final incluye:

Patrón Result para manejo de respuestas

Antes de mostrar los tests, es importante entender el patrón Result[T] que uso para unificar respuestas de use cases:

// 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

Ventajas:

  • ✅ Manejo explícito de éxito/error
  • ✅ Type-safe para datos de retorno
  • ✅ Facilita testing (verificar success flag)
  • ✅ Evita panic por nil pointer
  • ✅ Fluent API para construcción

Ahora, veamos cómo se ven los tests usando este patrón junto con mocks generados.

1. Counterfeiter para generación de mocks

Counterfeiter genera mocks automáticamente desde interfaces:

# Generar mocks para todas las interfaces en contracts/
go generate ./...
Enter fullscreen mode Exit fullscreen mode

Ventajas sobre testify/mock:

  • ✅ Type-safe por defecto
  • ✅ Código generado limpio y legible
  • ✅ IDE autocomplete completo
  • ✅ Compilación falla si la interfaz cambia
  • ✅ Spy pattern built-in para verificar llamadas

2. Librería de aserciones minimalista

  • Assert para aserciones simples y expresivas:
assert.True(t, result.IsSuccessful())
assert.Equal(t, expected, actual)
assert.Contains(t, slice, item)
Enter fullscreen mode Exit fullscreen mode

3. Factory genérico para mocks

Implementé un factory pattern usando generics para simplificar la creación de mocks:

// 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

Ventajas:

  • ✅ Type-safe creation de fakes
  • ✅ Gestión centralizada para fakes compartidos
  • ✅ Simple y directo

Ejemplo de test completo

Los tests combinan mocks de interfaces (counterfeiter) con builders de dominio para crear tests limpios y expresivos:

// 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

Ventajas de esta combinación:

  • Mocks de interfaces (counterfeiter): Para comportamiento de puertos/adapters
  • Builders de dominio: Para construir estado de entidades de forma fluida y legible
  • Separación clara: Lógica de negocio en dominio, comportamiento de I/O en mocks
  • Tests expresivos: El builder pattern hace el código de test autodocumentado

Mejores prácticas implementadas

  1. Test Isolation: Cada test usa t.Parallel() para ejecución concurrente
  2. AAA Pattern: Arrange-Act-Assert claramente separado
  3. Mock Verification: Verificar no solo resultados sino también interacciones
  4. Table-Driven Tests para casos múltiples:

Resultados y métricas

  • Code coverage: >85% en capa de aplicación
  • Test execution time: <2s para toda la suite con paralelización
  • Mantenibilidad: Cambios en interfaces detectados en compile-time
  • Developer experience: Reducción del 60% en líneas de código de setup de tests

Cuándo considerar testify/mock

A pesar de mi elección de counterfeiter para este proyecto, testify/mock sigue siendo una excelente opción en muchos escenarios:

Ventajas de testify/mock:

  1. Adopción y Ecosistema:

    • Más stars en GitHub que counterfeiter (Esto es comunidad)
    • Mayor cantidad de ejemplos, tutoriales y Stack Overflow
    • Mejor soporte en equipos grandes con rotación de personal
  2. Matchers Avanzados (⚠️ Code Smell):

    • testify/mock permite matchers complejos con lógica:
   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

Sin embargo, esto es un antipatrón arquitectural. Si necesitas validar lógica compleja en un mock, es una señal que puede indicar que:

  • ❌ La lógica de negocio está en el lugar equivocado
  • ❌ Tu interfaz (puerto) está haciendo demasiado
  • ❌ Violación de Single Responsibility Principle

Diseño correcto: Los mocks deben ser dumb stubs (entrada → salida). La lógica debe vivir en el dominio:

   // ✅ Lógica en dominio, no en 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
   }

   // ✅ Mock simple del repositorio
   fakeRepo.GetReturns(&User{}, nil)
Enter fullscreen mode Exit fullscreen mode
  1. Verificación de Comportamiento:
   // Aserciones específicas sobre interacciones
   mockRepo.AssertNumberOfCalls(t, "Get", 3)
   mockRepo.AssertCalled(t, "Update", mock.Anything, expectedUser)
   mockRepo.AssertExpectations(t) // Verifica todas las expectations
Enter fullscreen mode Exit fullscreen mode
  1. Mockery v2.20+ con Expecters (mejora parcial):

Mockery es un generador de código que crea mocks usando testify/mock internamente. Desde v2.20+, con --with-expecter=true genera código adicional type-safe:

   // Code generated by mockery with --with-expecter=true
   mockRepo.EXPECT().Get(mock.Anything, "123").Return(&User{}, nil)
Enter fullscreen mode Exit fullscreen mode

Ventajas sobre testify/mock tradicional:

  • ✅ Type-safety en nombres de métodos (refactoring seguro)
  • ✅ Mejor IDE autocomplete
  • ✅ Sin overhead de reflection (código generado en compile-time)

Limitaciones que persisten:

  • ❌ Aún requiere mock.Anything para argumentos variables
  • ❌ No hay verificación de tipos de argumentos en compile-time
  • ❌ No hay verificación de tipos de retorno en compile-time

Es un paso intermedio entre testify/mock tradicional y counterfeiter, pero no resuelve completamente el problema de type-safety que motivó mi búsqueda de alternativas.

  1. Flexibilidad en Runtime:
    • Útil para testing de integraciones complejas (Cuidando de no mezclar lógica de dominio en mocks)
    • Simulación de condiciones race conditions
    • Cambiar comportamiento durante el test

Casos de uso ideales para testify/mock:

  • Proyectos enterprise con equipos grandes
  • Testing de comportamiento complejo con múltiples interacciones
  • Equipos con experiencia previa en testify (inercia organizacional)
  • Proyectos que valoran estabilidad de API sobre type-safety estricto
  • Testing de integraciones donde se necesita verificar orden de llamadas

⚠️ Nota: Si te encuentras usando matchers complejos frecuentemente, considera evaluar si tu arquitectura respeta la separación entre puertos (interfaces) y lógica de dominio.

Por qué los matchers complejos son problemáticos

Cuando te encuentras necesitando esto:

// 🚩 RED FLAG: Lógica de negocio en el 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

Estás mezclando responsabilidades. El diseño correcto separa:

// ✅ CORRECTO: Lógica en dominio
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
}

// ✅ Mock simple - solo comportamiento de puerto
fakeRepo.GetReturns(&User{Age: 25, HasConsent: true, Country: "US"}, nil)

// ✅ Test de dominio separado
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

Principio fundamental:

Los mocks modelan comportamiento de puertos (interfaces), no lógica de dominio.

Esta separación hace que:

  • ✅ Los tests sean más simples y legibles
  • ✅ La lógica de dominio sea testeable independientemente
  • ✅ Los mocks sean realmente "dumb stubs"
  • ✅ El refactoring sea más seguro
  • ✅ Se respete la Arquitectura de Port And Adapters

Mi elección: counterfeiter

Para este proyecto específico elegí counterfeiter porque:

  • ✅ Preferencia por compile-time safety
  • ✅ Refactoring frecuente de interfaces
  • ✅ Priorización de IDE autocomplete y type-safety
  • Fuerza buen diseño: No permite matchers complejos (feature, no limitación)
  • ✅ Interfaces limpias siguiendo principios de Ports And Adapters

La elección entre testify/mock y counterfeiter es una preferencia arquitectónica válida, no una decisión de correcto vs incorrecto. Ambas herramientas son excelentes y la mejor opción depende del contexto del proyecto y las prioridades del equipo.

Conclusiones

Este stack de testing personalizado ha demostrado ser efectivo para las necesidades específicas de la mayoría de nuestros nuevos proyectos porque ofrece:

  • Type-safe: Errores detectados en compilación
  • Maintainable: Cambios en interfaces reflejan automáticamente en tests
  • Clean: Código de test legible y expresivo
  • Fast: Ejecución paralela de tests
  • Scalable: Fácil agregar nuevos tests siguiendo el mismo patrón

Lecciones aprendidas

  1. No hay solución única: testify/mock y counterfeiter resuelven el mismo problema con trade-offs diferentes
  2. Context matters: La mejor herramienta depende del tamaño del equipo, experiencia y requisitos del proyecto
  3. Type-safety vs Flexibilidad: Es un espectro, no una dicotomía
  4. Comunidad importa: A veces vale la pena sacrificar preferencias personales por adopción del ecosistema
  5. Las limitaciones pueden ser features: La "imposibilidad" de crear matchers complejos en counterfeiter fuerza mejor diseño arquitectural
  6. Mocks simples = Diseño limpio: Si tus mocks son complejos, probablemente tu aplicación tiene lógica de dominio donde no debe estar y obviamente tu dominio necesita refactoring

La combinación de counterfeiter + factory genérico + aserciones minimalistas ha resultado en una experiencia de testing que se ajusta a mis preferencias arquitectónicas, manteniendo la expresividad necesaria para tests limpios. Sin embargo, reconozco que testify/mock sigue siendo la opción estándar de la industria por buenas razones, y recomendaría evaluar ambas opciones según el contexto específico de cada proyecto.

Espero que esta explicación detallada de mi experiencia ayude a otros desarrolladores a tomar decisiones informadas sobre su stack de testing en Go pero sobre todo a reflexionar sobre la importancia del diseño arquitectónico en la calidad y mantenibilidad de los tests.

Si te fue útil esta explicación, no dudes en compartirla con otros desarrolladores que puedan beneficiarse de estas ideas y experiencias, y también te invito a compartir tus propias experiencias y preferencias en el desarrollo de tests en Go porque siempre es valioso aprender de las experiencias de otros.

Top comments (0)