CodeGym /Cursos /Go SELF /Dobles de prueba de dependencias para tests de API

Dobles de prueba de dependencias para tests de API

Go SELF
Nivel 64, Lección 2
Disponible

1. Por que sustituir dependencias en los tests de handlers

Cuando acabas de empezar a escribir un servidor, apetece mucho hacer que el handler sea «autosuficiente»: parsea el JSON, escribe en map y genera el error. Es rapido, pero dura poco. En cuanto aparece al menos un escenario no trivial (validacion, busqueda, distintos errores), el handler se convierte en una «cosechadora» y resulta dificil de probar, porque estas probando todo a la vez: HTTP, logica de negocio y almacenamiento.

Una arquitectura razonable hace justo lo contrario: el handler debe ser fino. Recibe la solicitud HTTP, extrae los datos, invoca una dependencia (servicio/almacenamiento) y luego traduce el resultado a una respuesta HTTP (estado, cabeceras, JSON).

Y aqui surge la pregunta: ¿como probar exactamente el contrato HTTP sin arrastrar «todo el universo» de dependencias?

La respuesta son los dobles de prueba (test doubles): stub, fake, mock. Sustituyen al servicio/almacenamiento real y dan al test control total sobre lo que el handler recibira como entrada y lo que devolvera hacia fuera.

Interfaz minima de la dependencia

Para poder sustituir una dependencia, el handler debe depender no de un «tipo concreto», sino de un «contrato». En Go ese contrato suele expresarse mediante una interfaz.

Es importante no caer en el extremo opuesto y no crear una interfaz de 25 metodos «por si acaso», porque entonces sufrirás al escribir fakes. Haz la interfaz exactamente para las necesidades del handler concreto (o de un pequeño conjunto de handlers).

Supongamos que tenemos una API educativa de tareas (to‑do) y queremos probar el endpoint de obtencion de una tarea por id.

Aqui estan los tipos de dominio minimos y la interfaz del servicio:


package taskapi

import "context"

type Task struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

type TaskService interface {
	GetByID(ctx context.Context, id int) (Task, error)
}

El handler guarda TaskService dentro de la estructura: este es nuestro «gancho» para sustituirlo en los tests:

package taskapi

import "net/http"

type Handler struct {
	svc TaskService
}

func NewHandler(svc TaskService) *Handler {
	return &Handler{svc: svc}
}

Ahora, para probar la capa HTTP, no necesitamos una base de datos real. Basta con proporcionar una implementacion de TaskService que se comporte como el test necesita.

2. Stub, fake y mock: diferencia y eleccion de la herramienta

Las palabras «mock» y «fake» se usan a menudo como sinonimos, pero es mas util distinguirlas por su sentido. Esto ayuda a elegir la herramienta en lugar de discutir en una reunion si «en nuestro proyecto los mocks estan prohibidos».

A continuacion tienes una tabla compacta que conviene tener en mente:

Doble Idea principal Que comprobamos Cuando usarlo
Stub «Devuelve una respuesta predefinida» El comportamiento del handler con distintos resultados de la dependencia Cuando necesitas cambiar de rama: ok/not_found/validation/internal
Fake «Implementacion simplificada que funciona» Escenarios mas largos, cercanos a la realidad, pero sin recursos externos Cuando quieres probar el encadenamiento de varias llamadas sin BD
Mock «Recuerda como te llamaron» La interaccion: argumentos, numero de llamadas, orden Cuando es importante demostrar que el handler paso los parametros correctos

La buena noticia es que, la mayoria de las veces, te bastara con un stub. El mock es la herramienta mas «ruidosa»: es util, pero convierte el test con facilidad en contabilidad («cuantas veces llamo uno a otro»). El fake es un excelente compromiso cuando quieres un comportamiento realista, pero sin infraestructura.

Como elegir sin fanatismo

Si tu objetivo es comprobar que el handler mapea correctamente los errores del servicio al contrato HTTP, el stub es casi siempre la mejor opcion. Es corto, directo y hace que el test vaya «sobre el contrato».

Si tu objetivo es probar un escenario largo con datos, pero sin infraestructura (sin BD ni migraciones), el fake gana: proporciona un comportamiento «casi real», pero controlado en memoria.

Si tu objetivo es comprobar la interaccion (por ejemplo, el id correcto, o que el metodo se llamo exactamente una vez), el mock es apropiado. Pero conviene dosificarlo, porque de lo contrario empezarás a probar «como esta escrito el codigo» y no «que hace». Y ese es el camino hacia tests fragiles: cambiaste la implementacion, no el comportamiento, y los tests cayeron.

3. Stub: ramas y mapeo de errores al contrato HTTP

El stub es tu mando para «cambiar la realidad». No modelamos el almacenamiento real; simplemente decimos: «el servicio devolvera este valor y este error». En los tests de la capa HTTP, el stub es especialmente bueno, porque la capa HTTP se dedica sobre todo a traducir distintos resultados a estados y al error envelope.

Errores de dominio como base del mapeo

Supongamos que el servicio devuelve ErrNotFound si la tarea no existe:

package taskapi

import "errors"

var ErrNotFound = errors.New("not found")

La implementacion stub del servicio puede tener este aspecto:

package taskapi

import "context"

type stubTaskService struct {
	task Task
	err  error
}

func (s stubTaskService) GetByID(ctx context.Context, id int) (Task, error) {
	return s.task, s.err
}

Ahora el test puede «activar» la rama not found:

package taskapi_test

import (
	"net/http"
	"net/http/httptest"
	"testing"

	"example.com/taskapi"
)

func TestGetTask_NotFound(t *testing.T) {
	h := taskapi.NewHandler(stubTaskService{err: taskapi.ErrNotFound})

	rr := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/10", nil)

	h.ServeHTTP(rr, req)

	if rr.Code != http.StatusNotFound {
		t.Fatalf("status=%d, want %d", rr.Code, http.StatusNotFound)
	}
}

Aqui hay un punto metodologico importante: un test sobre el contrato HTTP no tiene por que saber como busca exactamente la tarea el servicio. Lo unico importante es que not found se mapea a 404. Si ademas compruebas el error envelope, que es lo correcto, entonces anadiras la decodificacion de JSON y la comparacion de code/message.

Mapeo de errores y errors.Is/As

Normalmente el handler traduce los errores del servicio a una respuesta HTTP. Y si has diseñado los errores con cuidado, esto se hace de forma estable y predecible.

En Go es importante recordar la idea «errors are values»: los errores no solo se pueden imprimir, tambien se pueden analizar y comparar. Y si los errores se envuelven mediante %w, entonces errors.Is y errors.As permiten entender «que ocurrio en realidad» sin depender del texto.

Un patron practico para la capa HTTP tiene este aspecto:

  • el servicio devuelve el error de dominio ErrNotFound → el handler responde 404 + {code:"not_found" ...}
  • el servicio devuelve un error de validacion de dominio (por ejemplo, ValidationError) → el handler responde 400 + {code:"validation", fields:{...}}
  • el servicio devuelve cualquier otro error → el handler responde 500 + {code:"internal", message:"internal error"} y no «expone» err.Error()

Para probar esto, el stub es ideal: te permite «darle de comer» al handler cualquier error.

Por ejemplo, un error de validacion como tipo:

package taskapi

type ValidationError struct {
	Fields map[string]string
}

func (e ValidationError) Error() string {
	return "validation failed"
}

Despues, en el test, le pasas ValidationError{Fields: map[string]string{"id":"must be integer"}} y compruebas que el error envelope contiene fields. (Tecnicamente, una validacion asi suele pertenecer mas a parseID en la capa HTTP, pero como ejemplo de la mecanica es excelente: aprendes a probar el mapeo).

Ademas, errors.Is/As se vuelven especialmente utiles si el servicio devuelve un error envuelto, por ejemplo fmt.Errorf("storage: %w", ErrNotFound). Entonces el handler no tiene por que comparar err == ErrNotFound; puede hacer errors.Is(err, ErrNotFound) y ser resistente a que se anada mas contexto.

4. Fake: implementacion en memoria sin base de datos

El fake es util cuando una simple respuesta «de pega» no basta. Por ejemplo, quieres probar el escenario «se creo una tarea → despues se solicito por id», pero aun no quieres levantar una BD real. Y eso es razonable: los unit tests deben ser rapidos y simples.

Hagamos un fake-storage minimo en memoria. Guardara las tareas en map[int]Task.

package taskapi

import "context"

type fakeTaskService struct {
	data map[int]Task
}

func newFakeTaskService() *fakeTaskService {
	return &fakeTaskService{data: make(map[int]Task)}
}

func (f *fakeTaskService) GetByID(ctx context.Context, id int) (Task, error) {
	task, ok := f.data[id]
	if !ok {
		return Task{}, ErrNotFound
	}
	return task, nil
}

Ese fake se puede «configurar» directamente en el test, simplemente poniendo datos en map. No es la abstraccion perfecta, pero es barata y clara: si lo rellenas, existe; si no lo rellenas, no existe.

func TestGetTask_OK(t *testing.T) {
	fake := newFakeTaskService()
	fake.data[7] = taskapi.Task{ID: 7, Title: "learn httptest", Done: false}

	h := taskapi.NewHandler(fake)

	rr := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/7", nil)

	h.ServeHTTP(rr, req)

	if rr.Code != http.StatusOK {
		t.Fatalf("status=%d, want %d", rr.Code, http.StatusOK)
	}
}

Si, esto ya se parece un poco a un escenario de integracion, pero sigue siendo sin red, sin BD, sin migraciones, sin el clasico «por que postgres no se levanto en CI». Este tipo de fake es especialmente bueno si tienes varios handlers y quieres probar su comportamiento con datos «vivos», pero sin infraestructura.

5. Mock: comprobamos como el handler llama a la dependencia

El mock se necesita con menos frecuencia, pero es insustituible cuando el objetivo del test es demostrar la correccion de la interaccion. Por ejemplo, quieres comprobar que el handler realmente parseo {id} y lo paso al servicio, y no que por accidente siempre pasa 0.

Hagamos un mock que recuerde con que id fue llamado:

package taskapi

import "context"

type mockTaskService struct {
	gotID int
	calls int
}

func (m *mockTaskService) GetByID(ctx context.Context, id int) (Task, error) {
	m.gotID = id
	m.calls++
	return Task{ID: id, Title: "x"}, nil
}

Test:

func TestGetTask_PassesIDToService(t *testing.T) {
	mock := &mockTaskService{}
	h := taskapi.NewHandler(mock)

	rr := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/42", nil)

	h.ServeHTTP(rr, req)

	if mock.calls != 1 {
		t.Fatalf("calls=%d, want 1", mock.calls)
	}
	if mock.gotID != 42 {
		t.Fatalf("gotID=%d, want 42", mock.gotID)
	}
}

Fijate en el matiz: este test no va sobre el cuerpo de la respuesta, ni sobre el JSON, ni sobre las cabeceras. Va sobre que el handler ha «pasado correctamente los datos» hacia dentro. En cuanto empiezas a comprobar en un mismo test tanto gotID como la respuesta JSON y el middleware, el test acaba siendo «sobre todo» y se rompe con cualquier pequeño cambio. Por eso es mejor que los tests con mocks sean puntuales.

6. Helpers para tests: para que el codigo no se convierta en un lio

Cuando empiezas a usar dobles de forma activa, existe el riesgo de que los tests se conviertan en un conjunto de «si no es igual, caer». Las comprobaciones en si son normales, pero la legibilidad cae rapido.

Por eso, en los tests es util tener pequenas funciones helper con t.Helper(): asi, cuando falle el test, Go mostrara la linea de llamada al helper y no su implementacion interna.

Empecemos por la decodificacion de JSON:

func mustDecodeJSON(t *testing.T, rr *httptest.ResponseRecorder, dst any) {
	t.Helper()

	if err := json.NewDecoder(rr.Body).Decode(dst); err != nil {
		t.Fatalf("decode json: %v", err)
	}
}

Ahora el test que comprueba el error envelope es mas corto y legible, porque en el queda el sentido y no el «ruido tecnico».

Si pruebas mediante httptest.NewServer, resulta util un helper para cerrar resp.Body (si, normalmente basta con defer resp.Body.Close(), pero el helper disciplina y hace visibles los errores al cerrar):

func mustClose(t *testing.T, c io.Closer) {
	t.Helper()

	if err := c.Close(); err != nil {
		t.Fatalf("close: %v", err)
	}
}

Y un truco mas importante: no intentes reescribir en cada test un setup complejo de mocks. Es mejor mantener los dobles simples y la configuracion cerca del test. Cuando ves al principio del test stubTaskService{err: ErrNotFound}, el cerebro entiende enseguida el escenario. Cuando ves «un builder de mocks con cinco metodos y una configuracion», el cerebro empieza a irse silenciosamente de vacaciones.

7. Errores tipicos al usar fakes y mocks

Error 1: interfaces enormes «para toda la aplicacion».
Cuando una interfaz incluye decenas de metodos, cualquier fake/mock se vuelve caro: tienes que implementar todo, aunque el test solo necesite un metodo. Esto lleva o bien al copia y pega, o bien a stubs vacios que luego nadie actualiza. Es mejor mantener la interfaz minima, para un handler concreto o un use case estrecho.

Error 2: los tests con mocks comprueban las tripas en lugar del contrato.
Si el test falla porque al servicio se le llamo «dos veces en vez de una», pero el comportamiento externo (la respuesta HTTP) sigue siendo correcto, eso suele indicar que el test esta atado a la implementacion. Usa mocks donde la interaccion sea de verdad parte de los requisitos, y no simplemente porque «es interesante verlo».

Error 3: el fake empieza a repetir la implementacion real y se convierte en «un segundo sistema».
El fake debe ser simple. En cuanto empiezas a anadirle transacciones, indices complejos u «optimizacion de busqueda», sin darte cuenta estas escribiendo una mini-BD. En ese momento es mas facil pasar a infraestructura real en tests de integracion o simplificar el escenario del test.

Error 4: en los tests comparan JSON como cadena y luego culpan a Go por el «orden inestable de los campos».
El orden de los campos puede cambiar, el formato puede cambiar, los espacios pueden cambiar. Comprueba el JSON de forma estructural: decodificalo a struct/map y compara los valores de los campos.

Error 5: «internal error» empieza accidentalmente a filtrarse hacia fuera con detalles.
Es un bug muy frecuente: alguien decide «mejorar el diagnostico» y devuelve al cliente err.Error() en una respuesta 500. Los tests con stub que esperan un mensaje estable sirven precisamente para detectar ese cambio al instante. En Go envolver errores es util, pero es importante recordar que el wrapping hace que los detalles sean accesibles para su analisis, y eso a veces acaba formando parte de la API.

1
Tarea
Go SELF, nivel 64, lección 2
Bloqueada
Stub de saludo
Stub de saludo
1
Tarea
Go SELF, nivel 64, lección 2
Bloqueada
Stubs para errores
Stubs para errores
1
Tarea
Go SELF, nivel 64, lección 2
Bloqueada
Tareas ficticias
Tareas ficticias
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION