Módulo 7 · Lección 14

Tests en Go: del dominio a un Keycloak real

Hasta ahora has probado la tienda a mano y con tools/comprobar. Aquí escribes los tests de api-pedidos, aprovechando la arquitectura de la lección 13: la mayoría corren en milisegundos y sin Keycloak, y uno arranca un Keycloak de verdad para comprobar que todo encaja.

≈ 60 min Carpeta: tienda/pasos/paso-14 testcontainers-go v0.44

En este capítulo

Trabajas en
Los tests de api-pedidos. El código de la aplicación no cambia.
Carpeta
tienda/pasos/paso-14 · mismo realm que el paso 12
Archivos
Nuevos: internal/pedidos/servicio_test.go, internal/adaptadores/rest/rest_test.go, internal/apiauth/apiauth_test.go, internal/adaptadores/keycloak/keycloak_test.go, internal/integracion/keycloak_test.go; go.mod suma testcontainers-go · ver todos los cambios del paso.
En marcha
Nada para los tests normales. Docker para el de integración (arranca su propio Keycloak).
Comprueba
go test ./... y go test -tags integracion ./internal/integracion (desde tienda/pasos/paso-14)

Al terminar sabrás

  • Probar reglas de negocio y permisos sin tokens ni HTTP.
  • Probar handlers con un doble de la identidad y proteger el formato JSON.
  • Probar la validación de JWT de verdad (firma, iss, aud, exp, typ) con un emisor falso hecho con httptest.
  • Arrancar Keycloak desde un test con testcontainers-go y probar con tokens reales.

1. Cuatro niveles

NivelQué pruebaNecesitaTarda (real)
DominioReglas y permisos de pedidos.ServiceNada≈ 5 ms
Adaptador RESTRutas, códigos de estado, formato JSONUn doble de Identity≈ 7 ms
Validación de JWTEl middleware de apiauth y la traducción a permisosUn emisor falso (httptest)≈ 0,1 s
IntegraciónQue tokens reales de Keycloak 26.8 y nuestra API encajanDocker≈ 35–55 s

La regla práctica: muchos tests rápidos abajo y pocos lentos arriba. El de integración no comprueba reglas (eso ya está cubierto); comprueba que las piezas reales encajan.

2. El dominio, sin nada más

Como el dominio recibe un Actor y no un token, un test solo tiene que crear actores. Los tests van en el paquete pedidos_test para poder usar el repositorio en memoria sin crear un ciclo de imports (lección 13):

func TestGet(t *testing.T) {
	tests := []struct {
		name    string
		actor   pedidos.Actor
		id      int
		wantErr error
	}{
		{"su propio pedido", ana, pedidoDe["ana"], nil},
		{"el de otro cliente parece no existir", ana, pedidoDe["carlos"], pedidos.ErrNotFound},
		{"quien gestiona ve cualquiera", carlos, pedidoDe["ana"], nil},
		{"un número que no existe", carlos, 9999, pedidos.ErrNotFound},
		{"sin permisos", anonimo, pedidoDe["ana"], pedidos.ErrForbidden},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			_, err := newService().Get(context.Background(), tt.actor, tt.id)
			if !errors.Is(err, tt.wantErr) {
				t.Errorf("err = %v, want %v", err, tt.wantErr)
			}
		})
	}
}
go test -v -run TestGet ./internal/pedidos
# --- PASS: TestGet (0.00s)
#     --- PASS: TestGet/su_propio_pedido (0.00s)
#     --- PASS: TestGet/el_de_otro_cliente_parece_no_existir (0.00s)
#     --- PASS: TestGet/quien_gestiona_ve_cualquiera (0.00s)
#     --- PASS: TestGet/un_número_que_no_existe (0.00s)
#     --- PASS: TestGet/sin_permisos (0.00s)

La regla de seguridad más importante de la API («el pedido de otro cliente parece no existir») queda probada en una línea de tabla. Antes de la lección 13, este test habría necesitado un JWT firmado.

3. La API REST con un doble

El puerto rest.Identity permite sustituir a Keycloak por un doble que decide quién llama y con qué scopes:

type fakeIdentity struct {
	actor  *pedidos.Actor
	scopes []string
}

func (f fakeIdentity) Middleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if f.actor == nil {
			http.Error(w, "sin token", http.StatusUnauthorized)
			return
		}
		next.ServeHTTP(w, r)
	})
}
// … RequireScope y Actor, igual de sencillos
func TestCodigosDeEstado(t *testing.T) {
	tests := []struct {
		name         string
		id           fakeIdentity
		method, path string
		body         string
		want         int
	}{
		{"sin identidad", fakeIdentity{}, "GET", "/pedidos", "", 401},
		{"mis pedidos", fakeIdentity{actor: ana}, "GET", "/pedidos", "", 200},
		{"pedido de otro → 404", fakeIdentity{actor: ana}, "GET", "/pedidos/1003", "", 404},
		{"id que no es número", fakeIdentity{actor: ana}, "GET", "/pedidos/abc", "", 400},
		{"crear sin scope", fakeIdentity{actor: ana}, "POST", "/pedidos", `{"producto":"taza","cantidad":1}`, 403},
		{"crear con scope", fakeIdentity{actor: ana, scopes: []string{"pedidos:escribir"}}, "POST", "/pedidos", `{"producto":"taza","cantidad":1}`, 201},
		{"crear con datos inválidos", fakeIdentity{actor: ana, scopes: []string{"pedidos:escribir"}}, "POST", "/pedidos", `{"producto":"yate","cantidad":1}`, 400},
		{"admin sin permiso de dominio", fakeIdentity{actor: ana}, "GET", "/admin/pedidos", "", 403},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if rec := do(tt.id, tt.method, tt.path, tt.body); rec.Code != tt.want {
				t.Errorf("%s %s = %d, want %d (%s)", tt.method, tt.path, rec.Code, tt.want, rec.Body)
			}
		})
	}
}

Y un test pequeño protege un contrato fácil de romper sin darse cuenta: el JSON que leen tienda-web y facturacion.

// El formato JSON es un contrato con tienda-web y facturacion: si cambia un
// nombre de campo, este test avisa.
func TestFormatoJSON(t *testing.T) {
	rec := do(fakeIdentity{actor: ana}, "GET", "/pedidos/1001", "")
	var got map[string]any
	if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
		t.Fatal(err)
	}
	for _, k := range []string{"id", "owner", "items", "total", "status", "created_at"} {
		if _, ok := got[k]; !ok {
			t.Errorf("falta el campo %q en %s", k, rec.Body)
		}
	}
}

4. Validar JWT sin Keycloak: un emisor falso

El middleware de apiauth (lección 5) es código de seguridad: merece tests que firmen tokens de verdad. Para eso basta un «Keycloak» mínimo hecho con httptest: publica el documento de descubrimiento y el JWKS, y firma con su propia clave usando go-jose (la librería que ya usa go-oidc por dentro).

func newFakeIssuer(t *testing.T) *fakeIssuer {
	t.Helper()
	key, err := rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		t.Fatal(err)
	}
	f := &fakeIssuer{key: key}
	mux := http.NewServeMux()
	mux.HandleFunc("GET /.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
		json.NewEncoder(w).Encode(map[string]any{
			"issuer":                                f.srv.URL,
			"jwks_uri":                              f.srv.URL + "/certs",
			"authorization_endpoint":                f.srv.URL + "/auth",
			"token_endpoint":                        f.srv.URL + "/token",
			"id_token_signing_alg_values_supported": []string{"RS256"},
		})
	})
	mux.HandleFunc("GET /certs", func(w http.ResponseWriter, r *http.Request) {
		json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{
			{Key: &key.PublicKey, KeyID: "clave-1", Algorithm: "RS256", Use: "sig"},
		}})
	})
	f.srv = httptest.NewServer(mux)
	t.Cleanup(f.srv.Close)
	return f
}

Cada caso altera un claim, o la clave, y espera un 401. Son exactamente las comprobaciones que explicaba la lección 5:

{"token válido", sign(t, f.key, f.claims()), 200, ""},
{"sin token", "", 401, "falta la cabecera"},
{"caducado", sign(t, f.key, with("exp", time.Now().Add(-time.Minute).Unix())), 401, "caducado"},
{"para otra API", sign(t, f.key, with("aud", "otra-api")), 401, "inválido"},
{"de otro issuer", sign(t, f.key, with("iss", "http://otro")), 401, "inválido"},
{"un ID token, no un access token", sign(t, f.key, with("typ", "ID")), 401, "inválido"},
{"firmado con otra clave", sign(t, otherKey, f.claims()), 401, "inválido"},
go test -v -run TestMiddleware ./internal/apiauth
# --- PASS: TestMiddleware (0.09s)
#     --- PASS: TestMiddleware/token_válido (0.00s)
#     --- PASS: TestMiddleware/sin_token (0.00s)
#     --- PASS: TestMiddleware/caducado (0.00s)
#     --- PASS: TestMiddleware/para_otra_API (0.00s)
#     --- PASS: TestMiddleware/de_otro_issuer (0.00s)
#     --- PASS: TestMiddleware/un_ID_token,_no_un_access_token (0.00s)
#     --- PASS: TestMiddleware/firmado_con_otra_clave (0.00s)
Pruébalo: rompe la validación

Borra en apiauth.go la comprobación c.Typ != "Bearer" y vuelve a ejecutar: falla «un ID token, no un access token». Un test de seguridad que nunca has visto fallar no demuestra nada.

La traducción de roles a permisos (keycloak.ActorFrom) es una tabla y se prueba como tal, sin tokens: ver internal/adaptadores/keycloak/keycloak_test.go.

5. Con un Keycloak de verdad: testcontainers-go

El último nivel arranca Keycloak 26.8 en un contenedor desde el propio test, con el realm del paso, en un puerto libre al azar (así no choca con el Keycloak que ya tengas en el 8080). Luego monta api-pedidos completa con httptest y la llama con tokens reales:

func TestAPIConKeycloakReal(t *testing.T) {
	ctx := context.Background()
	kc, err := testcontainers.Run(ctx, "quay.io/keycloak/keycloak:26.8.0",
		testcontainers.WithCmd("start-dev", "--import-realm"),
		testcontainers.WithEnv(map[string]string{
			"KC_BOOTSTRAP_ADMIN_USERNAME": "admin",
			"KC_BOOTSTRAP_ADMIN_PASSWORD": "admin",
		}),
		testcontainers.WithFiles(testcontainers.ContainerFile{
			HostFilePath:      "../../infra/realm/tienda-realm.json",
			ContainerFilePath: "/opt/keycloak/data/import/tienda-realm.json",
			FileMode:          0o644,
		}),
		testcontainers.WithExposedPorts("8080/tcp"),
		testcontainers.WithWaitStrategyAndDeadline(3*time.Minute,
			wait.ForHTTP("/realms/tienda").WithPort("8080/tcp")),
	)
	testcontainers.CleanupContainer(t, kc)
	if err != nil {
		t.Fatal(err)
	}
	endpoint, err := kc.PortEndpoint(ctx, "8080/tcp", "http")
	if err != nil {
		t.Fatal(err)
	}
	issuer := endpoint + "/realms/tienda"

	// api-pedidos completa, con sus adaptadores reales, en un servidor de test.
	v, err := apiauth.NewVerifier(ctx, issuer, "api-pedidos")
	if err != nil {
		t.Fatal(err)
	}
	mux := http.NewServeMux()
	rest.Register(mux, pedidos.NewService(memoria.NewWithSamples()), keycloak.Identity{Verifier: v})
	api := httptest.NewServer(mux)
	defer api.Close()

	// facturacion pide un token (Client Credentials) y lista lo entregado.
	tok := clientCredentials(t, issuer, "facturacion", "facturacion-secret")
	if code, body := get(t, api.URL+"/facturacion/pedidos", tok); code != 200 || !strings.Contains(body, `"id":1003`) {
		t.Errorf("facturacion: %d %s", code, body)
	}
	// El token de admin-tool es válido, pero no es para api-pedidos (aud).
	other := clientCredentials(t, issuer, "admin-tool", "admin-tool-secret")
	if code, _ := get(t, api.URL+"/facturacion/pedidos", other); code != 401 {
		t.Errorf("token de otra audiencia: %d, want 401", code)
	}
}
go test -tags integracion ./internal/integracion
# ok  	tienda/internal/integracion	34.616s

Lleva la etiqueta de compilación integracion (//go:build integracion): un go test ./... normal no lo compila ni necesita Docker. En el log verás token rechazado: oidc: expected audience "api-pedidos" got ["realm-management" "account"]: es el caso del token de admin-tool, que el test espera rechazado.

Que el realm de los tests sea el de verdad

El test importa infra/realm/tienda-realm.json, el mismo que usas en desarrollo. Si alguien quita el rol facturar o el mapper de audiencia, este test falla. Es la red de seguridad de la configuración de Keycloak, no solo del código.

6. En la verificación del curso

El curso se verifica a sí mismo con tools/ci (puedes ejecutarlo con act en un contenedor, sin GitHub; ver tools/ci/README.md). Para cada paso que tiene tests, ejecuta go test ./... y, si existe, el test de integración. Un consejo para tus proyectos: añade -race en la integración continua; aquí no cambia nada porque el almacén en memoria ya usa un mutex, pero detecta pronto el día que alguien lo olvide.

Ejercicios

1. Primero el test: un pedido cancelado es definitivo · fácil

La regla (a) del ejercicio 2 de la lección 13. Escribe primero el test, comprueba que falla y luego cambia el dominio.

Ver solución
func TestCanceladoEsDefinitivo(t *testing.T) {
	ctx := context.Background()
	s := newService()
	if _, err := s.SetStatus(ctx, carlos, 1002, "Cancelado"); err != nil {
		t.Fatal(err)
	}
	_, err := s.SetStatus(ctx, carlos, 1002, "Enviado")
	if !errors.Is(err, pedidos.ErrInvalid) {
		t.Errorf("reabrir un pedido cancelado: err = %v, want ErrInvalid", err)
	}
}

Antes de cambiar el código, salida real: reabrir un pedido cancelado: err = <nil>, want ErrInvalid. Después, en SetStatus, tras leer el pedido:

if o.Status == "Cancelado" && status != "Cancelado" {
	return Order{}, fmt.Errorf("%w: un pedido cancelado no cambia de estado", ErrInvalid)
}

No hace falta tocar la API: ErrInvalid ya se traduce a 400.

2. Lo que puede (y no) un servicio · fácil

Con el doble de identidad, prueba que un actor con solo PermInvoice (como facturacion) obtiene 200 en /facturacion/pedidos y 403 en /pedidos y en /admin/pedidos.

Ver solución
func TestFacturacionSoloListaParaFacturar(t *testing.T) {
	id := fakeIdentity{actor: &pedidos.Actor{ID: "service-account-facturacion", Permissions: []pedidos.Permission{pedidos.PermInvoice}}}
	for path, want := range map[string]int{"/facturacion/pedidos": 200, "/pedidos": 403, "/admin/pedidos": 403} {
		if got := do(id, "GET", path, "").Code; got != want {
			t.Errorf("GET %s = %d, want %d", path, got, want)
		}
	}
}

Probado: pasa.

3. Más casos con el Keycloak real · fácil

Añade al test de integración que el token real de facturacion recibe 403 en /admin/pedidos.

Ver solución
if code, _ := get(t, api.URL+"/admin/pedidos", tok); code != 403 {
	t.Errorf("facturacion en /admin/pedidos: %d, want 403", code)
}

Probado: pasa (31 s). Fíjate en que es un 403 y no un 401: el token es válido y para esta API, pero el dominio no le da permiso de gestionar.

Errores comunes

Cannot connect to the Docker daemon en el test de integración

Docker no está arrancado o tu usuario no puede usarlo. Los demás tests no lo necesitan: por eso el de integración va con su propia etiqueta.

oidc: issuer did not match en el test de integración

Keycloak pone en iss la URL con la que le llamaste. El test construye el issuer a partir de PortEndpoint y pide los tokens a esa misma URL: si mezclas localhost y 127.0.0.1, no coinciden.

el test de JWT pasa aunque rompas la validación

Revisa que cada caso cambie solo un claim respecto al token válido (la función with) y que compruebe el código y el mensaje. Un caso que falla por otra razón (por ejemplo, firma) no prueba la comprobación que crees.

Resumen del módulo 7

La arquitectura hexagonal (lección 13) separó reglas, HTTP e identidad; aquí lo has aprovechado: reglas probadas sin nada, la API con un doble, la validación de JWT con un emisor falso que firma de verdad, y un único test lento que comprueba con un Keycloak real que todo encaja, usando el mismo realm que en desarrollo.