Guía práctica
El mock que hace fallar una refactorización correcta
Un mock exige Find seguido de Save para cancelar una reserva. Una implementación transaccional conserva el comportamiento, pero el test falla porque protegía el recorrido interno.
Un servicio cancela una reserva cargándola, cambiando su estado y guardándola:
type repository interface {
Find(ctx context.Context, id BookingID) (Booking, error)
Save(ctx context.Context, booking Booking) error
}
type Service struct {
bookings repository
}
func (s *Service) Cancel(ctx context.Context, id BookingID) error {
booking, err := s.bookings.Find(ctx, id)
if err != nil {
return err
}
if err := booking.Cancel(); err != nil {
return err
}
return s.bookings.Save(ctx, booking)
}El test utiliza un mock para describir ese recorrido:
func TestCancel(t *testing.T) {
repo := newMockRepository(t)
booking := ConfirmedBooking("booking-42")
repo.ExpectFind("booking-42").Return(booking, nil)
repo.ExpectSave(booking.WithStatus(Cancelled)).Return(nil)
service := NewService(repo)
if err := service.Cancel(context.Background(), "booking-42"); err != nil {
t.Fatalf("cancel: %v", err)
}
repo.VerifyExpectations()
}El caso pasa y parece preciso. También ha convertido dos decisiones internas en requisitos: cancelar debe llamar a Find y después a Save.
El consumidor que pidió cancelar nunca hizo esas dos promesas.
Una implementación atómica rompe el test, no el contrato#
En producción, el recorrido de lectura y escritura permite que otro proceso modifique la reserva entre ambas operaciones. El repositorio puede ofrecer una transición atómica:
type repository interface {
Cancel(ctx context.Context, id BookingID) error
}
func (s *Service) Cancel(ctx context.Context, id BookingID) error {
return s.bookings.Cancel(ctx, id)
}El adaptador SQL puede aplicar la transición con una sentencia condicional:
UPDATE bookings
SET status = 'cancelled'
WHERE id = $1 AND status = 'confirmed';Desde fuera puede conservar exactamente los mismos resultados:
- una reserva confirmada termina cancelada;
- una reserva inexistente produce
ErrBookingNotFound; - una reserva que ya no se puede cancelar produce
ErrCannotCancel.
Si el contrato distingue los dos últimos casos, el adaptador puede traducir las filas afectadas y consultar el motivo dentro de la misma transacción. Esa decisión no vuelve a exponer Find y Save al servicio.
Sin embargo, el test anterior ni siquiera compila. No hay Find ni Save que configurar.
Eso no demuestra que la refactorización sea incorrecta. Demuestra que el caso confundió el mecanismo elegido con el comportamiento prometido.
Prueba la transición que observa el consumidor#
Un caso escrito desde fuera prepara un estado, envía el mensaje y consulta el resultado:
func TestCancelChangesConfirmedBooking(t *testing.T) {
app := newTestApp(t)
app.bookings.givenConfirmed("booking-42")
err := app.cancellations.Cancel(
context.Background(),
"booking-42",
)
if err != nil {
t.Fatalf("cancel: %v", err)
}
if got := app.bookings.statusOf("booking-42"); got != Cancelled {
t.Fatalf("status = %q, want %q", got, Cancelled)
}
}newTestApp puede usar un adaptador en memoria o una base de datos efímera. El detalle importa menos que el punto de observación: el cuerpo del test habla en términos de reservas confirmadas, cancelación y estado final.
Al sustituir Find más Save por Cancel, quizá haya que adaptar el fixture. La afirmación no cambia. Sigue rechazando una implementación que devuelve nil sin cancelar y acepta tanto una transición en memoria como un UPDATE atómico.
Una prueba que sobrevive sin cambios mágicos no es el objetivo. El objetivo es que falle cuando cambia una promesa y no solo cuando cambia el camino utilizado para cumplirla.
Un fake no es correcto por llamarse fake#
Cambiar el framework de mocking por una estructura manual no resuelve el acoplamiento si el test continúa registrando llamadas:
type fakeRepository struct {
calls []string
}
func (f *fakeRepository) Find(...) (...) {
f.calls = append(f.calls, "Find")
// ...
}
func (f *fakeRepository) Save(...) error {
f.calls = append(f.calls, "Save")
// ...
}Una aserción sobre []string{"Find", "Save"} congela el mismo recorrido con menos biblioteca.
Un fake resulta útil cuando implementa una semántica observable. Para este caso mantiene reservas y aplica las mismas reglas relevantes: no puede cancelar una reserva ausente y una cancelación exitosa cambia el estado. El test consulta ese estado, no el historial privado de métodos usados para producirlo.
Algunas interacciones sí son la promesa#
No toda expectativa de llamadas es un detalle interno. Si el contrato dice que una cancelación exitosa publica BookingCancelled, la publicación es un efecto observable:
func TestCancelPublishesBookingCancelled(t *testing.T) {
events := &recordingEvents{}
app := newTestAppWithEvents(t, events)
app.bookings.givenConfirmed("booking-42")
err := app.cancellations.Cancel(context.Background(), "booking-42")
if err != nil {
t.Fatalf("cancel: %v", err)
}
if !events.Contains(BookingCancelled{ID: "booking-42"}) {
t.Fatal("BookingCancelled was not published")
}
}El caso no necesita exigir que Publish ocurra antes o después del método privado que persiste, salvo que ese orden sea parte de una garantía real. Comprueba el hecho prometido y deja libre el mecanismo de entrega.
La diferencia depende del límite que se está probando:
| Expectativa | ¿La observa el consumidor? | Qué protege |
|---|---|---|
llama a Find una vez | no | recorrido interno |
llama a Save después de Find | no | orden de implementación |
| la reserva queda cancelada | sí | resultado público |
publica BookingCancelled | sí, si forma parte del contrato | efecto prometido |
| no publica al rechazar la cancelación | sí, si se garantiza | ausencia de efecto |
El error suele aparecer antes del mock#
Un mock demasiado detallado suele indicar que el test empezó por las colaboraciones existentes:
- abrió la implementación;
- encontró
FindySave; - creó expectativas para ambas llamadas;
- llamó al método público al final.
El orden más seguro empieza con una alternativa incorrecta pero plausible. Para Cancel, una implementación podría devolver éxito sin cambiar la reserva. El caso mínimo debe hacer fallar esa alternativa. Consultar el estado final lo consigue; contar llamadas a Save solo la rechaza mientras Save sea el mecanismo elegido.
Antes de verificar una interacción, conviene preguntar: si mañana una transacción, una caché o una operación batch conserva el mismo comportamiento sin esa llamada, ¿el consumidor tendría motivos para rechazarla?
Si la respuesta es no, la expectativa pertenece a la implementación.
Un test protege una refactorización cuando fija lo que debe seguir siendo verdad, no los pasos que el código actual utiliza para conseguirlo.