Prueba de código Go concurrente con synctest

Deja de dormir en pruebas concurrentes de Go.

Índice

Probar código Go concurrente siempre ha requerido cierta disciplina. Las goroutines son baratas, los canales son simples, y la cancelación de contexto es idiomática: los workers en segundo plano y los temporizadores están en todas partes en los servicios Go reales.

Pero probar todo eso de forma fiable es más difícil que escribirlo.

Testing concurrent Go code with synctest

El patrón negativo habitual es familiar:

go doSomething()

time.Sleep(100 * time.Millisecond)

if !done {
	t.Fatal("background work did not finish")
}

Esa prueba puede pasar en tu portátiles y fallar en CI. O puede pasar durante seis meses y luego fallar en un runner cargado. O puede ser lenta porque alguien aumentó la pausa de 100 milisegundos a 2 segundos “solo por estar seguro”.

Esto no es buena prueba — es apostar con un temporizador, y esa apuesta se vuelve más costosa a medida que la suite de pruebas crece.

El paquete testing/synctest ofrece a los desarrolladores de Go una mejor forma de probar muchas formas de código asíncrono y dependiente del tiempo. Permite que una prueba se ejecute dentro de una burbuja aislada, da a esa burbuja un reloj falso y proporciona una manera de esperar hasta que las goroutines dentro de la burbuja estén bloqueadas.

El resultado es simple pero poderoso:

  • Sin pausas arbitrarias
  • Pruebas de timeout más rápidas
  • Pruebas concurrentes más deterministas
  • Mejor prueba de cancelación de contexto
  • Mejor prueba de goroutines en segundo plano
  • Menos flacidez en CI

La versión un poco opinada: si tu prueba concurrente en Go depende de un time.Sleep real, probablemente debas tratar esa prueba como sospechosa.

Qué es testing/synctest

testing/synctest es un paquete de la biblioteca estándar de Go para probar código concurrente.

Proporciona dos funciones principales:

package synctest

func Test(t *testing.T, f func(*testing.T))
func Wait()

synctest.Test ejecuta una función dentro de una burbuja de prueba aislada. Cualquier goroutine iniciada dentro de esa burbuja también forma parte de la burbuja, el tiempo dentro de la burbuja es falso, y el paquete time funciona contra ese reloj falso en lugar del reloj real de pared.

synctest.Wait espera hasta que todas las demás goroutines en la burbuja estén bloqueadas de forma duradera. Suena abstracto, pero el efecto práctico es fácil de entender:

synctest.Test(t, func(t *testing.T) {
	time.Sleep(10 * time.Second)
})

Esto no hace que tu prueba espere 10 segundos reales. Dentro de la burbuja de synctest, el tiempo puede avanzar instantáneamente cuando la burbuja está bloqueada y esperando que el tiempo avance — ese es el truco central detrás del paquete.

Por qué las pruebas concurrentes de Go son inestables

Si eres nuevo en las pruebas de Go en general, Pruebas Unitarias en Go: Estructura y Mejores Prácticas cubre el paquete de pruebas, las pruebas basadas en tablas y los patrones de mocking que forman la base sobre la que se construye este artículo. Las pruebas concurrentes suelen ser inestables por una de tres razones.

Primero, dependen del planificador. Una goroutine puede ejecutarse inmediatamente en tu máquina y más tarde en CI.

Segundo, dependen del tiempo real. Una prueba que duerme 50 milisegundos asume que 50 milisegundos es suficiente tiempo para que el trabajo en segundo plano termine.

Tercero, observan el estado demasiado temprano. La prueba verifica el resultado antes de que la operación en segundo plano haya completado realmente.

Aquí hay un ejemplo simple:

func TestBackgroundWorkBad(t *testing.T) {
	done := false

	go func() {
		done = true
	}()

	time.Sleep(10 * time.Millisecond)

	if !done {
		t.Fatal("background work did not finish")
	}
}

Esta prueba tiene dos problemas.

El obvio es la pausa. No hay garantía de que 10 milisegundos sea la cantidad correcta de tiempo.

El menos obvio es la carrera de datos. La prueba escribe done en una goroutine y lo lee en otra sin sincronización.

Puedes corregir este ejemplo específico con un canal o un sync.WaitGroup, y a menudo deberías hacerlo. Pero cuando el código bajo prueba usa temporizadores, límites de contexto, time.AfterFunc, workers en segundo plano o limpieza retrasada, la prueba aún puede volverse incómoda — y ese es exactamente el lugar donde testing/synctest ayuda.

La idea central: ejecutar la prueba dentro de una burbuja

Una burbuja synctest aísla las goroutines creadas dentro de ella.

Úsala de esta manera:

func TestSomethingConcurrent(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		// Test concurrent code here.
	})
}

Dentro de la burbuja:

  • Las goroutines iniciadas por la prueba pertenecen a la burbuja.
  • Los temporizadores y pausas usan un reloj falso.
  • synctest.Wait puede esperar a que la actividad en segundo plano se asiente.
  • La prueba debería evitar depender de goroutines externas, I/O de red real o procesos externos.

La burbuja no es magia. No hace que un mal diseño de concurrencia sea bueno. Pero le da a tu prueba un entorno controlado donde el tiempo y el comportamiento de bloqueo son más deterministas.

El problema con time.Sleep en pruebas

Un time.Sleep real en una prueba suele significar una de dos cosas:

I do not know how to wait for the event I actually care about.

o:

I know what I care about, but the code under test does not expose a clean way to observe it.

Ambas son señales de diseño dignas de tomarse en serio — señalan lugares donde el código de producción podría beneficiarse de una observabilidad más limpia o de mecanismos de coordinación más explícitos.

Considere una función que completa el trabajo en segundo plano:

type Worker struct {
	out chan string
}

func NewWorker() *Worker {
	return &Worker{
		out: make(chan string, 1),
	}
}

func (w *Worker) Start() {
	go func() {
		time.Sleep(5 * time.Second)
		w.out <- "done"
	}()
}

func (w *Worker) Result() <-chan string {
	return w.out
}

Una mala prueba podría parecerse a esto:

func TestWorkerBad(t *testing.T) {
	w := NewWorker()
	w.Start()

	time.Sleep(6 * time.Second)

	select {
	case got := <-w.Result():
		if got != "done" {
			t.Fatalf("got %q, want done", got)
		}
	default:
		t.Fatal("worker did not finish")
	}
}

Esta prueba espera seis segundos reales.

Es lento. Si tienes muchas pruebas como esta, la suite se vuelve dolorosa.

Una mejor prueba con synctest puede avanzar el tiempo falso instantáneamente:

func TestWorkerWithSynctest(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		w := NewWorker()
		w.Start()

		time.Sleep(5 * time.Second)
		synctest.Wait()

		select {
		case got := <-w.Result():
			if got != "done" {
				t.Fatalf("got %q, want done", got)
			}
		default:
			t.Fatal("worker did not finish")
		}
	})
}

La prueba aún expresa el hecho de negocio — el worker debería terminar después de 5 segundos — pero no gasta 5 segundos reales en hacerlo. Esa es la diferencia entre probar el comportamiento dependiente del tiempo y desperdiciar el tiempo del desarrollador.

Probando timeouts de contexto

Uno de los mejores usos para testing/synctest es probar los límites y timeouts de context.Context. Cómo deberían estructurarse la cancelación de contexto y los presupuestos de timeout a través de las capas de servicio se cubre en Go context.Context Hecho Correctamente, y la propagación correcta de context.Canceled y context.DeadlineExceeded a través de esas capas se cubre en Arquitectura de Manejo de Errores en Go: Fronteras y Patrones — synctest te permite verificar ese comportamiento sin que pase tiempo real.

Aquí hay una función simple que espera hasta que un contexto se cancele:

func WaitForCancel(ctx context.Context, done chan<- error) {
	go func() {
		<-ctx.Done()
		done <- ctx.Err()
	}()
}

Sin synctest, probar esto con un timeout de 30 segundos haría que la prueba fuera lenta o te forzará a cambiar el timeout solo para la prueba.

Con synctest, puedes probar la duración real del timeout rápidamente:

func TestWaitForCancelWithTimeout(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
		defer cancel()

		done := make(chan error, 1)
		WaitForCancel(ctx, done)

		synctest.Wait()

		select {
		case err := <-done:
			t.Fatalf("context canceled too early: %v", err)
		default:
		}

		time.Sleep(30 * time.Second)
		synctest.Wait()

		select {
		case err := <-done:
			if !errors.Is(err, context.DeadlineExceeded) {
				t.Fatalf("got %v, want %v", err, context.DeadlineExceeded)
			}
		default:
			t.Fatal("context was not canceled")
		}
	})
}

Este es el tipo de prueba que synctest hace agradable.

Puedes mantener valores de timeout realistas en el código y aún ejecutar las pruebas rápidamente.

Probando cancelación de contexto

También puedes probar la cancelación explícita sin competir con la goroutine en segundo plano.

func TestWaitForCancelWithExplicitCancel(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		ctx, cancel := context.WithCancel(t.Context())

		done := make(chan error, 1)
		WaitForCancel(ctx, done)

		synctest.Wait()

		select {
		case err := <-done:
			t.Fatalf("context canceled too early: %v", err)
		default:
		}

		cancel()
		synctest.Wait()

		select {
		case err := <-done:
			if !errors.Is(err, context.Canceled) {
				t.Fatalf("got %v, want %v", err, context.Canceled)
			}
		default:
			t.Fatal("context was not canceled")
		}
	})
}

El detalle importante es synctest.Wait.

Le da a la goroutine en segundo plano una oportunidad de observar la cancelación y asentarse antes de que la prueba verifique el resultado.

Qué hace synctest.Wait

synctest.Wait espera hasta que todas las demás goroutines en la burbuja estén bloqueadas de forma duradera.

En lenguaje normal, significa:

Wait until the goroutines inside this test have reached a stable blocked point.

Esto es útil cuando la prueba inicia una goroutine y necesita saber que la goroutine ha terminado o está esperando.

Por ejemplo:

func TestWaitExample(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		done := false

		go func() {
			done = true
		}()

		synctest.Wait()

		if !done {
			t.Fatal("goroutine did not run")
		}
	})
}

Esto es deliberadamente pequeño, pero demuestra la idea.

synctest.Wait no es solo una pausa más bonita — es un punto de sincronización dentro de la burbuja, y esa distinción importa más de lo que parece a primera vista.

Una pausa dice:

I hope enough time has passed.

Wait dice:

I want the bubble to reach a stable blocked state.

La segunda es mucho mejor para las pruebas porque describe una condición observable en lugar de una suposición sobre el tiempo transcurrido.

Tiempo falso en una burbuja synctest

Dentro de una burbuja synctest, el paquete time usa un reloj falso.

El reloj falso comienza en un tiempo fijo. Avanza solo cuando todas las goroutines en la burbuja están bloqueadas de forma duradera y el tiempo necesita avanzar para desbloquear algo.

Eso significa que esta prueba es rápida:

func TestFakeTime(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		start := time.Now()

		time.Sleep(1 * time.Hour)

		elapsed := time.Since(start)
		if elapsed != time.Hour {
			t.Fatalf("got %v, want %v", elapsed, time.Hour)
		}
	})
}

Se lee como si esperara una hora.

No lo hace.

Esto es útil para probar:

  • timeouts
  • límites
  • reintentos
  • retroceso
  • limpieza retrasada
  • límites de tasa
  • temporizadores
  • tickers
  • cancelación de contexto

Pero hay una regla importante: el tiempo falso solo ayuda al código que usa el paquete time dentro de la burbuja.

Si tu código depende de un sistema externo, I/O de red real o tiempo medido fuera de la burbuja, synctest no puede hacer que eso sea determinista.

Probando un bucle de reintento

Los bucles de reintento son una fuente común de pruebas lentas e inestables.

Aquí hay un helper de reintento pequeño:

func Retry(ctx context.Context, attempts int, delay time.Duration, fn func() error) error {
	var last error

	for i := 0; i < attempts; i++ {
		if err := fn(); err != nil {
			last = err
		} else {
			return nil
		}

		if i == attempts-1 {
			break
		}

		timer := time.NewTimer(delay)
		select {
		case <-ctx.Done():
			timer.Stop()
			return ctx.Err()
		case <-timer.C:
		}
	}

	return last
}

Una prueba normal podría reducir el retraso a 1 milisegundo solo para mantener la suite rápida.

Eso no es terrible, pero significa que la prueba ya no está ejercitando el valor real usado por el código de producción.

Con synctest, puedes mantener el retraso real:

func TestRetryEventuallySucceeds(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		ctx := t.Context()

		calls := 0
		err := Retry(ctx, 3, 10*time.Second, func() error {
			calls++
			if calls < 3 {
				return errors.New("temporary failure")
			}
			return nil
		})

		if err != nil {
			t.Fatalf("Retry returned error: %v", err)
		}

		if calls != 3 {
			t.Fatalf("calls = %d, want 3", calls)
		}
	})
}

La prueba representa dos esperas de 10 segundos.

Aún se ejecuta rápidamente.

Este es el lugar donde synctest cambia la economía de las pruebas. Ya no necesitas duraciones falsas diminutas esparcidas por las pruebas solo para evitar un CI lento.

Probando cancelación de reintento

También puedes probar la cancelación durante el retraso de reintento:

func TestRetryStopsWhenContextCanceled(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		ctx, cancel := context.WithCancel(t.Context())

		errCh := make(chan error, 1)

		go func() {
			errCh <- Retry(ctx, 10, 10*time.Second, func() error {
				return errors.New("temporary failure")
			})
		}()

		synctest.Wait()

		cancel()
		synctest.Wait()

		select {
		case err := <-errCh:
			if !errors.Is(err, context.Canceled) {
				t.Fatalf("got %v, want %v", err, context.Canceled)
			}
		default:
			t.Fatal("Retry did not return after cancellation")
		}
	})
}

Esta prueba verifica que el bucle de reintento responda a la cancelación en lugar de dormir a través del retraso.

Ese es exactamente el tipo de comportamiento que importa en producción.

Probando time.AfterFunc

time.AfterFunc es otro buen ajuste.

Supongamos que tienes una función que agenda una limpieza:

type Cache struct {
	cleaned chan struct{}
}

func NewCache() *Cache {
	return &Cache{
		cleaned: make(chan struct{}, 1),
	}
}

func (c *Cache) CleanupAfter(d time.Duration) {
	time.AfterFunc(d, func() {
		c.cleaned <- struct{}{}
	})
}

func (c *Cache) Cleaned() <-chan struct{} {
	return c.cleaned
}

La prueba puede avanzar el tiempo falso:

func TestCleanupAfter(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		cache := NewCache()
		cache.CleanupAfter(1 * time.Minute)

		synctest.Wait()

		select {
		case <-cache.Cleaned():
			t.Fatal("cleanup happened too early")
		default:
		}

		time.Sleep(1 * time.Minute)
		synctest.Wait()

		select {
		case <-cache.Cleaned():
		default:
			t.Fatal("cleanup did not happen")
		}
	})
}

Esta prueba verifica ambos lados:

  • La limpieza no sucede antes del retraso.
  • La limpieza sucede después del retraso.

Y no espera un minuto real.

Probando tickers

Los tickers también pueden probarse con tiempo falso, pero ten cuidado. Los tickers a menudo se usan en bucles de larga ejecución, y los bucles de larga ejecución necesitan una ruta de apagado limpia.

Aquí hay un contador basado en ticker pequeño:

type Counter struct {
	ticks int
	done  chan struct{}
}

func NewCounter() *Counter {
	return &Counter{
		done: make(chan struct{}),
	}
}

func (c *Counter) Start(ctx context.Context, interval time.Duration) {
	ticker := time.NewTicker(interval)

	go func() {
		defer ticker.Stop()
		defer close(c.done)

		for {
			select {
			case <-ctx.Done():
				return
			case <-ticker.C:
				c.ticks++
			}
		}
	}()
}

func (c *Counter) Wait() {
	<-c.done
}

func (c *Counter) Ticks() int {
	return c.ticks
}

Una prueba podría parecerse a esto:

func TestCounterTicks(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		ctx, cancel := context.WithCancel(t.Context())

		counter := NewCounter()
		counter.Start(ctx, 10*time.Second)

		time.Sleep(35 * time.Second)
		synctest.Wait()

		cancel()
		counter.Wait()

		if counter.Ticks() != 3 {
			t.Fatalf("ticks = %d, want 3", counter.Ticks())
		}
	})
}

Este ejemplo tiene un detalle de diseño deliberado: el worker tiene una ruta de apagado.

Eso no es solo bueno para las pruebas. Es bueno para la producción.

Las pruebas a menudo revelan si tus goroutines realmente pueden detenerse.

synctest y fugas de goroutines

testing/synctest es útil aquí porque synctest.Test espera a que las goroutines en la burbuja salgan antes de regresar, lo que significa que las goroutines fugadas son más difíciles de ignorar. Si una goroutine en segundo plano nunca sale, la prueba falla en lugar de dejar trabajo detrás silenciosamente — y eso es una buena cosa.

El código concurrente debería tener una propiedad clara. Si una función inicia una goroutine, debería haber una manera explícita de detenerla, o una razón documentada por la que se le permite vivir para siempre. En las pruebas, “para siempre” casi nunca es aceptable. La propiedad más un límite de concurrencia es el hábito de diseño que este artículo combina con: Limitando la Concurrencia en Go cubre los patrones del lado de producción cuyas rutas de fuga una burbuja atrapa en milisegundos.

Un buen patrón es:

ctx, cancel := context.WithCancel(t.Context())
defer cancel()

Luego haz que la goroutine se detenga cuando el contexto se cancele.

Qué significa “bloqueado de forma duradera” en la práctica

La documentación oficial usa el término “durably blocked”.

No necesitas memorizar cada detalle del runtime, pero debes entender el significado práctico.

Una goroutine está bloqueada de forma duradera cuando está bloqueada de una manera que solo puede desbloquearse por algo dentro de la misma burbuja synctest.

Los ejemplos incluyen:

  • recibir de un canal creado dentro de la burbuja
  • enviar a un canal creado dentro de la burbuja
  • esperar en un sync.WaitGroup asociado con la burbuja
  • dormir con time.Sleep
  • esperar en ciertas operaciones de temporizador

Algunas cosas no están bloqueadas de forma duradera porque algo fuera de la burbuja puede desbloquearlas.

Los ejemplos incluyen:

  • I/O de red
  • llamadas al sistema
  • operaciones de proceso externo
  • algunas esperas de mutex
  • interacciones con goroutines fuera de la burbuja

Esta es la razón por la que las pruebas synctest deben ser autocontenidas y mantenerse libres de sincronización externa que la burbuja no pueda ver. No uses synctest como un envoltorio alrededor de pruebas de integración que hablan con la red real.

Para qué es bueno synctest

testing/synctest es especialmente bueno para pruebas unitarias sobre comportamiento asíncrono.

Los buenos candidatos incluyen:

  • cancelación de contexto
  • timeouts de contexto
  • bucles de reintento
  • lógica de retroceso
  • limpieza retrasada
  • workers impulsados por temporizador
  • bucles impulsados por ticker
  • goroutines en segundo plano
  • comportamiento de timeout
  • coordinación de canales
  • time.AfterFunc
  • espera determinista para goroutines

El mejor caso de uso es el código donde la parte difícil es el tiempo o el planificado, no la I/O externa.

Para qué no es bueno synctest

testing/synctest no es un reemplazo para todas las pruebas de concurrencia.

No es un planificador determinista completo para cada posible carrera.

No es un sustituto para el detector de carreras.

No es un reemplazo para las pruebas de integración.

No hace que la I/O de red real sea determinista.

No corrige un mal diseño de ciclo de vida de goroutines.

No significa que puedas ignorar los canales, contextos, propiedad y apagado.

Usa synctest para la capa correcta: pruebas unitarias deterministas para comportamiento concurrente y dependiente del tiempo.

Usa otras herramientas para otras capas:

  • usa go test -race para detectar carreras de datos
  • usa pruebas de integración para dependencias reales
  • usa pruebas de carga para capacidad y contención
  • usa benchmarks para rendimiento
  • usa trazas y perfiles para el comportamiento de producción

synctest vs el detector de carreras

testing/synctest y el detector de carreras resuelven problemas diferentes.

El detector de carreras encuentra accesos de memoria concurrentes inseguros.

synctest te ayuda a controlar el tiempo asíncrono y la espera en las pruebas.

Deberías usar ambos a menudo.

Por ejemplo, esto aún es una carrera incluso dentro de una burbuja synctest si no hay una sincronización adecuada:

value := 0

go func() {
	value = 1
}()

_ = value

synctest.Wait puede proporcionar un punto de sincronización para algunos patrones de prueba, pero no significa que cada acceso concurrente en tu código sea automáticamente seguro.

Ejecuta pruebas concurrentes con:

go test -race ./...

El detector de carreras sigue siendo una de las mejores herramientas que Go te da. Combinarlo con Linter para Go: Herramientas Esenciales para la Calidad del Código te da una base sólida de análisis estático y comprobaciones de tiempo de ejecución para cualquier base de código concurrente.

synctest vs relojes falsos manuales

Antes de testing/synctest, muchos equipos usaban relojes falsos manuales.

Eso aún puede ser un buen diseño.

Una interfaz de reloj manual podría parecerse a esto:

type Clock interface {
	Now() time.Time
	After(time.Duration) <-chan time.Time
	Sleep(time.Duration)
}

Entonces el código de producción usa un reloj real y las pruebas usan un reloj falso.

Esto da un control explícito, pero tiene un costo:

  • más interfaces
  • más tubería (plumbing)
  • más abstracciones solo para pruebas
  • más formas para que el código evite el reloj falso accidentalmente

synctest es atractivo porque el código ordinario que usa el paquete time puede ejecutarse contra el tiempo falso dentro de la burbuja de prueba.

Eso reduce la necesidad de inyección de reloj en muchos casos.

Mi opinión: usa synctest cuando mantiene el código de producción más simple. Usa un reloj inyectado solo cuando el control del reloj es parte de tu diseño de dominio o cuando necesitas control fuera de lo que synctest proporciona. Para una mirada más amplia a los patrones de inyección de dependencias en Go — incluido cuándo y cómo inyectar abstracciones probables — ver Inyección de Dependencias en Go: Patrones y Mejores Prácticas.

synctest vs canales y WaitGroups

No reemplaces una buena sincronización con synctest.

Si tu código puede exponer un canal de finalización, un callback o un método Wait, eso a menudo es un buen diseño.

Por ejemplo:

type Server struct {
	done chan struct{}
}

func (s *Server) Done() <-chan struct{} {
	return s.done
}

Una prueba puede esperar en eso directamente.

synctest es más útil cuando el comportamiento bajo prueba involucra tiempo, límites de contexto, planificado en segundo plano o callbacks asíncronos.

Las mejores pruebas a menudo combinan ambas:

  • el código de producción tiene señales explícitas de apagado o finalización
  • synctest elimina la espera de tiempo real
  • Wait hace que la actividad en segundo plano sea determinista

Errores comunes

Error 1: Envolver cada prueba en synctest

No uses synctest en todas partes. Si el código es síncrono, una función de prueba simple es más clara, y agregar el envoltorio de burbuja solo introduce maquinaria innecesaria que hace que las pruebas sean más difíciles de leer y razonar.

Error 2: Probar I/O de red real dentro de la burbuja

Mantén las pruebas synctest autocontenidas. Si tu prueba usa un socket de red real, un servicio externo, una base de datos o un subproceso, pertenece a una prueba de integración en lugar de dentro de una burbuja synctest. Usa falsificaciones para pruebas unitarias y reserva las dependencias reales para pruebas de integración separadas donde el aislamiento de la burbuja no se aplica. Cuando la dependencia es un manejador HTTP, el httptest.NewTestServer de Go 1.27 elimina la necesidad de un socket real por completo — ejecuta el servidor sobre una red falsa en memoria, que es exactamente lo que la burbuja necesita, y se combina con synctest para un temporizado de ida y vuelta determinista como se muestra en la guía de actualización a Go 1.27.

Error 3: Fugando goroutines

Si tu prueba inicia una goroutine, asegúrate de que tenga una ruta de salida clara. Usa cancelación de contexto, canales cerrados o métodos de parada explícitos — una goroutine que nunca se detiene es tanto un olor de producción como un olor de prueba que synctest surgirá en lugar de ocultar.

Error 4: Dependiendo de estado a nivel de paquete

Los canales, temporizadores y WaitGroups a nivel de paquete pueden romper el aislamiento de la burbuja de formas sutiles. Prefiere crear todo el estado de prueba dentro de la función synctest.Test para que cada recurso pertenezca a la burbuja y su vida útil esté claramente acotada a la prueba.

Error 5: Tratar el tiempo falso como tiempo real

El tiempo falso es para pruebas deterministas, no para medición de rendimiento. Una prueba que avanza una hora instantáneamente no te dice nada útil sobre el costo de CPU, la contención de bloqueo, el uso de memoria o el comportamiento de planificado real en producción — usa benchmarks y pruebas de carga para esas preguntas.

Error 6: Ignorar el detector de carreras

synctest no es un reemplazo para go test -race, y las dos herramientas resuelven problemas diferentes. Ejecuta el detector de carreras junto con tus pruebas synctest para atrapar el acceso de memoria concurrente inseguro que la burbuja por sí sola no puede detectar.

Una lista de verificación práctica

Usa esta lista de verificación al escribir pruebas con testing/synctest.

Usa synctest cuando

  • el código inicia goroutines
  • el código usa time.Sleep
  • el código usa temporizadores o tickers
  • el código usa límites de contexto
  • el código tiene comportamiento de reintento o retroceso
  • la prueba actualmente usa pausas arbitrarias
  • la prueba es inestable en CI
  • la prueba es lenta porque espera por tiempo real

Evita synctest cuando

  • el código es síncrono
  • la prueba depende de I/O de red real
  • la prueba depende de procesos externos
  • la prueba es realmente una prueba de integración
  • estás tratando de medir el rendimiento
  • el código no tiene una ruta de apagado limpia

Prefiere este patrón

func TestSomething(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		// Arrange.
		ctx, cancel := context.WithCancel(t.Context())
		defer cancel()

		// Act.
		_ = ctx

		// Let background work settle.
		synctest.Wait()

		// Advance fake time if needed.
		time.Sleep(1 * time.Second)
		synctest.Wait()

		// Assert.
	})
}

Este patrón es simple:

  • configura dentro de la burbuja
  • inicia el trabajo dentro de la burbuja
  • espera a que la actividad en segundo plano se asiente
  • avanza el tiempo falso solo cuando sea necesario
  • afirma después de la sincronización

Dónde usar testing/synctest en proyectos reales

Los mejores lugares para mirar suelen no estar en la lógica de negocio simple.

Busca pruebas con estos olores:

grep -R "time.Sleep" .
grep -R "time.After" .
grep -R "WithTimeout" .
grep -R "WithDeadline" .
grep -R "NewTicker" .
grep -R "AfterFunc" .

Luego pregunta:

  • ¿Esta prueba es lenta porque espera por tiempo real?
  • ¿Esta prueba es inestable porque asume que una goroutine ya se ejecutó?
  • ¿Puede esta prueba aislarse de la red y procesos externos?
  • ¿Puede la goroutine en segundo plano detenerse limpiamente?
  • ¿Haría el tiempo falso la afirmación más clara?

Los buenos candidatos a menudo viven en:

  • paquetes de workers
  • paquetes de reintento
  • paquetes de caché
  • paquetes de planificador
  • consumidores de colas
  • envoltorios de clientes HTTP
  • middleware de timeout
  • código de limpieza en segundo plano
  • código de limitación de tasa

Comienza con una prueba inestable. No migres toda la base de código de una vez. Si tu suite de pruebas usa pruebas basadas en tablas paralelas junto con código asíncrono, Pruebas Basadas en Tablas Paralelas en Go cubre los patrones de t.Parallel() y las trampas de condición de carrera que se combinan naturalmente con el enfoque synctest.

Ejemplo: antes y después

Aquí hay una mala prueba realista:

func TestRetryBad(t *testing.T) {
	calls := 0

	err := Retry(context.Background(), 3, 500*time.Millisecond, func() error {
		calls++
		if calls < 3 {
			return errors.New("temporary failure")
		}
		return nil
	})

	if err != nil {
		t.Fatalf("Retry returned error: %v", err)
	}

	if calls != 3 {
		t.Fatalf("calls = %d, want 3", calls)
	}
}

Esto espera aproximadamente un segundo porque ocurren dos retrasos de reintento.

Eso puede no sonar malo, pero multiplícalo por muchas pruebas y varios paquetes. Las pruebas lentas hacen que los desarrolladores ejecuten las pruebas con menos frecuencia.

Ahora la versión synctest:

func TestRetryWithSynctest(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		calls := 0

		err := Retry(t.Context(), 3, 500*time.Millisecond, func() error {
			calls++
			if calls < 3 {
				return errors.New("temporary failure")
			}
			return nil
		})

		if err != nil {
			t.Fatalf("Retry returned error: %v", err)
		}

		if calls != 3 {
			t.Fatalf("calls = %d, want 3", calls)
		}
	})
}

La prueba mantiene el valor de retraso real, la suite permanece rápida y la intención es más clara. Ese es el valor principal de testing/synctest.

Cómo adoptar synctest de forma segura

Lo adoptaría gradualmente.

Paso 1: Encontrar pruebas concurrentes inestables o lentas

Busca pausas reales y pruebas intensas en timeout. Los comandos grep de la sección anterior son un buen punto de partida para identificar candidatos en toda la base de código.

Paso 2: Elegir un paquete

Elige un paquete que tenga un comportamiento asíncrono claro pero no requiera servicios externos reales. Los paquetes de workers, los helpers de reintento y los componentes impulsados por temporizador son objetivos iniciales ideales.

Paso 3: Convertir una prueba

Envuelve la prueba en synctest.Test y reemplaza las pausas arbitrarias con synctest.Wait, pausas de tiempo falso o sincronización explícita. La conversión suele ser pequeña — la parte más difícil es asegurarse de que las goroutines tengan rutas de apagado limpias.

Paso 4: Ejecutar con el detector de carreras

Siempre ejecuta con go test -race ./... después de convertir. Una prueba synctest que pasa no significa que el código esté libre de carreras; solo significa que el tiempo asíncrono ahora es determinista.

Paso 5: Revisar el ciclo de vida de las goroutines

Asegúrate de que cada goroutine iniciada por la prueba tenga una manera de salir antes de que la burbuja se cierre. Si no lo hace, synctest.Test surgirá la fuga en lugar de ignorarla silenciosamente.

Paso 6: Repetir solo donde mejora la claridad

No conviertas pruebas solo por moda. Una buena prueba synctest debería ser mediblemente más rápida, más clara de leer o menos inestable que la versión que reemplazó — si no lo es, la conversión no valió la pena.

Mis reglas opinadas

Usa estas como reglas prácticas.

Regla 1: Sin pausas arbitrarias en pruebas unitarias concurrentes

Una pausa que espera a que una goroutine termine tal vez es un olor. Reemplázala con canales, WaitGroups, callbacks, synctest.Wait, o tiempo falso — cualquier cosa que espere una condición en lugar de esperar que haya pasado suficiente tiempo.

Regla 2: Mantén las pruebas synctest autocontenidas

Crea goroutines, canales, contextos, temporizadores y workers dentro de la burbuja. Evita el estado compartido a nivel de paquete, que puede filtrarse entre pruebas y romper el aislamiento que hace que synctest sea útil.

Regla 3: No uses synctest como un envoltorio de prueba de integración

Si la prueba habla con una base de datos real, red real o proceso externo, mantenla fuera de synctest a menos que tengas una razón muy específica para hacerlo.

Regla 4: Prueba el comportamiento, no la suerte del planificador

El objetivo no es forzar a una goroutine a ejecutarse. El objetivo es verificar el comportamiento observable después de que el sistema haya alcanzado un estado significativo, lo que synctest.Wait hace posible sin depender de suposiciones de temporización.

Regla 5: Mantén las rutas de cancelación explícitas

Cada goroutine en segundo plano debería tener una ruta de apagado, y las pruebas deberían probar que esa ruta funciona cancelando el contexto o cerrando el canal y luego verificando que la goroutine sale limpiamente.

Pensamientos finales

testing/synctest es una de esas funciones de Go que parece pequeña pero cambia cómo escribes una clase de pruebas. No reemplaza un buen diseño de concurrencia, el detector de carreras o la necesidad de pruebas de integración — pero sí hace que muchas pruebas unitarias asíncronas sean más rápidas, más limpias y mucho menos dependientes de la suerte de temporización.

Eso importa porque el código concurrente ya es suficientemente difícil. Las pruebas deberían reducir la incertidumbre, no aumentarla. Para una visión más amplia de los patrones de Go de producción a través de la integración, la estructura del código y el acceso a datos, ver Arquitectura de Aplicaciones en Producción.

La conclusión práctica es simple:

Use synctest for deterministic unit tests around goroutines, timers, timeouts, retries, and cancellation.
Keep real sleeps out of concurrent tests unless you have a very good reason.

Ese hábito hará que muchas suites de pruebas de Go sean más rápidas y menos inestables.


Los hechos importantes actuales son: testing/synctest se volvió generalmente disponible en Go 1.25, expone synctest.Test y synctest.Wait, ejecuta pruebas dentro de una burbuja aislada, y el tiempo dentro de esa burbuja usa un reloj falso que avanza solo cuando las goroutines están bloqueadas de forma duradera.

Fuentes

Suscribirse

Recibe nuevas publicaciones sobre sistemas, infraestructura e ingeniería de IA.