Concurrent Go-code testen met synctest

Stop met slapen in concurrente Go-testen.

Inhoud

Het testen van concurrente Go-code vereiste altijd een zekere discipline. Goroutines zijn goedkoop, kanalen (channels) zijn eenvoudig en contextannulering is idiomatisch — achtergrondwerkers en timers zijn overal te vinden in echte Go-services.

Maar het betrouwbaar testen van al deze aspecten is lastiger dan het schrijven ervan.

Testing concurrent Go code with synctest

Het gebruikelijke slechte patroon is bekend:

go doSomething()

time.Sleep(100 * time.Millisecond)

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

Die test kan lokaal op je laptop slagen, maar falen in CI. Of hij kan zes maanden werken en dan falen op een belaste runner. Of hij kan traag zijn omdat iemand de sleep-tijd van 100 milliseconden naar 2 seconden heeft verhoogd “om op de veilige kant te zijn”.

Dit is geen goed testen — het is gokken met een timer, en dat gokje wordt duurder naarmate de testsuite groter wordt.

Het pakket testing/synctest biedt Go-ontwikkelaars een betere manier om veel vormen van asynchrone en tijdafhankelijke code te testen. Het laat een test binnen een geïsoleerde bubbel draaien, geeft die bubbel een nep-klok en biedt een manier om te wachten tot goroutines binnen de bubbel geblokkeerd zijn.

Het resultaat is eenvoudig maar krachtig:

  • Geen willekeurige sleeps
  • Snellere timeout-tests
  • Meer deterministische concurrente tests
  • Betere tests voor contextannulering
  • Betere tests voor achtergrondgoroutines
  • Minder onbetrouwbare CI

De iets eigenzinnige versie: als je concurrente Go-test afhangt van een echte time.Sleep, zou je die test waarschijnlijk als verdacht moeten beschouwen.

Wat testing/synctest is

testing/synctest is een pakket van de Go-standaardbibliotheek voor het testen van concurrente code.

Het biedt twee hoofdfuncties:

package synctest

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

synctest.Test voert een functie uit binnen een geïsoleerde testbubbel. Alle goroutines die binnen die bubbel worden gestart, maken ook deel uit van de bubbel, de tijd binnen de bubbel is nep en het time-pakket werkt tegen die nep-klok in plaats van de echte wandklok.

synctest.Wait wacht tot alle andere goroutines in de bubbel duurzaam geblokkeerd zijn. Dat klinkt abstract, maar het praktische effect is eenvoudig te begrijpen:

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

Dit zorgt niet dat je test 10 echte seconden wacht. Binnen de synctest-bubbel kan tijd onmiddellijk vooruitgaan wanneer de bubbel geblokkeerd is en wacht op het vooruitgaan van de tijd — dat is de kerntruc achter het pakket.

Waarom concurrente Go-tests onbetrouwbaar zijn

Als je nieuw bent in Go-testen in het algemeen, behandelt Go Unit Testing: Structure & Best Practices het testing-pakket, tabel-gedreven tests en mock-patronen die de basis vormen waarop dit artikel voortbouwt. Concurrente tests zijn meestal onbetrouwbaar om één van drie redenen.

Ten eerste zijn ze afhankelijk van de scheduler. Een goroutine kan onmiddellijk draaien op je machine, maar later op CI.

Ten tweede zijn ze afhankelijk van echte tijd. Een test die 50 milliseconden slaapt, gaat ervan uit dat 50 milliseconden genoeg tijd is voor het achtergrondwerk om af te ronden.

Ten derde observeren ze de staat te vroeg. De test controleert het resultaat voordat de achtergrondoperatie daadwerkelijk is voltooid.

Hier is een eenvoudig voorbeeld:

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

Deze test heeft twee problemen.

Het voor de hand liggende probleem is de sleep. Er is geen garantie dat 10 milliseconden de juiste hoeveelheid tijd is.

Het minder voor de hand liggende probleem is de data race. De test schrijft done in één goroutine en leest hem in een andere zonder synchronisatie.

Je kunt dit specifieke voorbeeld oplossen met een kanaal of een sync.WaitGroup, en vaak moet je dat ook. Maar wanneer de code die getest wordt timers, contextdeadlines, time.AfterFunc, achtergrondwerkers of vertraagde opschoning gebruikt, kan de test nog steeds omslachtig worden — en daar helpt testing/synctest precies bij.

Het kernidee: voer de test uit binnen een bubbel

Een synctest-bubbel isoleert de goroutines die erin worden aangemaakt.

Gebruik het als volgt:

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

Binnen de bubbel:

  • Goroutines die door de test worden gestart, behoren tot de bubbel.
  • Timers en sleeps gebruiken een nep-klok.
  • synctest.Wait kan wachten tot achtergrondactiviteit is afgevlakt.
  • De test moet vermijden af te haken op externe goroutines, echt netwerk-I/O of externe processen.

De bubbel is geen magie. Het maakt slecht concurrentieontwerp niet goed. Maar het geeft je test een gecontroleerde omgeving waarin tijd en blokkerend gedrag deterministischer zijn.

Het probleem met time.Sleep in tests

Een echte time.Sleep in een test betekent meestal één van twee dingen:

Ik weet niet hoe ik moet wachten op het evenement waar ik daadwerkelijk om geeff.

of:

Ik weet waar ik om geeff, maar de code die getest wordt, biedt geen schone manier om dit te observeren.

Beide zijn ontwerpsignalen die serieus genomen moeten worden — ze wijzen op plekken waar de productcode kan profiteren van schoner waarneembare observability of explicite coördinatiemechanismen.

Overweeg een functie die werk in de achtergrond voltooit:

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
}

Een slechte test zou er zo uit kunnen zien:

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

Deze test wacht zes echte seconden.

Dat is traag. Als je veel tests zoals deze hebt, wordt de suite pijnlijk.

Een betere test met synctest kan nep-tijd onmiddellijk laten vooruitgaan:

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

De test drukt nog steeds het bedrijfsfeit uit — de worker moet na 5 seconden klaar zijn — maar het besteedt geen 5 echte seconden aan het doen ervan. Dat is het verschil tussen het testen van tijdafhankelijk gedrag en het verspillen van ontwikkeltijd.

Testen van contexttimeouts

Een van de beste toepassingen voor testing/synctest is het testen van context.Context-deadlines en timeouts. Het correct doorgeven van context.Canceled en context.DeadlineExceeded door service- en handlerlagen wordt diep behandeld in Go Error Handling Architecture: Boundaries and Patterns — synctest stelt je in staat om dat gedrag te verifiëren zonder dat echte tijd verstrijkt.

Hier is een eenvoudige functie die wacht tot een context geannuleerd is:

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

Zonder synctest zou het testen van dit met een timeout van 30 seconden de test ofwel traag maken, of je dwingen de timeout alleen voor de test te wijzigen.

Met synctest kun je de echte timeoutduur snel testen:

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

Dit is het soort test dat synctest aangenamer maakt.

Je kunt realistische timeoutwaarden in de code behouden en toch snel testen.

Testen van contextannulering

Je kunt ook expliciete annulering testen zonder de achtergrondgoroutine te racen.

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

Het belangrijke detail is synctest.Wait.

Het geeft de achtergrondgoroutine een kans om annulering waar te nemen en af te vlakken voordat de test het resultaat controleert.

Wat synctest.Wait doet

synctest.Wait wacht tot alle andere goroutines in de bubbel duurzaam geblokkeerd zijn.

In gewone taal betekent het:

Wacht tot de goroutines binnen deze test een stabiel geblokkeerd punt hebben bereikt.

Dit is handig wanneer de test een goroutine start en moet weten of de goroutine is afgerond of wacht.

Bijvoorbeeld:

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

Dit is opzettelijk klein, maar het demonstreert het idee.

synctest.Wait is geen mooiere sleep — het is een synchronisatiepunt binnen de bubbel, en dat onderscheid is belangrijker dan het op het eerste gezicht lijkt.

Een sleep zegt:

Ik hoop dat er genoeg tijd is verstreken.

Wait zegt:

Ik wil dat de bubbel een stabiel geblokkeerde staat bereikt.

Het tweede is veel beter voor tests omdat het een waarneembare staat beschrijft in plaats van een gok over verstreekte tijd.

Nep-tijd in een synctest-bubbel

Binnen een synctest-bubbel gebruikt het time-pakket een nep-klok.

De nep-klok begint op een vaste tijd. Hij gaat alleen vooruit wanneer elke goroutine in de bubbel duurzaam geblokkeerd is en tijd vooruit moet gaan om iets te ontblokkeren.

Dat betekent dat deze test snel is:

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

Het leest alsof het een uur wacht.

Het doet dat niet.

Dit is handig voor het testen van:

  • timeouts
  • deadlines
  • retries
  • backoff
  • vertraagde opschoning
  • rate limits
  • timers
  • tickers
  • contextannulering

Maar er is één belangrijke regel: nep-tijd helpt alleen code die het time-pakket binnen de bubbel gebruikt.

Als je code afhangt van een extern systeem, echt netwerk-I/O of tijd gemeten buiten de bubbel, kan synctest dat niet deterministisch maken.

Testen van een retry-loop

Retry-loops zijn een veelvoorkomende bron van trage en onbetrouwbare tests.

Hier is een kleine retry-hulpfunctie:

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
}

Een normale test zou de vertraging mogelijk verlagen naar 1 milliseconde om de suite snel te houden.

Dat is niet erg, maar het betekent dat de test niet langer de echte waarde gebruikt door productcode.

Met synctest kun je de echte vertraging behouden:

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

De test vertegenwoordigt twee wachttijden van 10 seconden.

Het draait nog steeds snel.

Hier verandert synctest de economie van testen. Je hoeft niet langer nep-kleine duurten door tests te spreiden om trage CI te vermijden.

Testen van retry-annulering

Je kunt ook annulering tijdens de retry-vertraging testen:

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

Deze test controleert of de retry-loop reageert op annulering in plaats van door de vertraging te slapen.

Dat is precies het soort gedrag dat in productie belangrijk is.

Testen van time.AfterFunc

time.AfterFunc is een andere goede fit.

Stel dat je een functie hebt die opschoning scant:

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
}

De test kan nep-tijd laten vooruitgaan:

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

Deze test verifieert beide kanten:

  • De opschoning gebeurt niet voor de vertraging.
  • De opschoning gebeurt na de vertraging.

En het wacht niet een echte minuut.

Testen van tickers

Tickers kunnen ook met nep-tijd getest worden, maar wees voorzichtig. Tickers worden vaak gebruikt in langlopende loops, en langlopende loops hebben een schone afsluitpad nodig.

Hier is een kleine op ticker-gebaseerde teller:

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
}

Een test zou er zo uit kunnen zien:

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

Dit voorbeeld heeft een opzettelijk ontwerppunt: de worker heeft een afsluitpad.

Dat is niet alleen goed voor tests. Het is goed voor productie.

Tests onthullen vaak of je goroutines daadwerkelijk kunnen stoppen.

synctest en goroutine-leaks

testing/synctest is hier handig omdat synctest.Test wacht tot goroutines in de bubbel zijn afgerond voordat het terugkeert, wat betekent dat gelekte goroutines moeilijker te negeren zijn. Als een achtergrondgoroutine nooit afsluit, faalt de test in plaats van stil werk achter te laten — en dat is een goede zaak.

Concurrente code moet duidelijk eigendom hebben. Als een functie een goroutine start, moet er een expliciete manier zijn om deze te stoppen, of een gedocumenteerde reden waarom het voor altijd mag leven. In tests is “voor altijd” bijna nooit aanvaardbaar.

Een goed patroon is:

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

Maak de goroutine dan stoppen wanneer de context geannuleerd is.

Wat “duurzaam geblokkeerd” in de praktijk betekent

De officiële documentatie gebruikt de term “duurzaam geblokkeerd”.

Je hoeft niet elk runtime-detail te memoriseren, maar je moet de praktische betekenis begrijpen.

Een goroutine is duurzaam geblokkeerd wanneer het op een manier geblokkeerd is die alleen ontblokt kan worden door iets binnen dezelfde synctest-bubbel.

Voorbeelden zijn:

  • ontvangen van een kanaal dat binnen de bubbel is aangemaakt
  • verzenden naar een kanaal dat binnen de bubbel is aangemaakt
  • wachten op een sync.WaitGroup geassocieerd met de bubbel
  • slapen met time.Sleep
  • wachten op bepaalde timeroperaties

Sommige dingen zijn niet duurzaam geblokkeerd omdat iets buiten de bubbel ze kan ontblokken.

Voorbeelden zijn:

  • netwerk-I/O
  • systeemcalls
  • externe procesoperaties
  • sommige mutex-wachten
  • interacties met goroutines buiten de bubbel

Daarom moeten synctest-tests zelfstandig zijn en vrij gehouden worden van externe synchronisatie die de bubbel niet kan zien. Gebruik synctest niet als een wrapper rond integratietests die met het echte netwerk praten.

Waar synctest goed voor is

testing/synctest is vooral goed voor unittests rond asynchroon gedrag.

Goede kandidaten zijn:

  • contextannulering
  • contexttimeouts
  • retry-loops
  • backoff-logica
  • vertraagde opschoning
  • timer-gestuurde workers
  • ticker-gestuurde loops
  • achtergrondgoroutines
  • timeout-gedrag
  • kanaalcoördinatie
  • time.AfterFunc
  • deterministisch wachten op goroutines

Het beste gebruik is code waarbij het moeilijke deel tijd of scheduling is, niet externe I/O.

Waar synctest niet goed voor is

testing/synctest is geen vervanging voor alle concurrentietests.

Het is geen volledige deterministische scheduler voor elke mogelijke race.

Het is geen vervanging voor de race-detector.

Het is geen vervanging voor integratietests.

Het maakt echt netwerk-I/O niet deterministisch.

Het lost slecht goroutine-levenscyclusontwerp niet op.

Het betekent niet dat je kanalen, contexten, eigendom en afsluiting kunt negeren.

Gebruik synctest voor de juiste laag: deterministische unittests voor concurrent en tijdafhankelijk gedrag.

Gebruik andere tools voor andere lagen:

  • gebruik go test -race om data races te detecteren
  • gebruik integratietests voor echte afhankelijkheden
  • gebruik loadtests voor doorvoer en concurrentie
  • gebruik benchmarks voor prestaties
  • gebruik tracing en profiling voor productgedrag

synctest vs de race-detector

testing/synctest en de race-detector lossen verschillende problemen op.

De race-detector vindt onveilige concurrente geheugentoegang.

synctest helpt je om asynchrone timing en wachten in tests te controleren.

Je zou ze vaak samen moeten gebruiken.

Bijvoorbeeld, dit is nog steeds een race, zelfs binnen een synctest-bubbel, als er geen juiste synchronisatie is:

value := 0

go func() {
	value = 1
}()

_ = value

synctest.Wait kan een synchronisatiepunt bieden voor sommige testpatronen, maar het betekent niet dat elke concurrente toegang in je code automatisch veilig is.

Voer concurrente tests uit met:

go test -race ./...

De race-detector is nog steeds een van de beste tools die Go je biedt. Het combineren met Go Linters: Essential Tools for Code Quality geeft je een solide basis voor statische analyse en runtime-controle voor elke concurrente codebase.

synctest vs handmatige nep-klokken

Voor testing/synctest gebruikten veel teams handmatige nep-klokken.

Dat kan nog steeds een goed ontwerp zijn.

Een handmatige klokinterface zou er zo uit kunnen zien:

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

Dan gebruikt productcode een echte klok en tests een nep-klok.

Dit geeft expliciete controle, maar het heeft een kosten:

  • meer interfaces
  • meer pluggenwerk
  • meer test-specifieke abstracties
  • meer manieren waarop code de nep-klok per ongeluk kan omzeilen

synctest is aantrekkelijk omdat gewone code die het time-pakket gebruikt, tegen nep-tijd kan draaien binnen de testbubbel.

Dat vermindert de behoefte aan klokinjectie in veel gevallen.

Mijn mening: gebruik synctest wanneer het productcode eenvoudiger houdt. Gebruik een geïnjecteerde klok alleen wanneer klokcontrole deel uitmaakt van je domeinontwerp of wanneer je controle nodig hebt buiten wat synctest biedt. Voor een bredere kijk op afhankelijkheidsinjectiepatronen in Go — inclusief wanneer en hoe je testbare abstracties injecteert — zie Dependency Injection in Go: Patterns & Best Practices.

synctest vs kanalen en WaitGroups

Vervang goede synchronisatie niet met synctest.

Als je code een voltooiingskanaal, een callback of een Wait-methode kan blootstellen, is dat vaak goed ontwerp.

Bijvoorbeeld:

type Server struct {
	done chan struct{}
}

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

Een test kan daar direct op wachten.

synctest is het meest nuttig wanneer het gedrag dat getest wordt tijd, contextdeadlines, achtergrondscheduling of asynchrone callbacks omvat.

De beste tests combineren vaak beide:

  • productcode heeft expliciete afsluit- of voltooiingssignalen
  • synctest verwijdert echte tijdwachting
  • Wait maakt achtergrondactiviteit deterministisch

Veelgemaakte fouten

Fout 1: Elke test wrappen in synctest

Gebruik synctest niet overal. Als de code synchron is, is een gewone testfunctie duidelijker, en het toevoegen van de bubbelwrapper introduceert alleen onnodige mechaniek die tests moeilijker leesbaar en begrijpelijk maakt.

Fout 2: Testen van echt netwerk-I/O binnen de bubbel

Houd synctest-tests zelfstandig. Als je test een echt netwerksocket, externe service, database of subproces gebruikt, hoort het bij een integratietest in plaats van binnen een synctest-bubbel. Gebruik fakes voor unittests en behoud echte afhankelijkheden voor aparte integratietests waar bubbelisolatie niet van toepassing is.

Fout 3: Goroutines lekken

Als je test een goroutine start, zorg ervoor dat deze een duidelijk afsluitpad heeft. Gebruik contextannulering, gesloten kanalen of expliciete stopmethoden — een goroutine die nooit stopt is zowel een productgeur als een testgeur die synctest zal oppikken in plaats van verbergen.

Fout 4: Afhankelijk zijn van pakket-niveau staat

Kanalen, timers en WaitGroups op pakket-niveau kunnen bubbelisolatie op subtiele manieren breken. Geef de voorkeur aan het aanmaken van alle teststaat binnen de synctest.Test-functie, zodat elke resource tot de bubbel behoort en zijn levensduur duidelijk is gekoppeld aan de test.

Fout 5: Nep-tijd behandelen als echte tijd

Nep-tijd is voor deterministische tests, niet voor prestatiemeting. Een test die onmiddellijk één uur vooruitgaat, vertelt je niets nuttigs over CPU-kosten, slotconcurrentie, geheugengebruik of echt scheduler-gedrag in productie — gebruik benchmarks en loadtests voor die vragen.

Fout 6: De race-detector negeren

synctest is geen vervanging voor go test -race, en de twee tools lossen verschillende problemen op. Voer de race-detector uit naast je synctest-tests om onveilige concurrente geheugentoegang te vangen die de bubbel alleen niet kan detecteren.

Een praktische checklist

Gebruik deze checklist bij het schrijven van tests met testing/synctest.

Gebruik synctest wanneer

  • de code goroutines start
  • de code time.Sleep gebruikt
  • de code timers of tickers gebruikt
  • de code contextdeadlines gebruikt
  • de code retry- of backoff-gedrag heeft
  • de test momenteel willekeurige sleeps gebruikt
  • de test onbetrouwbaar is in CI
  • de test traag is omdat het wacht op echte tijd

Vermijd synctest wanneer

  • de code synchron is
  • de test afhangt van echt netwerk-I/O
  • de test afhangt van externe processen
  • de test eigenlijk een integratietest is
  • je prestaties probeert te meten
  • de code geen schone afsluitpad heeft

Geef de voorkeur aan dit patroon

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

Dit patroon is eenvoudig:

  • instellen binnen de bubbel
  • werk starten binnen de bubbel
  • wachten tot achtergrondactiviteit is afgevlakt
  • nep-tijd alleen laten vooruitgaan wanneer nodig
  • asserteren na synchronisatie

Waar testing/synctest te gebruiken in echte projecten

De beste plekken om te zoeken zijn meestal niet in eenvoudige bedrijfslogica.

Zoek naar tests met deze geuren:

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

Vraag dan:

  • Is deze test traag omdat het wacht op echte tijd?
  • Is deze test onbetrouwbaar omdat het ervan uitgaat dat een goroutine al heeft gelopen?
  • Kan deze test geïsoleerd worden van netwerk en externe processen?
  • Kan de achtergrondgoroutine schoon worden gestopt?
  • Zou nep-tijd de assertie duidelijker maken?

Goede kandidaten leven vaak in:

  • worker-pakketten
  • retry-pakketten
  • cache-pakketten
  • scheduler-pakketten
  • wachtrijconsumenten
  • HTTP-clientwrappers
  • timeout-middleware
  • achtergrondopschoningscode
  • rate-limiting-code

Begin met één onbetrouwbare test. Migreer niet de hele codebase in één keer. Als je testsuite parallelle tabel-gedreven tests gebruikt naast asynchrone code, behandelt Parallel Table-Driven Tests in Go de t.Parallel()-patronen en race-conditievalkuilen die natuurlijk combineren met de synctest-aanpak.

Voorbeeld: voor en na

Hier is een realistisch slechte test:

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

Dit wacht ongeveer één seconde omdat twee retry-vertragingen optreden.

Dat klinkt misschien niet erg, maar vermenigvuldig het met veel tests en meerdere pakketten. Trage tests zorgen ervoor dat ontwikkelaars minder vaak testen.

Nu de synctest-versie:

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

De test behoudt de echte vertragingwaarde, de suite blijft snel en de intentie is duidelijker. Dat is de hoofdwinst van testing/synctest.

Hoe synctest veilig te adopteren

Ik zou het geleidelijk adopteren.

Stap 1: Vind onbetrouwbare of trage concurrente tests

Zoek naar echte sleeps en timeout-zware tests. De grep-commando’s in het vorige gedeelte zijn een goed startpunt om kandidaten in de hele codebase te identificeren.

Stap 2: Kies één pakket

Kies een pakket dat duidelijk asynchroon gedrag heeft, maar geen echte externe services vereist. Worker-pakketten, retry-hulpen en timer-gestuurde componenten zijn ideale eerste doelwitten.

Stap 3: Converteer één test

Wrap de test in synctest.Test en vervang willekeurige sleeps door synctest.Wait, nep-tijd-sleeps of expliciete synchronisatie. De conversie is meestal klein — het moeilijkste deel is ervoor te zorgen dat goroutines schone afsluitpaden hebben.

Stap 4: Voer uit met de race-detector

Voer altijd go test -race ./... uit na conversie. Een slagen synctest-test betekent niet dat de code race-vrij is; het betekent alleen dat de asynchrone timing nu deterministisch is.

Stap 5: Beoordeel goroutine-levenscyclus

Zorg ervoor dat elke goroutine die door de test is gestart, een manier heeft om af te sluiten voordat de bubbel sluit. Als dat niet zo is, zal synctest.Test het lek oppikken in plaats het stil te negeren.

Stap 6: Herhaal alleen waar het de duidelijkheid verbetert

Converteer tests niet alleen maar om de mode te volgen. Een goede synctest-test moet meetbaar sneller, duidelijker leesbaar of minder onbetrouwbaar zijn dan de versie die het verving — als dat niet zo is, was de conversie het niet waard.

Mijn eigenzinnige regels

Gebruik deze als praktische vuistregels.

Regel 1: Geen willekeurige sleeps in concurrente unittests

Een sleep die wacht op een goroutine die misschien afgerond is, is een geur. Vervang het door kanalen, WaitGroups, callbacks, synctest.Wait of nep-tijd — alles wat wacht op een conditie in plaats van hopen dat er genoeg tijd is verstreken.

Regel 2: Houd synctest-tests zelfstandig

Maak goroutines, kanalen, contexten, timers en workers binnen de bubbel aan. Vermijd gedeelde staat op pakket-niveau, die tussen tests kan lekken en de isolatie breekt die synctest nuttig maakt.

Regel 3: Gebruik synctest niet als een integratietest-wrapper

Als de test communiceert met een echte database, echt netwerk of extern proces, houd het buiten synctest tenzij je een zeer specifieke reden hebt om dat te doen.

Regel 4: Test gedrag, niet scheduler-geluk

Het doel is niet om een goroutine dwingend te laten draaien. Het doel is om waarneembaar gedrag te verifiëren nadat het systeem een zinvolle staat heeft bereikt, wat synctest.Wait mogelijk maakt zonder af te haken op timing-aannames.

Regel 5: Houd annuleringspaden expliciet

Elke achtergrondgoroutine moet een afsluitpad hebben, en tests moeten bewijzen dat dit pad werkt door de context te annuleren of het kanaal te sluiten en vervolgens te verifiëren dat de goroutine schoon afsluit.

Eindgedachten

testing/synctest is een van die Go-functies die klein lijkt, maar verandert hoe je een klasse van tests schrijft. Het vervangt niet goed concurrentieontwerp, de race-detector of de behoefte aan integratietests — maar het maakt veel asynchrone unittests sneller, schoner en veel minder afhankelijk van timing-geluk.

Dat is belangrijk omdat concurrente code al moeilijk genoeg is. Tests moeten onzekerheid verminderen, niet toevoegen. Voor een bredere kijk op productie-GO-patronen across integratie, code-structuur en data-toegang, zie App Architecture in Production.

De praktische boodschap is eenvoudig:

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.

Die ene gewoonte zal veel Go-testsuites sneller en minder onbetrouwbaar maken.


De belangrijke huidige feiten zijn: testing/synctest werd algemeen beschikbaar in Go 1.25, het exposeert synctest.Test en synctest.Wait, het voert tests uit binnen een geïsoleerde bubbel, en tijd binnen die bubbel gebruikt een nep-klok die alleen vooruitgaat wanneer goroutines duurzaam geblokkeerd zijn.

Bronnen

Abonneren

Ontvang nieuwe berichten over systemen, infrastructuur en AI-engineering.