Test del codice Go concorrente con synctest

Smetti di dormire nei test Go concorrenti.

Indice

Testare il codice Go concorrente ha sempre richiesto un po’ di disciplina. Le goroutine sono leggere, i canali sono semplici e la cancellazione tramite context è un pattern idiomatico: i worker in background e i timer sono ovunque nei servizi Go reali.

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

Test del codice Go concorrente con synctest

Il solito cattivo pattern è familiare:

go doSomething()

time.Sleep(100 * time.Millisecond)

if !done {
	t.Fatal("lavoro in background non completato")
}

Questo test potrebbe passare sul tuo laptop e fallire in 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”.

Questa non è una buona pratica di test: è un azzardo basato su un timer, e quell’azzardo diventa sempre più costoso 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 essere eseguito all’interno di una bolla isolata, fornisce a quella bolla un orologio finto e offre un modo per attendere finché le goroutine all’interno della bolla non sono bloccate.

Il risultato è semplice ma potente:

  • Nessun sleep arbitrario
  • Test dei timeout più rapidi
  • Test concorrenti più deterministici
  • Migliore test della cancellazione del context
  • Migliore test delle goroutine in background
  • Meno test instabili (flaky) in CI

La versione leggermente opinata: 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 di Go per testare il 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 funziona contro quell’orologio finto anziché contro l’orologio reale (wall clock).

synctest.Wait attende finché tutte le altre goroutine nella bolla non sono durabilmente bloccate. Suona astratto, ma l’effetto pratico è facile da capire:

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

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

Perché i test Go concorrenti sono instabili

Se sei nuovo nel test di Go in generale, Go Unit Testing: Struttura e Best Practices copre il pacchetto di test, i test table-driven e i pattern di mock che costituiscono la base su cui questo articolo si basa. I test concorrenti sono generalmente instabili (flaky) per uno di tre motivi.

Primo, dipendono dal scheduler. Una goroutine potrebbe eseguirsi immediatamente sul tuo computer e in ritardo in CI.

Secondo, dipendono dal tempo reale. Un test che fa sleep per 50 millisecondi assume che 50 millisecondi siano sufficienti tempo perché il lavoro in background si completi.

Terzo, osservano lo stato troppo presto. Il test verifica il risultato prima che l’operazione in background 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("lavoro in background non completato")
	}
}

Questo test ha due problemi.

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

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

Puoi correggere questo esempio specifico con un canale o un sync.WaitGroup, e spesso dovresti farlo. Ma quando il codice in test usa timer, deadline di context, time.AfterFunc, worker in background o cleanup ritardati, il test può ancora diventare scomodo: ed è esattamente lì che testing/synctest aiuta.

L’idea di base: eseguire il test all’interno di 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) {
		// Testa il codice concorrente qui.
	})
}

All’interno della bolla:

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

La bolla non è magia. Non trasforma una cattiva progettazione concorrente in una buona. Ma fornisce al tuo test un ambiente controllato dove il comportamento di tempo e blocco è più deterministico.

Il problema con time.Sleep nei test

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

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

oppure:

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

Entrambi sono segnali di progettazione che vale la pena prendere sul serio: puntano a luoghi dove il codice di produzione potrebbe trarre beneficio da una migliore osservabilità o da meccanismi di coordinamento 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 sembrare questo:

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

	time.Sleep(6 * time.Second)

	select {
	case got := <-w.Result():
		if got != "done" {
			t.Fatalf("ricevuto %q, atteso done", got)
		}
	default:
		t.Fatal("worker non completato")
	}
}

Questo test aspetta 6 secondi reali.

Questo è lento. Se hai molti test come questo, 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("ricevuto %q, atteso done", got)
			}
		default:
			t.Fatal("worker non completato")
		}
	})
}

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

Testare i timeout del context

Uno dei migliori usi di testing/synctest è testare le deadline e i timeout di context.Context. Come la cancellazione del context e i budget di timeout dovrebbero essere strutturati attraverso i livelli del servizio è coperto in Go context.Context Fatto Bene, e la corretta propagazione di context.Canceled e context.DeadlineExceeded attraverso quei livelli è coperta in Architettura di Gestione Errori in Go: Confini e Pattern — synctest ti permette di verificare quel comportamento senza far passare tempo reale.

Ecco una semplice funzione che aspetta finché un context non viene 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 costringerebbe a cambiare il timeout solo per il test.

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

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 cancellato troppo presto: %v", err)
		default:
		}

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

		select {
		case err := <-done:
			if !errors.Is(err, context.DeadlineExceeded) {
				t.Fatalf("ricevuto %v, atteso %v", err, context.DeadlineExceeded)
			}
		default:
			t.Fatal("context non è stato cancellato")
		}
	})
}

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 context

Puoi anche testare la cancellazione esplicita senza competere con 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 cancellato troppo presto: %v", err)
		default:
		}

		cancel()
		synctest.Wait()

		select {
		case err := <-done:
			if !errors.Is(err, context.Canceled) {
				t.Fatalf("ricevuto %v, atteso %v", err, context.Canceled)
			}
		default:
			t.Fatal("context non è stato cancellato")
		}
	})
}

Il dettaglio importante è synctest.Wait.

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

Cosa fa synctest.Wait

synctest.Wait attende finché tutte le altre goroutine nella bolla non sono durabilmente bloccate.

In linguaggio normale, significa:

Aspetta finché le goroutine dentro 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 either completato 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("la goroutine non si è eseguita")
		}
	})
}

Questo è deliberatamente piccolo, ma dimostra l’idea.

synctest.Wait non è solo uno sleep più elegante: è un punto di sincronizzazione all’interno della bolla, e quella distinzione conta più 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.

Il secondo è molto migliore per i test perché descrive una condizione osservabile anziché una supposizione 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 è durabilmente bloccata e il tempo ha bisogno di muoversi in avanti 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("ricevuto %v, atteso %v", elapsed, time.Hour)
		}
	})
}

Legge come se aspettasse un’ora.

Non lo fa.

Questo è utile per testare:

  • timeout
  • deadline
  • retry
  • backoff
  • cleanup ritardato
  • limiti di frequenza (rate limits)
  • timer
  • tickers
  • cancellazione del context

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 all’esterno della bolla, synctest non può renderlo deterministico.

Testare un loop di retry

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

Ecco un piccolo helper per i 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 delay 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 delay 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("fallimento temporaneo")
			}
			return nil
		})

		if err != nil {
			t.Fatalf("Retry ha restituito errore: %v", err)
		}

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

Il test rappresenta due attese di 10 secondi.

Si esegue comunque rapidamente.

Questo è dove synctest cambia l’economia del testing. Non hai più bisogno di durate finte minuscole sparse nei test solo per evitare CI lente.

Testare la cancellazione dei retry

Puoi anche testare la cancellazione durante il delay di 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("fallimento temporaneo")
			})
		}()

		synctest.Wait()

		cancel()
		synctest.Wait()

		select {
		case err := <-errCh:
			if !errors.Is(err, context.Canceled) {
				t.Fatalf("ricevuto %v, atteso %v", err, context.Canceled)
			}
		default:
			t.Fatal("Retry non ha restituito dopo la cancellazione")
		}
	})
}

Questo test verifica che il loop di retry risponda alla cancellazione invece di fare sleep attraverso il delay.

Questo è esattamente il tipo di comportamento che conta in produzione.

Testare time.AfterFunc

time.AfterFunc è un altro buon candidato.

Supponi di avere una funzione che programma una 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("pulizia avvenuta troppo presto")
		default:
		}

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

		select {
		case <-cache.Cleaned():
		default:
			t.Fatal("pulizia non avvenuta")
		}
	})
}

Questo test verifica entrambi i lati:

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

E non aspetta un minuto reale.

Testare i tickers

I tickers possono anche essere testati con il tempo finto, ma fai attenzione. I tickers sono spesso usati in loop a lungo termine, e i loop a lungo termine 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 sembrare questo:

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, attesi 3", counter.Ticks())
		}
	})
}

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

Questo non è buono solo per i test. È buono per la produzione.

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

synctest e leak di goroutine

testing/synctest è utile qui perché synctest.Test attende che le goroutine nella bolla escano prima di tornare, il che significa che i leak di goroutine sono più difficili da ignorare. Se una goroutine in background non esce mai, il test fallisce invece di lasciare silenziosamente lavoro indietro: ed è una buona cosa.

Il codice concorrente dovrebbe avere una proprietà chiara. Se una funzione avvia una goroutine, ci dovrebbe essere un modo esplicito per fermarla, o una ragione documentata per cui le è consentito vivere per sempre. Nei test, “per sempre” è quasi mai accettabile. La proprietà più un limite alla concorrenza è l’abitudine di progettazione con cui questo articolo si abbina a: Limitare la Concorrenza in Go copre i pattern lato produzione i cui percorsi di leak una bolla cattura in millisecondi.

Un buon pattern è:

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

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

Cosa significa “durabilmente bloccato” nella pratica

I documenti ufficiali usano il termine “durably blocked” (durabilmente bloccato).

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

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

Gli esempi includono:

  • ricezione da un canale creato all’interno della bolla
  • invio a un canale creato all’interno della bolla
  • attesa su un sync.WaitGroup associato alla bolla
  • sleep con time.Sleep
  • attesa su alcune operazioni di timer

Alcune cose non sono durabilmente bloccate perché qualcosa all’esterno della bolla potrebbe sbloccarle.

Gli esempi includono:

  • I/O di rete
  • chiamate di sistema
  • operazioni di processi esterni
  • alcune attese su mutex
  • interazioni con goroutine all’esterno della bolla

Questo è perché i test synctest dovrebbero essere auto-contenuti e tenuti liberi da sincronizzazioni esterne che la bolla non può vedere. Non usare synctest come wrapper attorno a test di integrazione che parlano con la rete reale.

Per cosa synctest è buono

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

I buoni candidati includono:

  • cancellazione del context
  • timeout del context
  • loop di retry
  • logica di backoff
  • cleanup ritardato
  • worker guidati da timer
  • loop guidati da ticker
  • goroutine in background
  • comportamento di timeout
  • coordinamento di canali
  • time.AfterFunc
  • attesa deterministica per goroutine

Il caso d’uso migliore è il codice dove la parte difficile è il tempo o la schedulazione, non l’I/O esterno.

Per cosa synctest non è buono

testing/synctest non è un sostituto per tutti i test di 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 deterministico l’I/O di rete reale.

Non corregge una cattiva progettazione del ciclo di vita delle goroutine.

Non significa che puoi ignorare canali, context, proprietà e spegnimento.

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

Usa altri strumenti per altri livelli:

  • usa go test -race per rilevare data race
  • usa test di integrazione per dipendenze reali
  • usa test di carico per throughput e contention
  • usa benchmark per le performance
  • 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 di memoria concorrenti non sicuri.

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

Dovresti spesso usarne entrambi.

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

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 dà. Abbinandolo a Go Linters: Strumenti Essenziali per la Qualità del Codice ti dà una solida base di analisi statica e verifica runtime per qualsiasi codebase concorrente.

synctest vs orologi finti manuali

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

Questo può ancora essere una buona progettazione.

Un’interfaccia per l’orologio manuale potrebbe sembrare 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à un controllo esplicito, ma ha un costo:

  • più interfacce
  • più cablaggi (plumbing)
  • più astrazioni solo per test
  • più modi in cui il codice può accidentalmente aggirare l’orologio finto

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

Questo 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 della tua progettazione di dominio o quando hai bisogno di un 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 Iniezione delle Dipendenze in Go: Pattern e Best Practices.

synctest vs canali e WaitGroups

Non sostituire la buona sincronizzazione con synctest.

Se il tuo codice può esporre un canale di completamento, un callback o un metodo Wait, è spesso una buona progettazione.

Per esempio:

type Server struct {
	done chan struct{}
}

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

Un test può attendere su quello direttamente.

synctest è più utile quando il comportamento in test comporta tempo, deadline di context, schedulazione in background o callback asincroni.

I migliori test spesso combinano entrambi:

  • il codice di produzione ha segnali espliciti di spegnimento o completamento
  • 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 piana è più chiara, e aggiungere il wrapper della bolla introduce solo meccanismi non necessari che rendono i test più difficili da leggere e ragionare.

Errore 2: Testare I/O di rete reale all’interno della bolla

Tieni i test synctest auto-contenuti. 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 all’interno di una bolla synctest. Usa falsi per i test unitari e riserva le dipendenze reali per test di integrazione separati dove l’isolamento della bolla non si applica. Quando la dipendenza è un handler HTTP, httptest.NewTestServer di Go 1.27 rimuove del tutto la necessità di un socket reale: esegue il server su una rete finta in memoria, che è esattamente ciò di cui ha bisogno la bolla, e si abbina a synctest per un timing deterministico di round-trip come mostrato nella guida all’aggiornamento a Go 1.27.

Errore 3: Creare leak di goroutine

Se il tuo test avvia una goroutine, assicurati che abbia un chiaro percorso di uscita. Usa la cancellazione del context, canali chiusi o metodi di stop espliciti: una goroutine che non si ferma mai è sia un cattivo odore in produzione che un cattivo odore nei test che synctest farà emergere invece di nascondere.

Errore 4: Dipendere da stato a livello di pacchetto

I canali, i timer e i WaitGroups 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 definita al test.

Errore 5: Trattare il tempo finto come tempo reale

Il tempo finto è per test deterministici, non per la misurazione delle performance. Un test che fa avanzare un’ora istantaneamente non ti dice nulla di utile sul costo CPU, sulla contention dei lock, sull’uso della memoria o sul comportamento di schedulazione 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 gli accessi di 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 tickers
  • il codice usa deadline di context
  • il codice ha comportamento di retry o backoff
  • il test usa attualmente sleep arbitrari
  • il test è instabile in CI
  • il test è lento perché aspetta 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 è in realtà un test di integrazione
  • stai cercando di misurare le performance
  • il codice non ha un percorso di spegnimento pulito

Preferisci questo pattern

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

		// Agisci.
		_ = ctx

		// Lascia assestare il lavoro in background.
		synctest.Wait()

		// Avanza il tempo finto se necessario.
		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 assesti
  • avanza il tempo finto solo quando necessario
  • asserisci dopo la sincronizzazione

Dove usare testing/synctest in progetti reali

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

Cerca test con questi cattivi 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 tempo reale?
  • Questo test è instabile perché assume che una goroutine si sia già eseguita?
  • Questo test può essere isolato dalla rete e dai processi esterni?
  • La goroutine in background può essere fermata pulitamente?
  • Il tempo finto renderebbe l’asserzione più chiara?

I buoni candidati spesso si trovano in:

  • pacchetti di worker
  • pacchetti di retry
  • pacchetti di cache
  • pacchetti di scheduler
  • consumatori di code
  • wrapper di client HTTP
  • middleware di timeout
  • codice di cleanup in background
  • codice di limitazione di frequenza (rate-limiting)

Inizia con un test instabile. Non migrare l’intera codebase in una volta sola. Se la tua suite di test usa test table-driven paralleli accanto al codice asincrono, Test Table-Driven Paralleli in Go copre i pattern di t.Parallel() e le trappole delle race condition che si abbinano naturalmente all’approccio synctest.

Esempio: prima e dopo

Ecco un cattivo test 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("fallimento temporaneo")
		}
		return nil
	})

	if err != nil {
		t.Fatalf("Retry ha restituito errore: %v", err)
	}

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

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

Questo potrebbe non sembrare male, ma moltiplicalo 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("fallimento temporaneo")
			}
			return nil
		})

		if err != nil {
			t.Fatalf("Retry ha restituito errore: %v", err)
		}

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

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

Come adottare synctest in sicurezza

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 tutta la codebase.

Passo 2: Scegli un pacchetto

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

Passo 3: Converti un test

Avvolgi il test in synctest.Test e sostituisci gli sleep arbitrari con synctest.Wait, sleep a tempo finto o sincronizzazione esplicita. La conversione è di solito 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 privo di 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 ce l’ha, synctest.Test farà emergere 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 valeva la pena.

Le mie regole opinionate

Usale come regole pratiche.

Regola 1: Nessun sleep arbitrario nei test unitari concorrenti

Uno sleep che aspetta che una goroutine finisca magari è un cattivo odore. Sostituisco con canali, WaitGroup, callback, synctest.Wait o tempo finto: qualsiasi cosa che aspetti una condizione invece di sperare che sia passato abbastanza tempo.

Regola 2: Tieni i test synctest auto-contenuti

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

Regola 3: Non usare synctest come wrapper per 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 è costringere una goroutine a eseguirsi. 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 sul timing.

Regola 5: Mantieni i percorsi di cancellazione espliciti

Ogni goroutine in background dovrebbe avere un percorso di spegnimento, e i test dovrebbero provare che quel percorso funzioni cancellando il context o chiudendo il canale e poi verificando che la goroutine esca pulitamente.

Pensieri finali

testing/synctest è una di quelle funzionalità di Go che sembra piccola ma cambia il modo in cui scrivi una classe di test. Non sostituisce una buona progettazione concorrente, 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 conta perché il codice concorrente è già abbastanza difficile. I test dovrebbero ridurre l’incertezza, non aggiungerci. Per una visione più ampia dei pattern di produzione Go attraverso integrazione, struttura del codice e accesso ai dati, vedi Architettura di App in Produzione.

La lezione pratica è semplice:

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

Quella singola abitudine renderà molte suite di test Go più veloci e meno instabili.


I fatti correnti importanti 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 durabilmente bloccate.

Fonti

Iscriviti

Ricevi nuovi articoli su sistemi, infrastruttura e ingegneria AI.