Guías / Component Testing: la estrategia de pruebas estándar en White Label
Testing
13 de julio de 2026
testingcomponent-testinggotestcontainerscalidadwhite-label

Component Testing: la estrategia de pruebas estándar en White Label

Por qué reemplazamos las pruebas unitarias por component tests contra la API real, sus ventajas y desventajas, y un tutorial paso a paso con código real de core-lists y engine-promotions.

El problema que resuelve

Cada microservicio White Label sigue la arquitectura en cuatro capas (ADR-000), expone su contrato vía Protocol Buffers (ADR-001), varía su comportamiento por tenant (ADR-002) y se comunica con otros servicios por eventos (ADR-003). El código de negocio evoluciona constantemente — eso es sano en un sistema vivo.

Durante un tiempo la cobertura descansó en pruebas unitarias por capa (Core, Service, helpers), con mocks de las dependencias. En la práctica se repetía siempre el mismo patrón: los tests unitarios se volvían verbosos, ensuciaban la navegación del código, y quedaban obsoletos apenas el código cambiaba. Un dev refactorizaba, rompía varios tests unitarios que no aportaban señal real, y terminaba borrándolos o dejándolos rotos. El test dejaba de ser una red de seguridad y se volvía deuda.

El problema de fondo: las pruebas unitarias con mocks verifican la implementación (volátil), no el contrato (estable), que es lo único que realmente le importa al cliente de la API. Necesitábamos una estrategia que sobreviviera a la evolución del código, que detectara exactamente lo que rompe en producción, y que costara poco mantener.

Alcance de esta decisión: cubre el testing del código de cada microservicio que corre como gate de PR en CI — debe ser autónomo, determinista y rápido. Las pruebas end-to-end sobre un ambiente de staging que replica producción son responsabilidad de QA y quedan fuera de este alcance; ambas capas son complementarias, no sustitutas.

Qué es un component test

Un punto intermedio entre unit test y end-to-end: se levanta el servicio completo en proceso (grafo fx real, servidores HTTP/gRPC reales), con la infraestructura que controlamos como dependencia real y containerizada (Postgres, NATS, Redis vía testcontainers) y todo lo externo mockeado al nivel del cable — un servidor real escuchando en una URL/dirección, nunca una interfaz de Go inyectada. Las pruebas le pegan a la API REST real y afirman sobre la respuesta JSON que recibiría el cliente final.

Gana porque ataca la causa raíz: testea el contrato (estable), no la implementación (volátil). Menos tests cubren más componentes y más casos, las pruebas no se rompen con refactors internos, y una falla señala un problema real de producción. Para un gate de PR en CI es reproducible y determinista — todo corre local o en CI con Docker, sin depender de un ambiente vivo ni de credenciales de terceros.

Por qué lo adoptamos: alternativas descartadas

Pirámide de tests clásica (unit-heavy + algunos e2e). Es justo el modelo que generó el problema: los unit tests con mocks acoplan el test a la estructura interna, así que cualquier refactor los rompe aunque el comportamiento observable no cambie. Alta verbosidad, baja señal, mantenimiento caro, obsolescencia rápida.

Pruebas end-to-end contra un ambiente desplegado. No se descarta por mala — el equipo de QA la corre sobre staging y aporta valor real — pero pertenece a otro alcance. Requiere un ambiente vivo y credenciales de SaaS de terceros, no es reproducible ni determinista dentro de un GitHub Action, y no sirve como gate rápido de PR para el código de un microservicio puntual.

Component testing (la opción elegida). Ni unit ni e2e. Recrea el servicio completo en memoria/contenedores, con un límite explícito: lo que controlamos es real, lo que no controlamos se mockea al nivel del cable.

Ventajas y desventajas

Positivas:

  • Menos código de test cubre más casos y más componentes — se ejercita el wiring fx real y la infraestructura real, no mocks de la estructura interna.
  • Se testea la data exacta que recibe el cliente: una rotura de test es una rotura de contrato, es decir, un problema real de producción.
  • Los tests no se acoplan a la implementación → sobreviven a los refactors internos → mantenibilidad alta y obsolescencia baja.
  • Cobertura alta de internal/ con pocos tests, vía el flag -coverpkg (ver “Cómo correr y medir cobertura” más abajo).
  • El loop multi-tenant detecta divergencias de comportamiento por tenant.
  • Determinista y reproducible, local y en CI (todo containerizado).

Negativas:

  • Requiere Docker disponible local y en CI; el arranque de contenedores y servidores hace el ciclo de feedback más lento que un unit test puro.
  • Cero unit tests implica que la lógica pura difícil de gatillar desde la API (backoff/retry, parsers internos) no tiene un test dirigido; una falla señala “algo en el flujo”, con menos localización que un unit test.
  • Acoplamiento al contrato de la API: un cambio de contrato rompe varios tests a la vez. Es una señal deseada, pero concentra el costo de actualización.
  • Mantener mock servers y fixtures de las dependencias externas es trabajo adicional que crece con la cantidad de integraciones.
  • El app compartido por tenant comparte estado en memoria (config) entre tests.

Riesgos y mitigaciones:

  • Docker ausente en CI → se exige runner con socket de Docker / DinD como prerrequisito de plataforma.
  • Carrera de arranque del servidor in-process (bufconn) → readiness gating contra /health antes de afirmar.
  • Contaminación entre tests por estado compartido → aislación por usuario fresco; los tests que mutan estado global en memoria (p. ej. un RefreshConfig real contra un CMS stubbeado) levantan su propio app aislado.
  • Suite lenta al crecer → app fx compartido por tenant, lazy y con teardown en TestMain.
  • Lógica pura inalcanzable desde la API → se diseña el código para que sea alcanzable por el contrato; la postura de plataforma es no reintroducir unit tests.

La decisión

Adoptar los component tests como único método de prueba estándar para todos los microservicios White Label. No se escriben pruebas unitarias nuevas; las existentes son legacy y se retiran a medida que el código evoluciona. La fórmula es obligatoria y uniforme para todos los servicios.

Stack y mecánica (no negociables):

  • testcontainers-go para la infraestructura propia (Postgres, NATS, Redis): se levanta una vez por paquete de tests en TestMain, creando una base de datos por tenant.
  • fx.New real (no fxtest) para cablear el servicio completo: un app por tenant, iniciado de forma lazy y compartido entre todos los archivos de test, con teardown en TestMain.
  • bufconn para el servidor gRPC in-process; el gateway REST se expone en un puerto local.
  • httpexpect para las peticiones HTTP y las aserciones contra el JSON real del gateway — esto convierte a los component tests también en contract tests del contrato proto/OpenAPI.
  • Un archivo de test por endpoint.
  • Loop multi-tenant: cada test corre sobre todos los tenants soportados por el servicio.
  • Isolación por usuario: los datos se aíslan creando un usuario fresco por test (no por reset/truncate de la base).
  • Readiness gating: antes de las aserciones se sondea /health (sin auth, sin DB) hasta 200, para no chocar con la carrera de arranque del servidor in-process.

Frontera real vs. mock:

  • Infraestructura que controlamos y podemos containerizar (Postgres, NATS, Redis) → real.
  • Todo lo externo — SaaS de terceros (Cognito, CMS), APIs REST externas y otros servicios White Label downstream — → mock, mockeado al nivel del cable (un servidor real que escucha en una URL/dirección), nunca con una interfaz de Go inyectada en el grafo.
  • Sin interfaces de mock. El código de negocio recibe el cliente concreto — nunca una interfaz creada solo para testear. El cliente se construye con una URL/dirección; eso es lo único que el test sustituye. El cliente real disca el mock exactamente como discaría al servicio real, ejercitando de paso el reenvío de metadata/headers de auth.
  • Recrear producción, no aislar: si una dependencia está siempre presente en producción (p. ej. CMS Remote Config), su mock va cableado en el app compartido que usan todos los tests, arrancado en TestMain. Dejar la mayoría de los tests sobre un fallback in-memory testea un camino irreal.

Tutorial paso a paso

Esta sección es reproducible con código real de dos microservicios en producción: dc-wl-core-lists (referencia canónica; API REST, mock gRPC downstream y consumer de eventos) y dc-wl-groceries-core-engine-promotions (variantes: mock REST, app dedicado con estado propio, y producer de eventos). En otro servicio cambian los nombres de dominio, no la mecánica.

Paso 0 — Prerrequisitos

  • Docker corriendo (local y en el runner de CI). testcontainers-go lo usa.
  • Dependencias de test: testcontainers-go (+ módulos postgres y nats), gavv/httpexpect/v2, go.uber.org/fx, google.golang.org/grpc (para bufconn).

Paso 1 — Layout de carpetas

tests/
├── helpers/                 # utilidades compartidas (cross-paquete)
│   └── http.go
├── platform/                 # módulos fx que reemplazan dependencias en test
│   ├── config/module.go      #   AppConfig de test (API key, feature flags, URLs de mocks)
│   ├── cognito/module.go     #   JWKS mock + minter de tokens
│   ├── server/module.go      #   listeners + dialer bufconn
│   ├── catalog/mock.go       #   mock externo gRPC (core-lists → core-catalog)
│   └── tenants/tenants.go    #   lista de tenants soportados
└── <dominio>/                 # p.ej. lists_core/, promotions_query/
    ├── main_test.go           #   TestMain: levanta contenedores
    ├── setup_test.go          #   registro de apps por tenant (appFor)
    └── <endpoint>_test.go     #   un archivo por endpoint

Así se ve en los dos servicios de referencia:

services/dc-wl-core-lists/tests/
├── helpers/http.go
├── platform/{catalog,cms,cognito,config,listscore,server,tenants}/
├── lists_core/     (main_test.go, setup_test.go, create_list_test.go, ...)
└── lists_query/    (main_test.go, setup_test.go, consumer_test.go, ...)

services/dc-wl-groceries-core-engine-promotions/tests/
├── helpers/helpers.go
├── platform/{argpromotions,cognito,config,hubpdv,otel,promotexts,server,tenants}/
├── promotions_query/   (setup_test.go, nats_consumer_test.go, db_promos_test.go, ...)
└── promotions_ingest/  (main_test.go, producer_test.go)

Paso 2 — TestMain: infraestructura real con testcontainers

Una sola vez por paquete: levantar Postgres y NATS, crear una base por tenant, exportar las URLs por env, y al final frenar los apps y los contenedores. De dc-wl-core-lists/tests/lists_core/main_test.go:

func TestMain(m *testing.M) {
	ctx := context.Background()

	if err := SetupPostgres(ctx); err != nil {
		fmt.Printf("Failed to set up PostgreSQL container: %v\n", err)
		os.Exit(1)
	}
	if err := SetupNats(ctx); err != nil {
		fmt.Printf("Failed to set up NATS container: %v\n", err)
		os.Exit(1)
	}

	// CMS Remote Config mock — wired into every app via CMS_* env.
	// Mirrors production: CMS siempre está configurado, así que todos los
	// tests corren contra el mismo estado real, no un fallback irreal.
	cmsMock = testcms.Run(testcms.Limits{
		MaxListsPerUser:    10,
		MaxItemsPerList:    20,
		MaxQuantityPerItem: 20,
	})
	os.Setenv("CMS_DOMAIN", cmsMock.URL)
	os.Setenv("CMS_API_KEY", "test-key")
	os.Setenv("CMS_PROJECT_NAME", "test-project")

	catalogMock, _ = testcatalog.Run()
	os.Setenv("CATALOG_GRPC_URL", catalogMock.Addr())
	os.Setenv("CATALOG_API_KEY", "test-key")

	code := m.Run()

	stopApps()          // frena los apps compartidos (cierra sus pools) ...
	cmsMock.Close()
	catalogMock.Close()
	pgContainer.Terminate(ctx)   // ... antes de tirar los contenedores
	natsContainer.Terminate(ctx)

	os.Exit(code)
}

func SetupPostgres(ctx context.Context) (err error) {
	pgContainer, err = postgres.Run(ctx, "postgres:18-alpine",
		postgres.WithDatabase("app"),
		postgres.WithUsername("test"),
		postgres.WithPassword("test"),
		postgres.BasicWaitStrategies(),
		postgres.WithSQLDriver("pgx"),
	)
	// ...
	host, _ := pgContainer.Host(ctx)
	port, _ := pgContainer.MappedPort(ctx, "5432/tcp")

	// Crear una base por tenant (multi-tenant).
	adminDSN := fmt.Sprintf("postgres://test:test@%s:%s/app?sslmode=disable", host, port.Port())
	conn, _ := pgx.Connect(ctx, adminDSN)
	defer conn.Close(ctx)
	for _, tenant := range tenants.All {
		conn.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, tenant))
	}

	// shared-pkg arma el DSN solo con POSTGRES_HOST: hay que plegar el puerto
	// mapeado dentro del host, o el pool intenta el 5432 por defecto y falla.
	os.Setenv("POSTGRES_HOST", fmt.Sprintf("%s:%s", host, port.Port()))
	os.Setenv("POSTGRES_USER", "test")
	os.Setenv("POSTGRES_PASSWORD", "test")
	os.Setenv("POSTGRES_SSLMODE", "disable")
	return nil
}

Gotcha real: el POSTGRES_HOST debe incluir el puerto mapeado. El cliente de Postgres del shared-pkg construye el DSN solo con cfg.Host (sin campo de puerto). Si exportás el host sin puerto, el pool intenta 5432, falla, queda en modo degradado (queries == nil) y todos los endpoints devuelven 503.

engine-promotions sigue el mismo patrón pero es más granular: el TestMain de promotions_ingest (que no tiene API REST, ver el Paso 9 más abajo) solo levanta NATS, porque es lo único que ese paquete necesita:

// tests/promotions_ingest/main_test.go
func TestMain(m *testing.M) {
	ctx := context.Background()
	if err := setupNats(ctx); err != nil {
		log.Fatalf("Failed to set up NATS container: %v", err)
	}
	code := m.Run()
	if natsContainer != nil {
		_ = natsContainer.Terminate(ctx)
	}
	os.Exit(code)
}

Paso 3 — Un app fx por tenant, compartido (setup_test.go)

El corazón del patrón. Un registro lazy: el primer test que pide un tenant lo construye con fx.New real y lo arranca; el resto lo reusa. TestMain los frena al final.

// tests/lists_core/setup_test.go
type tenantApp struct {
	baseURL string
	keyFunc *testcognito.KeyFuncMock
	stop    func(context.Context) error
}

var (
	appsMu sync.Mutex
	apps   = map[string]*tenantApp{}
)

func appFor(t *testing.T, tenant string) (string, *testcognito.KeyFuncMock) {
	t.Helper()
	appsMu.Lock()
	defer appsMu.Unlock()

	if a, ok := apps[tenant]; ok {
		return a.baseURL, a.keyFunc
	}
	a, err := startApp(tenant)
	if err != nil {
		t.Fatalf("failed to start app for tenant %q: %v", tenant, err)
	}
	apps[tenant] = a
	return a.baseURL, a.keyFunc
}

func startApp(tenant string) (*tenantApp, error) {
	os.Setenv("POSTGRES_DATABASE", tenant)

	httpLn, _ := net.Listen("tcp", "127.0.0.1:0")
	grpcLn := bufconn.Listen(1024 * 1024)

	var keyFunc *testcognito.KeyFuncMock

	app := fx.New(
		fx.NopLogger,
		fx.Provide(func() (config.TenantValue, error) { return config.NewTenant(tenant) }),
		testconfig.Module,   // override: AppConfig de test
		testserver.Module(httpLn, grpcLn), // override: listeners + dialer bufconn
		testcognito.Module,  // override: JWKS mock
		postgres.Module,     // REAL (container)
		cms.Module,          // externo (apunta al mock vía env)
		nats.Module,         // REAL (container)
		common.Module,
		health.Module,       // necesario para WaitReady
		catalog.Module,      // cliente gRPC real, apunta al mock vía env
		lists_core.Module,   // el dominio bajo prueba
		fx.Populate(&keyFunc),
	)
	if err := app.Err(); err != nil {
		return nil, fmt.Errorf("building app: %w", err)
	}

	startCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	if err := app.Start(startCtx); err != nil {
		return nil, fmt.Errorf("starting app: %w", err)
	}

	return &tenantApp{
		baseURL: fmt.Sprintf("http://127.0.0.1:%d", httpLn.Addr().(*net.TCPAddr).Port),
		keyFunc: keyFunc,
		stop:    app.Stop,
	}, nil
}

Claves: fx.New (no fxtest) porque fxtest se ata a un *testing.T y acá el app vive más allá de un test. El app se comparte porque el aislamiento de datos es por usuario, no por servidor (Paso 6).

engine-promotions extiende este mismo patrón con una variante útil: además del appFor compartido, expone un appForWithCMS que arranca un app dedicado (no cacheado) con una URL de CMS distinta, usando fx.Decorate para sobreescribir un solo valor de la config sin reconstruir todo el módulo:

// tests/promotions_query/setup_test.go
func appForWithCMS(t *testing.T, tenant, cmsURL string) (string, *testcognito.KeyFuncMock) {
	t.Helper()
	a, err := startAppWithOptions(tenant, cmsURL)
	// ...
	t.Cleanup(func() {
		ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
		defer cancel()
		a.stop(ctx)
	})
	return a.baseURL, a.keyFunc
}

func startAppWithOptions(tenant, cmsURLOverride string) (*tenantApp, error) {
	// ... mismo wiring que startApp ...
	opts := []fx.Option{ /* ... */ }

	if cmsURLOverride != "" {
		opts = append(opts, fx.Decorate(func(cfg *config.AppConfig) *config.AppConfig {
			cfgCopy := *cfg
			cfgCopy.Cms.ApiURL = cmsURLOverride
			return &cfgCopy
		}))
	}
	app := fx.New(opts...)
	// ...
}

Es el mecanismo a usar cuando un test puntual necesita simular un estado de dependencia distinto al del app compartido (ver el Paso 8 más abajo) — sin duplicar el resto del wiring.

Paso 4 — Módulos de override de plataforma

Cada dependencia que en producción es real pero en test debe controlarse tiene su módulo fx en tests/platform/.

a) server — listeners reales + dialer sobre bufconn. El gateway necesita un *grpc.ClientConn; acá lo apuntamos al listener bufconn en vez de a un TCP real:

// tests/platform/server/module.go
func Module(httpLn net.Listener, grpcLn *bufconn.Listener) fx.Option {
	grpcDialer := func(ctx context.Context, _ string) (net.Conn, error) {
		return grpcLn.DialContext(ctx)
	}
	return fx.Module("server",
		fx.Provide(fx.Annotate(func() net.Listener { return httpLn }, fx.ResultTags(`name:"http_listener"`))),
		fx.Provide(fx.Annotate(func() net.Listener { return grpcLn }, fx.ResultTags(`name:"grpc_listener"`))),
		fx.Provide(func() (*grpc.ClientConn, error) {
			return grpc.NewClient("passthrough:///bufconn",
				grpc.WithContextDialer(grpcDialer),
				grpc.WithTransportCredentials(insecure.NewCredentials()))
		}),
		fx.Provide(server.NewGrpcServer, server.NewGatewayServeMux, server.NewServeMux, server.NewHttpServer),
		fx.Invoke(fx.Annotate(server.RunGrpcServer, fx.ParamTags("", "", `name:"grpc_listener"`, ""))),
		fx.Invoke(fx.Annotate(server.RunHttpServer, fx.ParamTags("", "", `name:"http_listener"`, ""))),
	)
}

b) config — AppConfig de test. Acá viven la API key, los feature flags y las URLs de los servicios externos (que apuntarán a los mocks):

// tests/platform/config/module.go
var Module = fx.Module("config",
	fx.Provide(func() *config.AppConfig {
		return &config.AppConfig{
			Security: config.SecurityConfig{APIKey: "test-api-key"},
			Server:   config.ServerConfig{Port: 0, GrpcPort: 0},
			Features: config.FeaturesConfig{UpsertEnabled: true},
			Catalog:  config.CatalogConfig{PreviewImageSize: 250},
		}
	}),
)

c) cognito — JWKS mock + minter de tokens. Genera un par RSA en memoria: la clave pública valida los tokens en el interceptor; la privada los firma. Así el test puede forjar JWTs que el servicio acepta sin Cognito real:

// tests/platform/cognito/module.go
type KeyFuncMock struct {
	privateKey *rsa.PrivateKey
}

var _ keyfunc.Keyfunc = (*KeyFuncMock)(nil) // implementa la interfaz real que espera el interceptor

func (k *KeyFuncMock) SignedToken(username, subject string) (string, error) {
	claims := &cognito.JwtClaims{
		RegisteredClaims: jwt.RegisteredClaims{
			Subject:   subject, // ← se vuelve el userId
			Issuer:    fmt.Sprintf("https://cognito-idp.%s.amazonaws.com/%s", testRegion, testPoolID),
			ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
		},
		Username: username,
	}
	token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
	token.Header["kid"] = testKeyID
	return token.SignedString(k.privateKey)
}

func (k *KeyFuncMock) Keyfunc(token *jwt.Token) (any, error) {
	if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
		return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
	}
	return &k.privateKey.PublicKey, nil
}

Paso 5 — Helpers compartidos

Reutilizables cross-paquete: readiness gating y clientes autenticados. De tests/helpers/http.go:

// WaitReady resuelve la carrera de arranque: la primera petición puede chocar con
// el server bufconn aún no aceptando (503 transitorio). /health es público y no
// toca la DB, así que sondearlo aísla la señal de readiness.
func WaitReady(t *testing.T, baseURL string) {
	t.Helper()
	deadline := time.Now().Add(10 * time.Second)
	for time.Now().Before(deadline) {
		resp, err := http.Get(baseURL + "/health")
		if err == nil {
			defer resp.Body.Close()
			if resp.StatusCode == http.StatusOK {
				return
			}
		}
		time.Sleep(50 * time.Millisecond)
	}
	t.Fatal("service did not become ready within 10s")
}

// AuthClientFresh: cliente autenticado como un usuario aleatorio nuevo.
// Clave para la isolación: cada test opera sobre su propia cuenta vacía.
func AuthClientFresh(t *testing.T, baseURL string, keyFunc *testcognito.KeyFuncMock) *httpexpect.Expect {
	return AuthClientAs(t, baseURL, keyFunc, "fresh-user", uuid.NewString())
}

func AuthClientAs(t *testing.T, baseURL string, keyFunc *testcognito.KeyFuncMock, username, subject string) *httpexpect.Expect {
	t.Helper()
	bearer, _ := keyFunc.BearerToken(username, subject)
	return httpexpect.WithConfig(httpexpect.Config{
		BaseURL:  baseURL,
		Reporter: httpexpect.NewAssertReporter(t),
		Client:   &http.Client{},
	}).Builder(func(req *httpexpect.Request) {
		req.WithHeader("x-api-key", APIKey).WithHeader("Authorization", bearer)
	})
}

Paso 6 — Un archivo de test por endpoint

El patrón fijo: loop multi-tenant → appForWaitReady → cliente → subtests que afirman sobre el JSON real del gateway. De tests/lists_core/create_list_test.go:

func TestCreateList(t *testing.T) {
	for _, tenant := range tenants.All {          // multi-tenant
		t.Run(tenant, func(t *testing.T) {
			baseURL, keyFunc := appFor(t, tenant) // app compartido
			helpers.WaitReady(t, baseURL)         // readiness gating

			c := helpers.AuthClient(t, baseURL, keyFunc)

			t.Run("with name only returns a list id", func(t *testing.T) {
				id := c.POST("/core/v1/lists").              // ruta prefijada
					WithJSON(map[string]any{"name": "Asado del finde"}).
					Expect().
					Status(http.StatusOK).
					JSON().Object().
					Value("id").String().Raw()                // contrato real

				if _, err := uuid.Parse(id); err != nil {
					t.Fatalf("expected a UUID id, got %q", id)
				}
			})

			t.Run("duplicate name is rejected", func(t *testing.T) {
				body := map[string]any{"name": "Lista repetida"}
				c.POST("/core/v1/lists").WithJSON(body).Expect().Status(http.StatusOK)
				c.POST("/core/v1/lists").WithJSON(body).Expect().Status(http.StatusConflict)
			})

			t.Run("max lists per user reached is rejected", func(t *testing.T) {
				// Usuario fresco para no interferir con otros tests del mismo tenant.
				cFresh := helpers.AuthClientFresh(t, baseURL, keyFunc)
				for i := 0; i < 10; i++ { // 10 = el límite configurado en el mock de CMS
					cFresh.POST("/core/v1/lists").
						WithJSON(map[string]any{"name": "List " + string(rune('A'+i))}).
						Expect().Status(http.StatusOK)
				}
				cFresh.POST("/core/v1/lists").
					WithJSON(map[string]any{"name": "One Too Many"}).
					Expect().Status(http.StatusTooManyRequests)
			})

			t.Run("missing bearer token is unauthorized", func(t *testing.T) {
				httpexpect.Default(t, baseURL).
					POST("/core/v1/lists").
					WithHeader("x-api-key", helpers.APIKey).
					WithJSON(map[string]any{"name": "Sin token"}).
					Expect().Status(http.StatusUnauthorized)
			})
		})
	}
}

Notá algo importante en el segundo subtest: el límite de 10 listas por usuario no está hardcodeado en el test — viene del mock de CMS Remote Config configurado en TestMain (MaxListsPerUser: 10, Paso 2). El test verifica el comportamiento real end-to-end del feature flag, no un valor inventado.

Aislamiento sin truncate: no se resetea la DB entre tests. Cada test que necesita una cuenta limpia usa AuthClientFresh (un userId aleatorio nuevo), así su data no colisiona con la de otros tests. Esto permite compartir el app y deja los tests independientes del orden de ejecución.

Paso 7 — Mockear una dependencia externa (al nivel del cable)

Todo lo externo se mockea levantando un servidor real que escucha en una URL o dirección, y apuntando el cliente del servicio a esa URL. Nunca se inyecta una interfaz de Go: el código de negocio recibe su cliente concreto y lo único que el test cambia es la URL/dirección a la que disca.

a) Dependencia REST → httptest. Ejemplo real de engine-promotions, mockeando la API de búsqueda de promociones de VTEX:

// tests/platform/argpromotions/mock.go
type MockServer struct {
	Server       *httptest.Server
	URL          string
	SearchResult func() interface{} // mutable: cada test puede cambiar la respuesta
}

func NewMockServer() *MockServer {
	m := &MockServer{
		SearchResult: func() interface{} {
			return map[string]interface{}{
				"promotions": map[string]interface{}{
					"jumbo_prime": map[string]interface{}{"promotions": map[string]interface{}{}},
					"generic":     map[string]interface{}{"promotions": map[string]interface{}{}},
				},
			}
		},
	}
	m.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(m.SearchResult())
	}))
	m.URL = m.Server.URL
	return m
}

b) Dependencia gRPC (servicio downstream) → servidor gRPC mock. Ejemplo real de core-lists, mockeando core-catalog (el servicio que lists_query consulta para enriquecer cada lista con datos de producto):

// tests/platform/catalog/mock.go
// MockServer serves DetailService.GetProductsBySkus from an in-memory fixture map.
type MockServer struct {
	catalogv1.UnimplementedDetailServiceServer
	server   *grpc.Server
	addr     string
	products map[string]*catalogv1.Product
}

func Run() (*MockServer, error) {
	ln, _ := net.Listen("tcp", "127.0.0.1:0")
	m := &MockServer{server: grpc.NewServer(), addr: ln.Addr().String(), products: map[string]*catalogv1.Product{}}
	m.Set("3074740", "Carne para asar", true, "https://example.com/3074740.jpg")

	catalogv1.RegisterDetailServiceServer(m.server, m) // implementa el server gRPC real generado
	go func() { _ = m.server.Serve(ln) }()
	return m, nil
}

func (m *MockServer) GetProductsBySkus(_ context.Context, req *catalogv1.GetProductsBySkusRequest) (*catalogv1.GetProductsResponse, error) {
	skus := strings.Split(req.GetSkus(), ",")
	out := make([]*catalogv1.Product, 0, len(skus))
	for _, sku := range skus {
		if p, ok := m.products[sku]; ok {
			out = append(out, p)
		}
	}
	return &catalogv1.GetProductsResponse{Products: out}, nil
}

La dirección del mock se inyecta como variable de entorno que el Module real del cliente lee al construirse (CATALOG_GRPC_URL en el Paso 2). El cliente concreto (*CatalogClient) disca esa dirección exactamente como discaría a core-catalog en producción — reenvío de metadata de auth incluido. El patrón es idéntico para REST y gRPC: mock real escuchando en una URL/dirección → esa URL entra al grafo como config → el cliente concreto disca el mock.

Dos formas de inyectar la dirección. El ejemplo anterior usa una env var porque así lee el Module del cliente de catalog. Cuando el cliente recibe su dirección como un valor tipado del grafo, la inyección es aún más directa — un fx.Provide. Así lo hace lists_query para discar a lists_core (su servicio downstream en modo split CQRS):

fx.Provide(func() config.CoreAddr { return config.CoreAddr(coreMock.Addr()) }),

El detalle que sostiene la isolación bajo un mock compartido. Un mismo mock gRPC atiende a todos los tests, pero cada test usa un usuario fresco (Paso 6). Para que las respuestas no se pisen, el mock deriva el userID del JWT que el cliente reenvía en la metadata — exactamente como el servicio real deriva la identidad del token validado — y keyea sus fixtures por ese userID:

// tests/platform/listscore/mock.go
func (m *MockServer) GetList(ctx context.Context, req *corev1.GetListRequest) (*corev1.GetListResponse, error) {
	if r, ok := m.details[userIDFromCtx(ctx)+"|"+req.GetId()]; ok {
		return r, nil
	}
	return nil, status.Error(codes.NotFound, "list not found") // el service lo mapea a 404
}

// userIDFromCtx decodifica el subject del JWT desde la metadata de authorization
// reenviada. Sin verificar — es un doble de test que refleja cómo el core real
// deriva el userId del token ya validado.
func userIDFromCtx(ctx context.Context) string {
	md, _ := metadata.FromIncomingContext(ctx)
	auth := md.Get("authorization")
	if len(auth) == 0 {
		return ""
	}
	raw := strings.TrimPrefix(auth[0], "Bearer ")
	var claims jwt.RegisteredClaims
	_, _, _ = jwt.NewParser().ParseUnverified(raw, &claims)
	return claims.Subject
}

Es la pieza que cierra el círculo: mock compartido + usuario fresco por test + reenvío de auth real conviven sin colisiones porque el mock particiona su estado por la misma identidad que el servicio real extrae del token.

Paso 8 — Un test que necesita su propio app

La mayoría de los tests reusan el app compartido por tenant (Paso 3). Pero cuando un test necesita simular un estado distinto de una dependencia siempre-presente —no “la dependencia no está”, sino “la dependencia responde otra cosa”— no se debe contaminar el app compartido: se levanta uno dedicado con t.Cleanup para su propio teardown.

Ejemplo real de engine-promotions, testeando cómo reacciona el cache de BINes de tarjeta a un evento de invalidación cuando el CMS tiene datos específicos:

// tests/promotions_query/nats_consumer_test.go
func TestNATSConsumer_MatchingEvent_ReloadsCache(t *testing.T) {
	bins := []remoteconfig.Bin{{Code: "VISA", Bines: []string{"411111"}}}
	cmsServer := newCMSMockServer(bins)
	defer cmsServer.Close()

	client := newRedisClient(t)
	defer client.Close()
	keys, _ := client.Keys(context.Background(), "bins:*").Result()
	if len(keys) > 0 {
		client.Del(context.Background(), keys...)
	}

	// App dedicado — co-jumbo con BinCacheEnabled y este CMS específico.
	baseURL, keyFunc := appForWithCMS(t, "co-jumbo", cmsServer.URL)
	_ = keyFunc
	_ = baseURL

	nc := connectNATS(t)
	defer nc.Close()

	publishNATSMsg(t, nc, testMatchingEventType)

	// El efecto se verifica por el estado observable resultante (Redis),
	// no espiando el handler — es lo que realmente le importa al sistema.
	require.Eventually(t, func() bool {
		freshClient := newRedisClient(t)
		defer freshClient.Close()
		val, err := freshClient.Get(context.Background(), "bins:411111").Result()
		return err == nil && val == "VISA"
	}, 5*time.Second, 200*time.Millisecond, "cache should be reloaded after matching NATS event")
}

Paso 9 — Borde de eventos: producers y consumers

Alcance v1 cubre la API REST. Pero como el servicio corre completo, sus producers y consumers ejecutan su lógica real durante los tests — y como NATS es un contenedor real, el mismo harness cubre el borde asíncrono. Hay dos formas según de qué lado del evento esté el servicio; en ambas la fórmula (app real + infra real + mock al cable) no cambia, solo la unidad de test pasa de “un archivo por endpoint” a “un archivo por evento/handler”.

9a — Servicio productor: afirmar sobre el mensaje publicado

promotions_ingest (dentro de engine-promotions) no expone REST: solo produce un evento cuando termina un job de ingesta. Su test se conecta directo a NATS y afirma sobre el mensaje publicado, en vez de sobre una respuesta HTTP:

// tests/promotions_ingest/producer_test.go
func TestEventProducer_PublishIngestionFinished(t *testing.T) {
	nc, _ := natsgo.Connect(os.Getenv("NATS_URI"))
	defer nc.Close()

	t.Run("publishes valid CloudEvent with all fields", func(t *testing.T) {
		sub, _ := nc.SubscribeSync("co-jumbo.promotions.ingestor-job.finished")
		defer sub.Unsubscribe()

		producer := nats.NewEventProducer(
			&config.AppConfig{Nats: config.NatsConfig{URI: os.Getenv("NATS_URI")}},
			config.TenantValue("co-jumbo"),
			noopLifecycle{},
		)

		report := data.IngestionReport{
			TotalErrors: 2, TotalPromotionsToIngest: 50, PromotionsActivated: 5,
			Errors: []data.PromotionError{{PromotionID: "promo-1", ErrorMessage: "timeout getting VTEX data"}},
		}
		producer.PublishIngestionFinished(context.Background(), report)

		nmsg, _ := sub.NextMsg(5 * time.Second)

		// Naming de channel/subject real — no un stub.
		if nmsg.Subject != "co-jumbo.promotions.ingestor-job.finished" {
			t.Errorf("expected subject co-jumbo.promotions.ingestor-job.finished, got %s", nmsg.Subject)
		}
		// Envelope CloudEvents 1.0 real, headers incluidos.
		if nmsg.Header.Get("Ce-Type") != "promotions.ingestor-job.finished" {
			t.Errorf("wrong Ce-Type: %s", nmsg.Header.Get("Ce-Type"))
		}

		var event ce.Event
		json.Unmarshal(nmsg.Data, &event)
		var finished consumerFinished
		event.DataAs(&finished)
		if finished.Report.TotalErrors != 2 {
			t.Errorf("TotalErrors: expected 2, got %d", finished.Report.TotalErrors)
		}
	})

	t.Run("graceful degradation — no NATS URI", func(t *testing.T) {
		producer := nats.NewEventProducer(&config.AppConfig{}, config.TenantValue("co-jumbo"), noopLifecycle{})
		if err := producer.PublishIngestionFinished(context.Background(), data.IngestionReport{}); err != nil {
			t.Errorf("expected no error (no-op), got %v", err)
		}
	})
}

Este test verifica tres cosas que un mock de interfaz nunca detectaría: el naming real del canal/subject ({tenant}.{dominio}.{entidad}), el envelope CloudEvents 1.0 completo (headers Ce-*, specversion), y el degradado gracioso cuando NATS no está configurado — las tres son parte del contrato real entre este servicio y sus consumidores.

9b — Servicio consumidor: publicar el evento y afirmar el efecto observable

El caso más interesante es el inverso: un servicio que reacciona a eventos. Acá no se espía el handler ni se mockea el consumer — se publica el evento real y se afirma sobre el estado observable resultante, tal como haría el sistema en producción. lists_query (el lado query del CQRS de core-lists) invalida su cache de Redis cuando llegan eventos de escritura; su consumer_test.go bootea el servicio completo con Redis y NATS reales, puebla el cache por REST, publica un CloudEvent en JetStream, y verifica que la clave se invalidó:

// tests/lists_query/consumer_test.go (resumido)
func TestConsumers(t *testing.T) {
	tenant := "co-jumbo"
	ctx := context.Background()

	// El stream de auth lo posee un servicio externo; el test lo crea porque
	// lists_query asume que ya existe (recrear producción, no aislar).
	nc, _ := natsgo.Connect(os.Getenv("NATS_URI"))
	defer nc.Close()
	jsClient, _ := jetstream.New(nc)
	jsClient.CreateStream(ctx, jetstream.StreamConfig{
		Name:     "CO-JUMBO_AUTH_EVENTS",
		Subjects: []string{"co-jumbo.auth.events.*"},
	})

	// App completo: Redis y NATS reales (containers), core downstream mockeado
	// por dirección (Paso 7b), y fx.Populate para sacar del grafo el cliente
	// Redis y el JetStream y poder afirmar/publicar desde el test.
	var keyFunc *testcognito.KeyFuncMock
	var rdb *goredis.Client
	var js jetstream.JetStream
	app := fx.New(
		fx.NopLogger,
		fx.Provide(func() (config.TenantValue, error) { return config.NewTenant(tenant) }),
		fx.Provide(func() config.CoreAddr { return config.CoreAddr(coreMock.Addr()) }),
		testconfig.Module, testserver.Module(httpLn, grpcLn), testcognito.Module,
		redis.Module, nats.Module, common.Module, health.Module,
		catalog.Module, lists_query.Module,
		fx.Populate(&keyFunc, &rdb, &js),
	)
	// ... app.Start + WaitReady ...

	t.Run("invalidates cache on list.created event", func(t *testing.T) {
		userID, listID := uuid.NewString(), uuid.NewString()
		cacheKey := fmt.Sprintf("%s:lists", userID)

		coreMock.SetList(userID, listID, &corev1.GetListResponse{Id: listID, Name: "Initial List"})

		// 1. Poblar el cache: un GET real lo llena desde el core mock.
		c := helpers.AuthClientAs(t, baseURL, keyFunc, "u", userID)
		c.GET("/query/v1/lists").Expect().Status(http.StatusOK)
		if rdb.Exists(ctx, cacheKey).Val() == 0 {
			t.Fatal("expected cache key to exist after GET")
		}

		// 2. Publicar el evento de dominio real (CloudEvent en JetStream).
		e := ce.NewEvent()
		e.SetType(event.ListCreatedType)
		e.SetSource(event.Source)
		e.SetData(ce.ApplicationJSON, map[string]any{"user_id": userID, "list_id": listID})
		payload, _ := json.Marshal(e)
		js.Publish(ctx, fmt.Sprintf("%s.%s.created", tenant, event.ListChannel), payload)

		// 3. Afirmar el efecto observable: la clave se invalidó (polling async).
		if !waitForKeyDeletion(ctx, rdb, cacheKey) {
			t.Error("timed out waiting for cache invalidation after list.created event")
		}
	})
}

El mismo archivo cubre —con la misma estructura— los eventos de updated/deleted/cleared, los de ítems, los de auth producidos por otro servicio (user_signed_in, segment_token_updated, que invalidan todo el prefijo del usuario), y los casos negativos (un tipo de evento desconocido no debe tocar el cache). Fijate el patrón de aserción async: como la invalidación ocurre en un handler en otra goroutine, no se afirma de inmediato — se hace polling del estado observable (waitForKeyDeletion, o require.Eventually en engine-promotions) hasta un deadline. Nunca un sleep fijo seguido de un assert, salvo para el caso negativo, donde un breve sleep y luego “la clave sigue existiendo” es la única forma de afirmar que algo no pasó.

Estos son exactamente los flujos que la comunicación por eventos (ADR-003) introduce y que un unit test con mocks jamás ejercería de punta a punta: el subject real, el envelope CloudEvents, el durable consumer suscrito de verdad, y el efecto sobre Redis.

Paso 10 — Cómo correr y medir cobertura

make test   # go test -v -race ./tests/...
make pr     # formato + lint + tests con cobertura y reporte

La pieza que hace cierta la promesa “más cobertura con menos tests” es el flag -coverpkg. El binario de test es solo ./tests/... —ahí viven todos los tests— pero -coverpkg le dice a Go que instrumente y acredite la cobertura del código de negocio en ./internal/.... Así es el make pr real de core-lists:

go test -v -race -coverprofile=coverage.out -covermode=atomic \
  -coverpkg=$(go list ./internal/... | grep -v -E 'platform/config|platform/otel|platform/server|platform/postgres' | tr '\n' ',') \
  ./tests/...

Sin ese flag, un test ubicado en tests/ no acreditaría nada de cobertura sobre internal/ (son paquetes distintos). Con él, las component tests miden la cobertura del código real que ejercen a través de la API. La exclusión (grep -v) saca los paquetes de wiring que no son lógica de negocio —listeners, config, otel— para que el número refleje lo que importa; la lista exacta varía por servicio (engine-promotions excluye platform/config|platform/server|platform/nats). El reporte final lo formatea un scripts/coverage-report.sh versionado en cada repo.

ADRs relacionados

  • ADR-000 (Arquitectura en cuatro capas) — los component tests viven fuera de las capas y le pegan a la API del servicio cableado.
  • ADR-001 (API-first con Protocol Buffers) — afirmar sobre el JSON del gateway convierte a estos tests en verificación del contrato generado.
  • ADR-002 (Modelo multi-tenant con FX) — el loop sobre todos los tenants valida cada uno.
  • ADR-003 (Comunicación orientada a eventos) — define el borde de eventos (producer y consumer) que cubre el Paso 9.
  • ADR-004 (Cliente REST externo con resty) — los clientes externos se mockean con httptest inyectado en el grafo.

Este es un ADR de plataforma (no de un microservicio puntual): aplica igual a todos los servicios White Label. Los ejemplos de este tutorial son de dc-wl-core-lists (referencia canónica) y dc-wl-groceries-core-engine-promotions (variantes REST/gRPC/eventos); cualquier microservicio nuevo debería replicar exactamente esta mecánica.