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.
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.modsuma 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 ./...ygo test -tags integracion ./internal/integracion(desdetienda/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 conhttptest. - Arrancar Keycloak desde un test con testcontainers-go y probar con tokens reales.
1. Cuatro niveles
| Nivel | Qué prueba | Necesita | Tarda (real) |
|---|---|---|---|
| Dominio | Reglas y permisos de pedidos.Service | Nada | ≈ 5 ms |
| Adaptador REST | Rutas, códigos de estado, formato JSON | Un doble de Identity | ≈ 7 ms |
| Validación de JWT | El middleware de apiauth y la traducción a permisos | Un emisor falso (httptest) | ≈ 0,1 s |
| Integración | Que tokens reales de Keycloak 26.8 y nuestra API encajan | Docker | ≈ 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)
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.
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.
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.