Pruebas de código Go concurrente con synctest

Dejen de dormir en pruebas concurrentes de Go.

Índice

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

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

Testing concurrent Go code with synctest

El patrón negativo habitual es conocido:

go doSomething()

time.Sleep(100 * time.Millisecond)

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

Esa prueba puede pasar en tu portátil y fallar en la CI. O puede pasar durante seis meses y luego fallar en un ejecutor cargado. O puede ser lenta porque alguien aumentó el sueño de 100 milisegundos a 2 segundos “solo para estar seguros”.

Esto no es una 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 manera de probar muchas formas de código asíncrono y dependiente del tiempo. Permite que una prueba se ejecute dentro de una burbuja aislada, le 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 sleeps arbitrarios
  • Pruebas de tiempo de espera más rápidas
  • Pruebas concurrentes más deterministas
  • Mejor prueba de cancelación de contexto
  • Mejor prueba de goroutines en segundo plano
  • Menos CI inestable (flaky)

La versión ligeramente opinada: si tu prueba concurrente de Go depende de un time.Sleep real, probablemente deberías 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 duraderamente bloqueadas. Eso 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 principal detrás del paquete.

Por qué las pruebas concurrentes de Go son inestables (flaky)

Si eres nuevo en las pruebas de Go en general, Go Unit Testing: Structure & Best Practices cubre el paquete de pruebas, pruebas basadas en tablas y patrones de mocks 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 la CI.

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

Tercero, observan el estado demasiado pronto. La prueba verifica el resultado antes de que la operación en segundo plano haya terminado 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 el sleep. No hay garantía de que 10 milisegundos sea la cantidad correcta de tiempo.

El menos obvio es la condición de carrera (data race). 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, plazos de contexto, time.AfterFunc, trabajadores en segundo plano o limpieza diferida, la prueba aún puede volverse torpe; y ahí es exactamente donde testing/synctest ayuda.

La idea principal: ejecutar la prueba dentro de una burbuja

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

Úsalo así:

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 sleeps usan un reloj falso.
  • synctest.Wait puede esperar a que la actividad en segundo plano se estabilice.
  • La prueba debe evitar depender de goroutines externas, E/S 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 las pruebas

Un time.Sleep real en una prueba generalmente significa una de dos cosas:

No sé cómo esperar el evento que realmente me importa.

o:

Sé lo que me importa, pero el código bajo prueba no expone una manera limpia de observarlo.

Ambos son señales de diseño que vale la pena tomar en serio: apuntan a lugares donde el código de producción puede beneficiarse de una observabilidad más limpia o mecanismos de coordinación más explícitos.

Considera 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 verse así:

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.

Eso 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 empresarial: el trabajador debería terminar después de 5 segundos; pero no gasta 5 segundos reales haciéndolo. Esa es la diferencia entre probar comportamiento dependiente del tiempo y desperdiciar tiempo del desarrollador.

Pruebas de tiempos de espera de contexto

Uno de los mejores usos de testing/synctest es probar los plazos y tiempos de espera de context.Context. La propagación correcta de context.Canceled y context.DeadlineExceeded a través de las capas de servicio y manejador se cubre en profundidad en Go Error Handling Architecture: Boundaries and Patterns — synctest te permite verificar ese comportamiento sin que pase tiempo real.

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

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

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

Con synctest, puedes probar la duración real del tiempo de espera 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 tiempo de espera realistas en el código y aún así ejecutar pruebas rápidamente.

Pruebas de 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 la oportunidad de observar la cancelación y estabilizarse 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 duraderamente bloqueadas.

En lenguaje normal, significa:

Espera hasta que las goroutines dentro de esta prueba hayan alcanzado un punto de bloqueo estable.

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 intencionalmente pequeño, pero demuestra la idea.

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

Un sleep dice:

Espero que haya pasado suficiente tiempo.

Wait dice:

Quiero que la burbuja alcance un estado de bloqueo estable.

El segundo 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 cada goroutine en la burbuja está duraderamente bloqueada 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:

  • tiempos de espera (timeouts)
  • plazos (deadlines)
  • reintentos (retries)
  • retroceso exponencial (backoff)
  • limpieza diferida
  • 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, E/S de red real o tiempo medido fuera de la burbuja, synctest no puede hacer que eso sea determinista.

Pruebas de un bucle de reintento

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

Aquí hay una pequeña ayuda para reintentos:

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 así, se ejecuta rápidamente.

Aquí es donde synctest cambia la economía de las pruebas. Ya no necesitas duraciones falsas diminutas dispersas en las pruebas solo para evitar una CI lenta.

Pruebas de 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 responde a la cancelación en lugar de dormir durante el retraso.

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

Pruebas de time.AfterFunc

time.AfterFunc es otro buen ajuste.

Supongamos que tienes una función que programa la 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 ocurre antes del retraso.
  • La limpieza sí ocurre después del retraso.

Y no espera un minuto real.

Pruebas de 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 pequeño basado en ticker:

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 verse así:

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 trabajador 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 devolver, lo que significa que las goroutines filtradas son más difíciles de ignorar. Si una goroutine en segundo plano nunca sale, la prueba falla en lugar de dejar trabajo silenciosamente; y eso es algo bueno.

El código concurrente debe tener una propiedad clara. Si una función inicia una goroutine, debe 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.

Un buen patrón es:

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

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

Qué significa “duraderamente bloqueado” en la práctica

La documentación oficial usa el término “duraderamente bloqueado”.

No necesitas memorizar cada detalle de tiempo de ejecución, pero debes entender el significado práctico.

Una goroutine está duraderamente bloqueada cuando está bloqueada de una manera que solo puede ser desbloqueada 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 duraderamente bloqueadas porque algo fuera de la burbuja puede desbloquearlas.

Los ejemplos incluyen:

  • E/S de red
  • llamadas al sistema
  • operaciones de procesos externos
  • algunas esperas de mutex
  • interacciones con goroutines fuera de la burbuja

Por eso las pruebas de 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 alrededor del comportamiento asíncrono.

Los buenos candidatos incluyen:

  • cancelación de contexto
  • tiempos de espera de contexto
  • bucles de reintento
  • lógica de retroceso
  • limpieza diferida
  • trabajadores impulsados por temporizadores
  • bucles impulsados por tickers
  • goroutines en segundo plano
  • comportamiento de tiempo de espera
  • coordinación de canales
  • time.AfterFunc
  • espera determinista de goroutines

El mejor caso de uso es el código donde la parte difícil es el tiempo o la programación, no la E/S 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 del detector de carreras.

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

No hace que la E/S de red real sea determinista.

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

No significa que puedas ignorar 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 condiciones de carrera
  • usa pruebas de integración para dependencias reales
  • usa pruebas de carga para rendimiento y contención
  • usa benchmarks para rendimiento
  • usa trazabilidad y perfilado 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 acceso concurrente a memoria inseguro.

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 ofrece. Combinarlo con Go Linters: Essential Tools for Code Quality te da una base sólida de análisis estático y verificación en 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 verse así:

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

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

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

  • más interfaces
  • más canalización (plumbing)
  • más abstracciones solo para pruebas
  • más formas de que el código omita 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 sea parte de tu diseño de dominio o cuando necesites control fuera de lo que synctest proporciona. Para una visión más amplia de los patrones de inyección de dependencia en Go, incluidas cuándo y cómo inyectar abstracciones probables, consulta Dependency Injection in Go: Patterns & Best Practices.

synctest vs canales y WaitGroups

No reemplaces una buena sincronización con synctest.

Si tu código puede exponer un canal de finalización, una devolución de llamada 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 implica tiempo, plazos de contexto, programación en segundo plano o devoluciones de llamada asíncronas.

Las mejores pruebas a menudo combinan ambas:

  • el código de producción tiene señales de apagado o finalización explícitas
  • 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 E/S de red real dentro de la burbuja

Mantén las pruebas de 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 falsos (fakes) para pruebas unitarias y reserva dependencias reales para pruebas de integración separadas donde el aislamiento de la burbuja no se aplica.

Error 3: Fugas de 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 mal olor en producción como en las pruebas que synctest revelará en lugar de ocultar.

Error 4: Depender de estado a nivel de paquete

Los canales, temporizadores y WaitGroups a nivel de paquete pueden romper el aislamiento de la burbuja de maneras 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 ciclo de vida esté claramente delimitado 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 bloqueos, el uso de memoria o el comportamiento de programación 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 de synctest para capturar el acceso concurrente a memoria inseguro que la burbuja 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 plazos de contexto
  • el código tiene comportamiento de reintento o retroceso
  • la prueba actualmente usa sleeps arbitrarios
  • la prueba es inestable en CI
  • la prueba es lenta porque espera tiempo real

Evita synctest cuando

  • el código es síncrono
  • la prueba depende de E/S de red real
  • la prueba depende de procesos externos
  • la prueba es realmente una prueba de integración
  • estás intentando 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:

  • configurar dentro de la burbuja
  • iniciar trabajo dentro de la burbuja
  • esperar a que la actividad en segundo plano se estabilice
  • avanzar el tiempo falso solo cuando sea necesario
  • afirmar después de la sincronización

Dónde usar testing/synctest en proyectos reales

Los mejores lugares para buscar generalmente no están en la lógica de negocios 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:

  • ¿Es esta prueba lenta porque espera tiempo real?
  • ¿Es esta prueba inestable porque asume que una goroutine ya se ejecutó?
  • ¿Puede esta prueba aislarse de la red y los 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 trabajadores
  • paquetes de reintento
  • paquetes de caché
  • paquetes de programador
  • consumidores de cola
  • envoltorios de cliente HTTP
  • middleware de tiempo de espera
  • código de limpieza en segundo plano
  • código de limitación de tasa

Comienza con una prueba inestable. No migras toda la base de código a la vez. Si tu suite de pruebas usa pruebas basadas en tablas paralelas junto con código asíncrono, Parallel Table-Driven Tests in Go cubre los patrones de t.Parallel() y las trampas de condiciones de carrera que se emparejan naturalmente con el enfoque de 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 mal, 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 de 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 real del retraso, la suite se mantiene rápida y la intención es más clara. Ese es el valor principal de testing/synctest.

Cómo adoptar synctest de manera segura

Yo lo adoptaría gradualmente.

Paso 1: Encontrar pruebas concurrentes inestables o lentas

Busca sleeps reales y pruebas con muchos tiempos de espera. 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 que no requiera servicios externos reales. Los paquetes de trabajadores, las ayudas de reintento y los componentes impulsados por temporizadores son objetivos ideales primero.

Paso 3: Convertir una prueba

Envuelve la prueba en synctest.Test y reemplaza los sleeps arbitrarios con synctest.Wait, sleeps 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 de 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 revelará la fuga en lugar de ignorarla silenciosamente.

Paso 6: Repetir solo donde mejora la claridad

No conviertas pruebas solo por moda. Una buena prueba de 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: No sleeps arbitrarios en pruebas unitarias concurrentes

Un sleep que espera a que una goroutine termine quizás es un mal olor. Reemplázalo con canales, WaitGroups, devoluciones de llamada, 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 de synctest autocontenidas

Crea goroutines, canales, contextos, temporizadores y trabajadores 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 envoltorio de pruebas de integración

Si la prueba habla con una base de datos real, red real o proceso externo, manténla 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 una goroutine a ejecutarse. El objetivo es verificar el comportamiento observable después de que el sistema ha alcanzado un estado significativo, lo que synctest.Wait hace posible sin depender de suposiciones de tiempo.

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

Cada goroutine en segundo plano debe tener una ruta de apagado, y las pruebas deben demostrar que esa ruta funciona cancelando el contexto o cerrando el canal y luego verificando que la goroutine salga limpiamente.

Pensamientos finales

testing/synctest es una de esas características 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 del tiempo.

Eso importa porque el código concurrente ya es lo suficientemente difícil. Las pruebas deberían reducir la incertidumbre, no agregarla. Para una visión más amplia de los patrones de Go de producción en integración, estructura de código y acceso a datos, consulta App Architecture in Production.

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 único 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 duraderamente bloqueadas.

Fuentes

Suscribirse

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