Test del codice Go concorrente con synctest

Smetti di usare sleep nei test Go concorrenti.

Indice

Testare il codice Go concorrente ha sempre richiesto un po’ di disciplina. Le goroutine sono leggere, i channel sono semplici e la cancellazione del contesto è idiomatica: i worker in background e i timer sono onnipresenti nei servizi Go reali.

Ma testare tutto questo in modo affidabile è più difficile che scriverlo.

Testing concurrent Go code with synctest

Il pattern negativo usuale è familiare:

go doSomething()

time.Sleep(100 * time.Millisecond)

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

Quel test potrebbe passare sul tuo laptop e fallire nella CI. Oppure potrebbe passare per sei mesi e poi fallire su un runner sotto carico. Oppure potrebbe essere lento perché qualcuno ha aumentato il sleep da 100 millisecondi a 2 secondi “solo per sicurezza”.

Non è un buon testing: è una scommessa con un timer, e quella scommessa diventa più costosa man mano che la suite di test cresce.

Il pacchetto testing/synctest offre agli sviluppatori Go un modo migliore per testare molte forme di codice asincrono e dipendente dal tempo. Permette a un test di eseguirsi all’interno di una bolla isolata, fornisce a quella bolla un orologio finto e offre un modo per aspettare fino a quando le goroutine all’interno della bolla sono bloccate.

Il risultato è semplice ma potente:

  • Niente sleep arbitrari
  • Test di timeout più veloci
  • Test concorrenti più deterministici
  • Miglior testing della cancellazione del contesto
  • Miglior testing delle goroutine in background
  • CI meno instabile (flaky)

La versione leggermente opinionata: se il tuo test Go concorrente dipende da un time.Sleep reale, dovresti probabilmente considerare quel test sospetto.

Cos’è testing/synctest

testing/synctest è un pacchetto della libreria standard Go per testare codice concorrente.

Fornisce due funzioni principali:

package synctest

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

synctest.Test esegue una funzione all’interno di una bolla di test isolata. Qualsiasi goroutine avviata all’interno di quella bolla fa parte della bolla, il tempo all’interno della bolla è finto e il pacchetto time lavora contro quell’orologio finto piuttosto che contro l’orologio reale.

synctest.Wait aspetta fino a quando tutte le altre goroutine nella bolla sono bloccate in modo duraturo. Sembra astratto, ma l’effetto pratico è facile da capire:

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

Questo non fa aspettare il tuo test per 10 secondi reali. All’interno della bolla synctest, il tempo può avanzare istantaneamente quando la bolla è bloccata e in attesa che il tempo passi: questo è il trucco fondamentale dietro il pacchetto.

Perché i test Go concorrenti sono instabili (flaky)

Se sei nuovo al testing Go in generale, Go Unit Testing: Structure & Best Practices copre il pacchetto di testing, i test guidati da tabelle e i pattern di mocking che formano la base su cui si costruisce questo articolo. I test concorrenti sono solitamente instabili per uno di tre motivi.

In primo luogo, dipendono dallo scheduler. Una goroutine potrebbe eseguirsi immediatamente sulla tua macchina e più tardi nella CI.

In secondo luogo, dipendono dal tempo reale. Un test che fa sleep per 50 millisecondi assume che 50 millisecondi siano tempo sufficiente per completare il lavoro in background.

In terzo luogo, osservano lo stato troppo presto. Il test controlla il risultato prima che l’operazione in background si sia effettivamente completata.

Ecco un esempio semplice:

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")
	}
}

Questo test ha due problemi.

Il più ovvio è lo sleep. Non c’è garanzia che 10 millisecondi siano la quantità giusta di tempo.

Il meno ovvio è la data race (corsa critica). Il test scrive done in una goroutine e lo legge in un’altra senza sincronizzazione.

Puoi correggere questo esempio specifico con un channel o un sync.WaitGroup, e spesso dovresti farlo. Ma quando il codice sotto test usa timer, deadline di contesto, time.AfterFunc, worker in background o pulizia ritardata, il test può comunque diventare ingombrante: ed è esattamente qui che testing/synctest aiuta.

L’idea principale: eseguire il test dentro una bolla

Una bolla synctest isola le goroutine create al suo interno.

Usala così:

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

All’interno della bolla:

  • Le goroutine avviate dal test appartengono alla bolla.
  • Timer e sleep usano un orologio finto.
  • synctest.Wait può aspettare che l’attività in background si stabilizzi.
  • Il test dovrebbe evitare di dipendere da goroutine esterne, I/O di rete reale o processi esterni.

La bolla non è magia. Non rende un design della concorrenza cattivo migliore. Ma fornisce al tuo test un ambiente controllato in cui il tempo e il comportamento di blocco sono più deterministici.

Il problema con time.Sleep nei test

Un time.Sleep reale in un test di solito significa una delle due cose:

Non so come aspettare l'evento che mi interessa davvero.

oppure:

So cosa mi interessa, ma il codice sotto test non espone un modo pulito per osservarlo.

Entrambi sono segnali di design da prendere seriamente: indicano luoghi in cui il codice di produzione potrebbe beneficiare di una osservabilità più pulita o di meccanismi di coordinazione più espliciti.

Considera una funzione che completa il lavoro in background:

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
}

Un test cattivo potrebbe essere così:

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")
	}
}

Questo test aspetta sei secondi reali.

È lento. Se hai molti test simili, la suite diventa dolorosa.

Un test migliore con synctest può far avanzare il tempo finto istantaneamente:

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")
		}
	})
}

Il test esprime ancora il fatto aziendale: il worker dovrebbe finire dopo 5 secondi — ma non impiega 5 secondi reali per farlo. Questa è la differenza tra testare il comportamento dipendente dal tempo e sprecare tempo dello sviluppatore.

Testare i timeout del contesto

Uno dei migliori usi per testing/synctest è testare le deadline e i timeout di context.Context. La corretta propagazione di context.Canceled e context.DeadlineExceeded attraverso i layer di servizio e handler è trattata in profondità in Go Error Handling Architecture: Boundaries and Patterns — synctest ti permette di verificare quel comportamento senza che passi tempo reale.

Ecco una funzione semplice che aspetta fino a quando un contesto è cancellato:

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

Senza synctest, testare questo con un timeout di 30 secondi renderebbe il test lento o ti costringerebbe a cambiare il timeout solo per il test.

Con synctest, puoi testare rapidamente la durata reale del timeout:

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")
		}
	})
}

Questo è il tipo di test che synctest rende piacevole.

Puoi mantenere valori di timeout realistici nel codice e comunque eseguire i test rapidamente.

Testare la cancellazione del contesto

Puoi anche testare la cancellazione esplicita senza correre contro la goroutine in background.

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")
		}
	})
}

Il dettaglio importante è synctest.Wait.

Dà alla goroutine in background la possibilità di osservare la cancellazione e stabilizzarsi prima che il test controlla il risultato.

Cosa fa synctest.Wait

synctest.Wait aspetta fino a quando tutte le altre goroutine nella bolla sono bloccate in modo duraturo.

In linguaggio comune, significa:

Aspetta fino a quando le goroutine all'interno di questo test hanno raggiunto un punto di blocco stabile.

Questo è utile quando il test avvia una goroutine e ha bisogno di sapere che la goroutine ha finito o è in attesa.

Per esempio:

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")
		}
	})
}

Questo è intenzionalmente piccolo, ma dimostra l’idea.

synctest.Wait non è solo un sleep più carino: è un punto di sincronizzazione all’interno della bolla, e questa distinzione è più importante di quanto appaia a prima vista.

Uno sleep dice:

Spero che sia passato abbastanza tempo.

Wait dice:

Voglio che la bolla raggiunga uno stato di blocco stabile.

La seconda è molto migliore per i test perché descrive una condizione osservabile piuttosto che un’ipotesi sul tempo trascorso.

Tempo finto in una bolla synctest

All’interno di una bolla synctest, il pacchetto time usa un orologio finto.

L’orologio finto parte da un tempo fisso. Avanza solo quando ogni goroutine nella bolla è bloccata in modo duraturo e il tempo ha bisogno di avanzare per sbloccare qualcosa.

Questo significa che questo test è veloce:

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)
		}
	})
}

Sembra che aspetti un’ora.

In realtà non lo fa.

Questo è utile per testare:

  • timeout
  • deadline
  • retry
  • backoff
  • pulizia ritardata
  • limiti di frequenza (rate limits)
  • timer
  • ticker
  • cancellazione del contesto

Ma c’è una regola importante: il tempo finto aiuta solo il codice che usa il pacchetto time all’interno della bolla.

Se il tuo codice dipende da un sistema esterno, I/O di rete reale o tempo misurato fuori dalla bolla, synctest non può renderlo deterministico.

Testare un ciclo di retry

I cicli di retry sono una fonte comune di test lenti e instabili.

Ecco un piccolo helper di retry:

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
}

Un test normale potrebbe ridurre il ritardo a 1 millisecondo solo per mantenere la suite veloce.

Non è terribile, ma significa che il test non sta più esercitando il valore reale usato dal codice di produzione.

Con synctest, puoi mantenere il ritardo reale:

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)
		}
	})
}

Il test rappresenta due attese di 10 secondi.

Esegue comunque rapidamente.

È qui che synctest cambia l’economia del testing. Non hai più bisogno di durate minuscole finte sparse nei test solo per evitare una CI lenta.

Testare la cancellazione del retry

Puoi anche testare la cancellazione durante il ritardo del retry:

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")
		}
	})
}

Questo test verifica che il ciclo di retry risponda alla cancellazione invece di dormire attraverso il ritardo.

È esattamente il tipo di comportamento che conta in produzione.

Testare time.AfterFunc

time.AfterFunc è un altro buon candidato.

Supponi di avere una funzione che pianifica la pulizia:

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
}

Il test può far avanzare il tempo finto:

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")
		}
	})
}

Questo test verifica entrambi gli aspetti:

  • La pulizia non avviene prima del ritardo.
  • La pulizia avviene dopo il ritardo.

E non aspetta un minuto reale.

Testare i ticker

I ticker possono anche essere testati con il tempo finto, ma fai attenzione. I ticker sono spesso usati in loop di lunga esecuzione, e i loop di lunga esecuzione hanno bisogno di un percorso di spegnimento pulito.

Ecco un piccolo contatore basato su 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
}

Un test potrebbe essere così:

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())
		}
	})
}

Questo esempio ha un dettaglio di design deliberato: il worker ha un percorso di spegnimento.

Non è solo buono per i test. È buono per la produzione.

I test rivelano spesso se le tue goroutine possono effettivamente fermarsi.

synctest e goroutine leak

testing/synctest è utile qui perché synctest.Test aspetta che le goroutine nella bolla escono prima di restituire, il che significa che le goroutine leak sono più difficili da ignorare. Se una goroutine in background non esce mai, il test fallisce invece di lasciare silenziosamente il lavoro behind — ed è una cosa buona.

Il codice concorrente dovrebbe avere un possesso chiaro. Se una funzione avvia una goroutine, dovrebbe esserci un modo esplicito per fermarla, o una ragione documentata per cui è permesso vivere per sempre. Nei test, “per sempre” è quasi mai accettabile.

Un buon pattern è:

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

Poi fai sì che la goroutine si fermi quando il contesto è cancellato.

Cosa significa “bloccato in modo duraturo” nella pratica

I documenti ufficiali usano il termine “durably blocked”.

Non hai bisogno di memorizzare ogni dettaglio del runtime, ma dovresti capire il significato pratico.

Una goroutine è bloccata in modo duraturo quando è bloccata in un modo che può essere sbloccato solo da qualcosa all’interno della stessa bolla synctest.

Gli esempi includono:

  • ricevere da un channel creato all’interno della bolla
  • inviare a un channel creato all’interno della bolla
  • aspettare su un sync.WaitGroup associato alla bolla
  • dormire con time.Sleep
  • aspettare su certe operazioni del timer

Alcune cose non sono bloccate in modo duraturo perché qualcosa fuori dalla bolla può sbloccarle.

Gli esempi includono:

  • I/O di rete
  • chiamate di sistema
  • operazioni di processo esterno
  • alcune attese di mutex
  • interazioni con goroutine fuori dalla bolla

Questo è perché i test synctest dovrebbero essere autocontenuti e tenuti liberi da sincronizzazione esterna che la bolla non può vedere. Non usare synctest come wrapper intorno ai test di integrazione che parlano con la rete reale.

Cosa è buono per synctest

testing/synctest è particolarmente buono per i test unitari intorno al comportamento asincrono.

I buoni candidati includono:

  • cancellazione del contesto
  • timeout del contesto
  • cicli di retry
  • logica di backoff
  • pulizia ritardata
  • worker guidati da timer
  • loop guidati da ticker
  • goroutine in background
  • comportamento di timeout
  • coordinazione dei channel
  • time.AfterFunc
  • attesa deterministica per le goroutine

Il miglior caso d’uso è il codice in cui la parte difficile è il tempo o la pianificazione, non l’I/O esterno.

Cosa non è buono per synctest

testing/synctest non è un sostituto per tutto il testing della concorrenza.

Non è uno scheduler deterministico completo per ogni possibile race.

Non è un sostituto per il race detector.

Non è un sostituto per i test di integrazione.

Non rende l’I/O di rete reale deterministico.

Non corregge un cattivo design del ciclo di vita delle goroutine.

Non significa che puoi ignorare channel, contesti, possesso e spegnimento.

Usa synctest per il layer giusto: test unitari deterministici per comportamento concorrente e dipendente dal tempo.

Usa altri strumenti per altri layer:

  • usa go test -race per rilevare data race
  • usa test di integrazione per dipendenze reali
  • usa test di carico per throughput e contesa
  • usa benchmark per le prestazioni
  • usa tracing e profiling per il comportamento di produzione

synctest vs il race detector

testing/synctest e il race detector risolvono problemi diversi.

Il race detector trova accessi alla memoria concorrenti non sicuri.

synctest ti aiuta a controllare il timing asincrono e l’attesa nei test.

Dovresti spesso usare entrambi.

Per esempio, questo è ancora una race anche all’interno di una bolla synctest se non c’è una sincronizzazione adeguata:

value := 0

go func() {
	value = 1
}()

_ = value

synctest.Wait può fornire un punto di sincronizzazione per alcuni pattern di test, ma non significa che ogni accesso concorrente nel tuo codice sia automaticamente sicuro.

Esegui i test concorrenti con:

go test -race ./...

Il race detector è ancora uno dei migliori strumenti che Go ti offre. Accoppiarlo con Go Linters: Essential Tools for Code Quality ti dà una solida base di analisi statica e controllo runtime per qualsiasi codebase concorrente.

synctest vs orologi finti manuali

Prima di testing/synctest, molti team usavano orologi finti manuali.

Quello può ancora essere un buon design.

Un’interfaccia di orologio manuale potrebbe essere così:

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

Poi il codice di produzione usa un orologio reale e i test usano un orologio finto.

Questo dà controllo esplicito, ma ha un costo:

  • più interfacce
  • più plumbing
  • più astrazioni solo per i test
  • più modi per il codice di bypassare accidentalmente l’orologio finto

synctest è attraente perché il codice ordinario che usa il pacchetto time può funzionare contro il tempo finto all’interno della bolla di test.

Ciò riduce la necessità di iniezione dell’orologio in molti casi.

La mia opinione: usa synctest quando mantiene il codice di produzione più semplice. Usa un orologio iniettato solo quando il controllo dell’orologio fa parte del tuo design del dominio o quando hai bisogno di controllo fuori da ciò che synctest fornisce. Per una visione più ampia dei pattern di iniezione delle dipendenze in Go — incluso quando e come iniettare astrazioni testabili — vedi Dependency Injection in Go: Patterns & Best Practices.

synctest vs channel e WaitGroups

Non sostituire la buona sincronizzazione con synctest.

Se il tuo codice può esporre un channel di completamento, un callback o un metodo Wait, è spesso un buon design.

Per esempio:

type Server struct {
	done chan struct{}
}

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

Un test può aspettare su quello direttamente.

synctest è più utile quando il comportamento sotto test coinvolge tempo, deadline di contesto, pianificazione in background o callback asincroni.

I migliori test spesso combinano entrambi:

  • il codice di produzione ha segnali di spegnimento o completamento espliciti
  • synctest rimuove l’attesa del tempo reale
  • Wait rende l’attività in background deterministica

Errori comuni

Errore 1: Avvolgere ogni test in synctest

Non usare synctest ovunque. Se il codice è sincrono, una funzione di test normale è più chiara, e aggiungere il wrapper della bolla introduce solo meccanismi inutili che rendono i test più difficili da leggere e ragionare.

Errore 2: Testare I/O di rete reale dentro la bolla

Mantieni i test synctest autocontenuti. Se il tuo test usa un socket di rete reale, un servizio esterno, un database o un sottoprocesso, appartiene a un test di integrazione piuttosto che dentro una bolla synctest. Usa fakes per i test unitari e riserva le dipendenze reali per test di integrazione separati dove l’isolamento della bolla non si applica.

Errore 3: Goroutine leak

Se il tuo test avvia una goroutine, assicurati che abbia un percorso di uscita chiaro. Usa la cancellazione del contesto, channel chiusi o metodi di stop espliciti: una goroutine che non si ferma mai è sia un odore di produzione che un odore di test che synctest rivelerà invece di nascondere.

Errore 4: Dipendere dallo stato a livello di pacchetto

Channel, timer e WaitGroup a livello di pacchetto possono rompere l’isolamento della bolla in modi sottili. Preferisci creare tutto lo stato del test all’interno della funzione synctest.Test in modo che ogni risorsa appartenga alla bolla e la sua durata sia chiaramente limitata al test.

Errore 5: Trattare il tempo finto come tempo reale

Il tempo finto è per test deterministici, non per misurazione delle prestazioni. Un test che avanza un’ora istantaneamente non ti dice nulla utile sul costo della CPU, contesa dei lock, uso della memoria o comportamento di scheduling reale in produzione: usa benchmark e test di carico per quelle domande.

Errore 6: Ignorare il race detector

synctest non è un sostituto per go test -race, e i due strumenti risolvono problemi diversi. Esegui il race detector insieme ai tuoi test synctest per catturare accessi alla memoria concorrenti non sicuri che la bolla da sola non può rilevare.

Una checklist pratica

Usa questa checklist quando scrivi test con testing/synctest.

Usa synctest quando

  • il codice avvia goroutine
  • il codice usa time.Sleep
  • il codice usa timer o ticker
  • il codice usa deadline di contesto
  • il codice ha comportamento di retry o backoff
  • il test usa attualmente sleep arbitrari
  • il test è instabile (flaky) nella CI
  • il test è lento perché aspetta il tempo reale

Evita synctest quando

  • il codice è sincrono
  • il test dipende da I/O di rete reale
  • il test dipende da processi esterni
  • il test è davvero un test di integrazione
  • stai cercando di misurare le prestazioni
  • il codice non ha un percorso di spegnimento pulito

Preferisci questo pattern

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.
	})
}

Questo pattern è semplice:

  • configura all’interno della bolla
  • avvia il lavoro all’interno della bolla
  • aspetta che l’attività in background si stabilizzi
  • avanza il tempo finto solo quando necessario
  • assert dopo la sincronizzazione

Dove usare testing/synctest nei progetti reali

I posti migliori da guardare di solito non sono nella logica di business semplice.

Cerca test con questi odori:

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

Poi chiedi:

  • Questo test è lento perché aspetta il tempo reale?
  • Questo test è instabile perché assume che una goroutine abbia già eseguito?
  • Questo test può essere isolato dalla rete e dai processi esterni?
  • La goroutine in background può essere fermata in modo pulito?
  • Il tempo finto renderebbe l’assert più chiaro?

I buoni candidati spesso vivono in:

  • pacchetti worker
  • pacchetti retry
  • pacchetti cache
  • pacchetti scheduler
  • consumatori di code
  • wrapper client HTTP
  • middleware di timeout
  • codice di pulizia in background
  • codice di limitazione della frequenza

Inizia con un test instabile. Non migrare l’intero codebase tutto insieme. Se la tua suite di test usa test guidati da tabelle paralleli alongside codice asincrono, Parallel Table-Driven Tests in Go copre i pattern t.Parallel() e le trappole delle race condition che si abbinano naturalmente con l’approccio synctest.

Esempio: prima e dopo

Ecco un test cattivo realistico:

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)
	}
}

Questo aspetta circa un secondo perché si verificano due ritardi di retry.

Potrebbe non sembrare male, ma moltiplica per molti test e diversi pacchetti. I test lenti fanno sì che gli sviluppatori eseguano i test meno spesso.

Ora la versione 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)
		}
	})
}

Il test mantiene il valore reale del ritardo, la suite rimane veloce e l’intento è più chiaro. Questo è il valore principale di testing/synctest.

Come adottare synctest in modo sicuro

Io lo adotterei gradualmente.

Passo 1: Trova test concorrenti instabili o lenti

Cerca sleep reali e test pesanti sui timeout. I comandi grep nella sezione precedente sono un buon punto di partenza per identificare i candidati in tutto il codebase.

Passo 2: Scegli un pacchetto

Scegli un pacchetto che ha un comportamento asincrono chiaro ma non richiede servizi esterni reali. I pacchetti worker, gli helper di retry e i componenti guidati da timer sono i primi obiettivi ideali.

Passo 3: Converting un test

Avvolgi il test in synctest.Test e sostituisci gli sleep arbitrari con synctest.Wait, sleep di tempo finto o sincronizzazione esplicita. La conversione è solitamente piccola: la parte più difficile è assicurarsi che le goroutine abbiano percorsi di spegnimento puliti.

Passo 4: Esegui con il race detector

Esegui sempre con go test -race ./... dopo la conversione. Un test synctest che passa non significa che il codice sia libero da race; significa solo che il timing asincrono è ora deterministico.

Passo 5: Rivedi il ciclo di vita delle goroutine

Assicurati che ogni goroutine avviata dal test abbia un modo per uscire prima che la bolla si chiuda. Se non lo fa, synctest.Test rivelerà il leak invece di ignorarlo silenziosamente.

Passo 6: Ripeti solo dove migliora la chiarezza

Non convertire i test solo per moda. Un buon test synctest dovrebbe essere misurabilmente più veloce, più chiaro da leggere o meno instabile della versione che ha sostituito: se non lo è, la conversione non ne è valsa la pena.

Le mie regole opinionate

Usa queste come regole pratiche.

Regola 1: Niente sleep arbitrari nei test unitari concorrenti

Uno sleep che aspetta che una goroutine finisca forse è un odore. Sostituiscilo con channel, WaitGroups, callback, synctest.Wait o tempo finto: qualsiasi cosa che aspetti una condizione piuttosto che sperare che sia passato abbastanza tempo.

Regola 2: Mantieni i test synctest autocontenuti

Crea goroutine, channel, contesti, timer e worker all’interno della bolla. Evita lo stato condiviso a livello di pacchetto, che può leak tra i test e rompere l’isolamento che rende synctest utile.

Regola 3: Non usare synctest come wrapper di test di integrazione

Se il test parla con un database reale, rete reale o processo esterno, tienilo fuori da synctest a meno che tu non abbia una ragione molto specifica per farlo.

Regola 4: Testa il comportamento, non la fortuna dello scheduler

L’obiettivo non è forzare una goroutine a eseguire. L’obiettivo è verificare il comportamento osservabile dopo che il sistema ha raggiunto uno stato significativo, cosa che synctest.Wait rende possibile senza dipendere da assunzioni di timing.

Regola 5: Mantieni i percorsi di cancellazione espliciti

Ogni goroutine in background dovrebbe avere un percorso di spegnimento, e i test dovrebbero dimostrare che quel percorso funziona cancellando il contesto o chiudendo il channel e poi verificando che la goroutine esca in modo pulito.

Pensieri finali

testing/synctest è una di quelle funzionalità Go che sembrano piccole ma cambiano come scrivi una classe di test. Non sostituisce un buon design della concorrenza, il race detector o la necessità di test di integrazione: ma rende molti test unitari asincroni più veloci, più puliti e molto meno dipendenti dalla fortuna del timing.

Questo è importante perché il codice concorrente è già abbastanza difficile. I test dovrebbero ridurre l’incertezza, non aggiungerla. Per una visione più ampia dei pattern Go di produzione attraverso integrazione, struttura del codice e accesso ai dati, vedi App Architecture in Production.

Il takeaway pratico è semplice:

Usa synctest per test unitari deterministici intorno a goroutine, timer, timeout, retry e cancellazione.
Tieni gli sleep reali fuori dai test concorrenti a meno che tu non abbia una ragione molto valida.

Quell’unica abitudine renderà molte suite di test Go più veloci e meno instabili.


I fatti importanti attuali sono: testing/synctest è diventato generalmente disponibile in Go 1.25, espone synctest.Test e synctest.Wait, esegue i test all’interno di una bolla isolata, e il tempo all’interno di quella bolla usa un orologio finto che avanza solo quando le goroutine sono bloccate in modo duraturo.

Fonti

Iscriviti

Ricevi nuovi articoli su sistemi, infrastruttura e ingegneria AI.