Konkurrenzfähigen Go-Code mit synctest testen
Hört auf, in koncurrenten Go-Tests zu schlafen.
Das Testen von konCURRENTEN Go-Code hat immer ein Mindestmaß an Disziplin erfordert. Goroutines sind günstig, Kanäle sind einfach, und das Abbrechen von Kontexten ist idiomatisch – Hintergrundworker und Timer sind in echten Go-Diensten allgegenwärtig.
Aber all das zuverlässig zu testen, ist schwieriger, als es zu schreiben.

Das übliche schlechte Muster ist bekannt:
go doSomething()
time.Sleep(100 * time.Millisecond)
if !done {
t.Fatal("Hintergrundarbeit wurde nicht abgeschlossen")
}
Dieser Test könnte auf Ihrem Laptop erfolgreich sein und in der CI-Umgebung fehlschlagen. Oder er könnte sechs Monate lang funktionieren und dann auf einem belasteten Runner scheitern. Oder er könnte langsam sein, weil jemand die Sleep-Zeit „nur für den Fall der Fälle" von 100 Millisekunden auf 2 Sekunden erhöht hat.
Das ist kein gutes Testen – das ist Glücksspiel mit einem Timer, und dieses Wagnis wird teurer, je größer die Testsuite wird.
Das Paket testing/synctest bietet Go-Entwicklern eine bessere Möglichkeit, viele Formen von asynchronem und zeitabhängigem Code zu testen. Es erlaubt einem Test, in einer isolierten „Blase" (Bubble) zu laufen, gibt dieser Blase eine künstliche Uhr und bietet eine Möglichkeit, darauf zu warten, bis die Goroutines innerhalb der Blase blockiert sind.
Das Ergebnis ist einfach, aber wirkungsvoll:
- Keine willkürlichen Sleeps
- Schnellere Timeout-Tests
- Deterministischere Concurrent-Tests
- Besseres Testen von Kontext-Abbrüchen
- Besseres Testen von Hintergrund-Goroutines
- Weniger „flaky" (unzuverlässige) CI-Läufe
Die etwas meinungsstärkere Version: Wenn Ihr koncurrenter Go-Test von einem echten time.Sleep abhängt, sollten Sie diesen Test wahrscheinlich als verdächtig betrachten.
Was testing/synctest ist
testing/synctest ist ein Standard-Bibliothekspaket in Go zum Testen von Concurrent-Code.
Es stellt zwei Hauptfunktionen bereit:
package synctest
func Test(t *testing.T, f func(*testing.T))
func Wait()
synctest.Test führt eine Funktion innerhalb einer isolierten Test-Blase aus. Jede Goroutine, die innerhalb dieser Blase gestartet wird, gehört ebenfalls zur Blase, die Zeit innerhalb der Blase ist künstlich, und das Paket time arbeitet gegen diese künstliche Uhr statt gegen die echte Wanduhr.
synctest.Wait wartet, bis alle anderen Goroutines in der Blase dauerhaft blockiert sind. Das klingt abstrakt, aber die praktische Wirkung ist leicht zu verstehen:
synctest.Test(t, func(t *testing.T) {
time.Sleep(10 * time.Second)
})
Das bedeutet nicht, dass Ihr Test 10 echte Sekunden warten muss. Innerhalb der synctest-Blase kann die Zeit sofort voranschreiten, wenn die Blase blockiert ist und wartet, dass die Zeit voranschreitet – das ist der Kerntrick hinter diesem Paket.
Warum Go-Tests für Concurrent-Code flaky sind
Wenn Sie neu in Go-Tests im Allgemeinen sind, behandelt Go Unit Testing: Struktur & Best Practices das Testing-Paket, tabelle-gesteuerte Tests und Mocking-Muster, die die Grundlage bilden, auf der dieser Artikel aufbaut. Concurrent-Tests sind in der Regel aus einem von drei Gründen unzuverlässig (flaky).
Erstens hängen sie vom Scheduler ab. Eine Goroutine kann auf Ihrem Rechner sofort ausgeführt werden, in der CI aber später.
Zweitens hängen sie von der echten Zeit ab. Ein Test, der 50 Millisekunden schläft, geht davon aus, dass 50 Millisekunden genug Zeit sind, damit die Hintergrundarbeit abgeschlossen ist.
Drittens beobachten sie den Zustand zu früh. Der Test prüft das Ergebnis, bevor die Hintergrundoperation tatsächlich abgeschlossen wurde.
Hier ist ein einfaches Beispiel:
func TestBackgroundWorkBad(t *testing.T) {
done := false
go func() {
done = true
}()
time.Sleep(10 * time.Millisecond)
if !done {
t.Fatal("Hintergrundarbeit wurde nicht abgeschlossen")
}
}
Dieser Test hat zwei Probleme.
Das offensichtliche ist der Sleep. Es gibt keine Garantie dafür, dass 10 Millisekunden die richtige Zeitspanne sind.
Das weniger offensichtliche ist der Data Race (Datenrennen). Der Test schreibt done in einer Goroutine und liest es in einer anderen ohne Synchronisation.
Sie können dieses spezifische Beispiel mit einem Kanal oder einem sync.WaitGroup beheben, und oft sollten Sie das auch tun. Aber wenn der getestete Code Timer, Kontext-Fristen (Deadlines), time.AfterFunc, Hintergrundworker oder verzögerte Bereinigung (Cleanup) verwendet, kann der Test trotzdem unhandlich werden – und genau hier hilft testing/synctest.
Die Kernidee: Führen Sie den Test innerhalb einer Blase aus
Eine synctest-Blase isoliert die Goroutines, die innerhalb ihrer Grenzen erstellt werden.
Verwenden Sie es wie folgt:
func TestSomethingConcurrent(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// Testen Sie hier Concurrent-Code.
})
}
Innerhalb der Blase:
- Goroutines, die vom Test gestartet werden, gehören zur Blase.
- Timer und Sleeps verwenden eine künstliche Uhr.
synctest.Waitkann warten, bis Hintergrundaktivität abgeklungen ist.- Der Test sollte vermeiden, von externen Goroutines, echtem Netzwerk-I/O oder externen Prozessen abzuweichen.
Die Blase ist keine Magie. Sie macht schlechtes Concurrent-Design nicht gut. Aber sie gibt Ihrem Test eine kontrollierte Umgebung, in der Zeit und Blockierverhalten deterministischer sind.
Das Problem mit time.Sleep in Tests
Ein echter time.Sleep in einem Test bedeutet in der Regel eines von zwei Dingen:
Ich weiß nicht, wie ich auf das Ereignis warten soll, das mich tatsächlich interessiert.
oder:
Ich weiß, worum es geht, aber der getestete Code bietet keinen sauberen Weg, es zu beobachten.
Beide sind Designsignale, die ernst genommen werden sollten – sie weisen auf Stellen hin, an denen der Produktionscode von sauberer Observability oder expliziteren Koordinierungsmechanismen profitieren könnte.
Betrachten Sie eine Funktion, die Arbeit im Hintergrund abschließt:
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
}
Ein schlechter Test könnte so aussehen:
func TestWorkerBad(t *testing.T) {
w := NewWorker()
w.Start()
time.Sleep(6 * time.Second)
select {
case got := <-w.Result():
if got != "done" {
t.Fatalf("erhalten %q, erwartet done", got)
}
default:
t.Fatal("Worker wurde nicht abgeschlossen")
}
}
Dieser Test wartet sechs echte Sekunden.
Das ist langsam. Wenn Sie viele solcher Tests haben, wird die Suite schmerzhaft.
Ein besserer Test mit synctest kann die künstliche Zeit sofort voranschreiten lassen:
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("erhalten %q, erwartet done", got)
}
default:
t.Fatal("Worker wurde nicht abgeschlossen")
}
})
}
Der Test drückt weiterhin die geschäftliche Tatsache aus – der Worker sollte nach 5 Sekunden fertig sein – aber er verbringt keine 5 echten Sekunden damit. Das ist der Unterschied zwischen dem Testen zeitabhängigen Verhaltens und dem Verschenken von Entwicklerzeit.
Testen von Kontext-Timeouts
Eine der besten Verwendungszwecke für testing/synctest ist das Testen von context.Context-Fristen (Deadlines) und Timeouts. Wie Kontext-Abbrüche und Timeout-Budgets über Dienstepen hinweg strukturiert werden sollten, wird in Go context.Context Done Right behandelt, und wie context.Canceled und context.DeadlineExceeded korrekt durch diese Ebenen propagiert werden, wird in Go Error Handling Architecture: Boundaries and Patterns behandelt – synctest ermöglicht es Ihnen, dieses Verhalten zu überprüfen, ohne dass echte Zeit vergeht.
Hier ist eine einfache Funktion, die darauf wartet, dass ein Kontext abgebrochen wird:
func WaitForCancel(ctx context.Context, done chan<- error) {
go func() {
<-ctx.Done()
done <- ctx.Err()
}()
}
Ohne synctest würde das Testen dieses Falls mit einer 30-Sekunden-Timeout den Test entweder langsam machen oder Sie dazu zwingen, die Frist nur für den Test zu ändern.
Mit synctest können Sie die echte Timeout-Dauer schnell 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("Kontext zu früh abgebrochen: %v", err)
default:
}
time.Sleep(30 * time.Second)
synctest.Wait()
select {
case err := <-done:
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("erhalten %v, erwartet %v", err, context.DeadlineExceeded)
}
default:
t.Fatal("Kontext wurde nicht abgebrochen")
}
})
}
Das ist die Art von Test, die synctest angenehm macht.
Sie können realistische Timeout-Werte im Code beibehalten und trotzdem Tests schnell ausführen.
Testen von Kontext-Abbrüchen
Sie können auch explizite Abbrüche testen, ohne mit der Hintergrundgoroutine zu wetteifern.
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("Kontext zu früh abgebrochen: %v", err)
default:
}
cancel()
synctest.Wait()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("erhalten %v, erwartet %v", err, context.Canceled)
}
default:
t.Fatal("Kontext wurde nicht abgebrochen")
}
})
}
Das wichtige Detail ist synctest.Wait.
Es gibt der Hintergrundgoroutine die Möglichkeit, den Abbruch wahrzunehmen und zur Ruhe zu kommen, bevor der Test das Ergebnis überprüft.
Was synctest.Wait tut
synctest.Wait wartet, bis alle anderen Goroutines in der Blase dauerhaft blockiert sind.
In gewöhnlicher Sprache bedeutet das:
Warte, bis die Goroutines in diesem Test einen stabilen Blockierpunkt erreicht haben.
Das ist nützlich, wenn der Test eine Goroutine startet und wissen muss, dass die Goroutine entweder beendet ist oder wartet.
Zum Beispiel:
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 wurde nicht ausgeführt")
}
})
}
Dies ist bewusst klein, aber es veranschaulicht die Idee.
synctest.Wait ist nicht nur ein hübscherer Sleep – es ist ein Synchronisationspunkt innerhalb der Blase, und dieser Unterschied ist wichtiger, als er auf den ersten Blick scheint.
Ein Sleep sagt:
Ich hoffe, dass genug Zeit vergangen ist.
Wait sagt:
Ich möchte, dass die Blase einen stabilen Blockierzustand erreicht.
Das Zweite ist für Tests weit besser, weil es eine beobachtbare Bedingung beschreibt, statt eine Vermutung über die verstrichene Zeit zu sein.
Künstliche Zeit in einer synctest-Blase
Innerhalb einer synctest-Blase verwendet das Paket time eine künstliche Uhr.
Die künstliche Uhr startet zu einer festen Zeit. Sie schreitet nur vor, wenn jede Goroutine in der Blase dauerhaft blockiert ist und die Zeit voranschreiten muss, um etwas zu entblocken.
Das bedeutet, dass dieser Test schnell ist:
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("erhalten %v, erwartet %v", elapsed, time.Hour)
}
})
}
Es liest sich, als würde es eine Stunde warten.
Es tut das aber nicht.
Das ist nützlich zum Testen von:
- Timeouts
- Fristen (Deadlines)
- Wiederholungsversuchen (Retries)
- Backoff-Logik
- Verzögerte Bereinigung (Cleanup)
- Rate-Limits
- Timern
- Tickern
- Kontext-Abbrüchen
Aber es gibt eine wichtige Regel: Künstliche Zeit hilft nur Code, der innerhalb der Blase das Paket time verwendet.
Wenn Ihr Code von einem externen System, echtem Netzwerk-I/O oder außerhalb der Blase gemessener Zeit abhängt, kann synctest das nicht deterministisch machen.
Testen einer Retry-Schleife
Retry-Schleifen sind eine häufige Ursache für langsame und flaky Tests.
Hier ist ein kleiner Retry-Helfer:
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
}
Ein normaler Test könnte die Verzögerung auf 1 Millisekunde reduzieren, nur um die Suite schnell zu halten.
Das ist nicht schrecklich, aber es bedeutet, dass der Test nicht mehr den echten Wert überprüft, der vom Produktionscode verwendet wird.
Mit synctest können Sie die echte Verzögerung beibehalten:
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("vorübergehender Fehler")
}
return nil
})
if err != nil {
t.Fatalf("Retry lieferte einen Fehler: %v", err)
}
if calls != 3 {
t.Fatalf("Aufrufe = %d, erwartet 3", calls)
}
})
}
Der Test stellt zwei 10-Sekunden-Wartezeiten dar.
Er wird trotzdem schnell ausgeführt.
Hier verändert synctest die Ökonomie des Testens. Sie müssen keine künstlich kleinen Zeitdauern mehr durch Tests streuen, nur um langsame CI zu vermeiden.
Testen von Retry-Abbrüchen
Sie können auch den Abbruch während der Retry-Verzögerung 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("vorübergehender Fehler")
})
}()
synctest.Wait()
cancel()
synctest.Wait()
select {
case err := <-errCh:
if !errors.Is(err, context.Canceled) {
t.Fatalf("erhalten %v, erwartet %v", err, context.Canceled)
}
default:
t.Fatal("Retry wurde nach dem Abbruch nicht zurückgegeben")
}
})
}
Dieser Test überprüft, dass die Retry-Schleife auf Abbrüche reagiert, anstatt die Verzögerung durchzuschlafen.
Das ist genau die Art von Verhalten, die in der Produktion wichtig ist.
Testen von time.AfterFunc
time.AfterFunc ist ein weiterer guter Anwendungsfall.
Nehmen wir an, Sie haben eine Funktion, die Bereinigung (Cleanup) plant:
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
}
Der Test kann die künstliche Zeit voranschreiten lassen:
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("Bereinigung geschah zu früh")
default:
}
time.Sleep(1 * time.Minute)
synctest.Wait()
select {
case <-cache.Cleaned():
default:
t.Fatal("Bereinigung geschah nicht")
}
})
}
Dieser Test verifiziert beide Seiten:
- Die Bereinigung erfolgt nicht vor der Verzögerung.
- Die Bereinigung erfolgt nach der Verzögerung.
Und er wartet nicht eine echte Minute.
Testen von Tickern
Ticker können auch mit künstlicher Zeit getestet werden, aber man muss vorsichtig sein. Ticker werden oft in langlaufenden Schleifen verwendet, und langlaufende Schleifen brauchen einen sauberen Shutdown-Pfad.
Hier ist ein kleiner Ticker-basierter Zähler:
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
}
Ein Test könnte so aussehen:
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, erwartet 3", counter.Ticks())
}
})
}
Dieses Beispiel hat ein bewusstes Detail im Design: Der Worker hat einen Shutdown-Pfad.
Das ist nicht nur gut für Tests. Es ist gut für die Produktion.
Tests decken oft auf, ob Ihre Goroutines tatsächlich aufhören können.
synctest und Goroutine-Leaks
testing/synctest ist hier hilfreich, weil synctest.Test darauf wartet, dass Goroutines in der Blase beendet sind, bevor es zurückkehrt, was bedeutet, dass Goroutine-Leaks schwerer zu ignorieren sind. Wenn eine Hintergrundgoroutine nie beendet wird, schlägt der Test fehl, statt Arbeit stillschweigend zurückzulassen – und das ist eine gute Sache.
Concurrent-Code sollte eine klare Zuständigkeit haben. Wenn eine Funktion eine Goroutine startet, sollte es einen expliziten Weg geben, sie anzuhalten, oder einen dokumentierten Grund, warum sie für immer leben darf. In Tests ist „für immer" fast nie akzeptabel. Zuständigkeit plus eine Concurrent-Obergrenze ist die Designgewohnheit, die dieser Artikel damit verbindet: [Bounding Concurrency in Go](https://www.glukhov.org/de/app-architecture/code-architecture/bounding-concurrency-in-go/ “Grenzen Sie Go-Concurrency: errgroup.SetLimit, Semaphore-Kanäle, Worker-Pools und begrenzte Warteschlangen – mit dem Goroutine-Leak, das über errgroup.Wait überlebt.”}) behandelt die Mustern auf der Produktionseite, deren Leckpfade eine Blase in Millisekunden fängt.
Ein gutes Muster ist:
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
Dann sorgt man dafür, dass die Goroutine aufhört, wenn der Kontext abgebrochen wird.
Was “dauerhaft blockiert” in der Praxis bedeutet
Die offiziellen Dokumente verwenden den Begriff “durably blocked” (dauerhaft blockiert).
Sie müssen nicht jedes Runtime-Detail auswendig lernen, aber Sie sollten die praktische Bedeutung verstehen.
Eine Goroutine ist dauerhaft blockiert, wenn sie auf eine Weise blockiert ist, die nur durch etwas innerhalb derselben synctest-Blase entblockt werden kann.
Beispiele umfassen:
- Empfangen aus einem innerhalb der Blase erstellten Kanal
- Senden an einen innerhalb der Blase erstellten Kanal
- Warten auf ein mit der Blase verknüpftes
sync.WaitGroup - Schlafen mit
time.Sleep - Warten auf bestimmte Timer-Operationen
Manche Dinge sind nicht dauerhaft blockiert, weil etwas außerhalb der Blase sie entblocken kann.
Beispiele umfassen:
- Netzwerk-I/O
- Systemaufrufe
- Operationen externer Prozesse
- Einige Mutex-Wartungen
- Interaktionen mit Goroutines außerhalb der Blase
Deshalb sollten synctest-Tests selbstenthalten sein und frei von externer Synchronisation gehalten werden, die die Blase nicht sehen kann. Verwenden Sie synctest nicht als Wrapper um Integrationstests, die mit dem echten Netzwerk kommunizieren.
Wofür synctest gut ist
testing/synctest ist besonders gut für Unit-Tests rund um asynchrones Verhalten.
Gute Kandidaten umfassen:
- Kontext-Abbrüche
- Kontext-Timeouts
- Retry-Schleifen
- Backoff-Logik
- Verzögerte Bereinigung
- Timer-getriebene Worker
- Ticker-getriebene Schleifen
- Hintergrund-Goroutines
- Timeout-Verhalten
- Kanal-Koordination
time.AfterFunc- Deterministisches Warten auf Goroutines
Der beste Anwendungsfall ist Code, bei dem der schwierige Teil Zeit oder Planung (Scheduling) ist, nicht externer I/O.
Wofür synctest nicht gut ist
testing/synctest ist kein Ersatz für alle Concurrent-Tests.
Es ist kein vollständiger deterministischer Scheduler für jedes mögliche Rennen.
Es ist kein Ersatz für den Race-Detektor.
Es ist kein Ersatz für Integrationstests.
Es macht echten Netzwerk-I/O nicht deterministisch.
Es behebt schlechtes Goroutine-Lebenszyklus-Design nicht.
Es bedeutet nicht, dass Sie Kanäle, Kontexte, Zuständigkeit und Shutdown ignorieren können.
Verwenden Sie synctest für die richtige Schicht: deterministische Unit-Tests für concurrentes und zeitabhängiges Verhalten.
Verwenden Sie andere Werkzeuge für andere Schichten:
- Verwenden Sie
go test -race, um Datenrennen zu erkennen - Verwenden Sie Integrationstests für echte Abhängigkeiten
- Verwenden Sie Lasttests für Durchsatz und Kontention
- Verwenden Sie Benchmarks für Leistung
- Verwenden Sie Tracing und Profiling für Produktionsverhalten
synctest vs. Race-Detektor
testing/synctest und der Race-Detektor lösen unterschiedliche Probleme.
Der Race-Detektor findet unsicheren concurrenten Speicherzugriff.
synctest hilft Ihnen, asynchrones Timing und Warten in Tests zu kontrollieren.
Sie sollten oft beide verwenden.
Zum Beispiel ist dies weiterhin ein Rennen, selbst innerhalb einer synctest-Blase, wenn keine korrekte Synchronisation vorhanden ist:
value := 0
go func() {
value = 1
}()
_ = value
synctest.Wait kann einen Synchronisationspunkt für einige Testmuster bereitstellen, aber es bedeutet nicht, dass jeder concurrente Zugriff in Ihrem Code automatisch sicher ist.
Führen Sie Concurrent-Tests aus mit:
go test -race ./...
Der Race-Detektor ist immer noch eines der besten Werkzeuge, die Ihnen Go bietet. Die Kombination mit Go Linters: Essential Tools for Code Quality gibt Ihnen eine solide Basis für statische Analyse und Runtime-Prüfungen für jede Concurrent-Codebase.
synctest vs. manuelle künstliche Uhren
Vor testing/synctest verwendeten viele Teams manuelle künstliche Uhren.
Das kann immer noch ein gutes Design sein.
Ein manuelles Uhr-Interface könnte so aussehen:
type Clock interface {
Now() time.Time
After(time.Duration) <-chan time.Time
Sleep(time.Duration)
}
Dann verwendet der Produktionscode eine echte Uhr, und Tests verwenden eine künstliche Uhr.
Das gibt explizite Kontrolle, hat aber einen Preis:
- mehr Interfaces
- mehr Verdrahtung (Plumbing)
- mehr nur für Tests gedachte Abstraktionen
- mehr Möglichkeiten, dass der Code die künstliche Uhr versehentlich umgeht
synctest ist attraktiv, weil gewöhnlicher Code, der das Paket time verwendet, innerhalb der Test-Blase gegen künstliche Zeit laufen kann.
Das reduziert die Notwendigkeit für Uhren-Injektion in vielen Fällen.
Meine Meinung: Verwenden Sie synctest, wenn es den Produktionscode einfacher hält. Verwenden Sie eine injizierte Uhr nur dann, wenn die Uhrensteuerung Teil Ihrer Domänen-Designs ist oder wenn Sie außerhalb dessen Kontrolle benötigen, was synctest bereitstellt. Für einen breiteren Einblick in Abhängigkeitsinjektions-Muster in Go – einschließlich wann und wie testbare Abstraktionen injiziert werden – sehen Sie Dependency Injection in Go: Patterns & Best Practices.
synctest vs. Kanäle und WaitGroups
Ersetzen Sie gute Synchronisation nicht durch synctest.
Wenn Ihr Code einen Abschluss-Kanal, einen Callback oder eine Wait-Methode bereitstellen kann, ist das oft gutes Design.
Zum Beispiel:
type Server struct {
done chan struct{}
}
func (s *Server) Done() <-chan struct{} {
return s.done
}
Ein Test kann direkt darauf warten.
synctest ist am nützlichsten, wenn das getestete Verhalten Zeit, Kontext-Fristen, Hintergrundplanung oder asynchrone Callbacks beinhaltet.
Die besten Tests kombinieren oft beides:
- Produktionscode hat explizite Shutdown- oder Abschluss-Signale
- synctest entfernt echtes Zeit-Warten
- Wait macht Hintergrundaktivität deterministisch
Häufige Fehler
Fehler 1: Jeden Test in synctest verpacken
Verwenden Sie synctest nicht überall. Wenn der Code synchron ist, ist eine normale Testfunktion klarer, und das Hinzufügen des Blase-Wrapper führt nur zu unnötigem Mechanismus, der Tests schwieriger lesbar und verständlich macht.
Fehler 2: Echtes Netzwerk-I/O in der Blase testen
Halten Sie synctest-Tests selbstenthalten. Wenn Ihr Test einen echten Netzwerk-Socket, einen externen Dienst, eine Datenbank oder einen Unterprozess verwendet, gehört er in einen Integrationstest, nicht in eine synctest-Blase. Verwenden Sie Fakes für Unit-Tests und reservieren Sie echte Abhängigkeiten für separate Integrationstests, wo die Blasen-Isolation nicht gilt. Wenn die Abhängigkeit ein HTTP-Handler ist, entfernt Go 1.27’s httptest.NewTestServer die Notwendigkeit eines echten Sockets vollständig – es führt den Server über ein speicherinternes falsches Netzwerk aus, was genau das ist, was die Blase braucht, und paart sich mit synctest für deterministische Round-Trip-Timing wie in dem Go 1.27 Upgrade Guide gezeigt.
Fehler 3: Goroutine-Leaks
Wenn Ihr Test eine Goroutine startet, stellen Sie sicher, dass sie einen klaren Ausstiegspfad hat. Verwenden Sie Kontext-Abbrüche, geschlossene Kanäle oder explizite Stop-Methoden – eine Goroutine, die nie aufhört, ist sowohl ein Produktions-Faultsmell als auch ein Test-Faultsmell, den synctest aufdeckt, anstatt ihn zu verbergen.
Fehler 4: Abhängigkeit von Paket-Stufe-Zustand
Kanäle, Timer und WaitGroups auf Paket-Stufe können die Blasen-Isolation auf subtile Weise brechen. Bevorzugen Sie die Erstellung aller Testzustände innerhalb der synctest.Test-Funktion, damit jede Ressource zur Blase gehört und ihre Lebensdauer klar auf den Test beschränkt ist.
Fehler 5: Künstliche Zeit als echte Zeit behandeln
Künstliche Zeit ist für deterministische Tests, nicht für Leistungsmessung. Ein Test, der eine Stunde sofort voranschreiten lässt, sagt Ihnen nichts Nützliches über CPU-Kosten, Lock-Kontention, Speichernutzung oder echtes Scheduling-Verhalten in der Produktion – verwenden Sie Benchmarks und Lasttests für diese Fragen.
Fehler 6: Den Race-Detektor ignorieren
synctest ist kein Ersatz für go test -race, und die beiden Werkzeuge lösen unterschiedliche Probleme. Führen Sie den Race-Detektor neben Ihren synctest-Tests aus, um unsicheren concurrenten Speicherzugriff zu fangen, den die Blase allein nicht erkennen kann.
Eine praktische Checkliste
Verwenden Sie diese Checkliste beim Schreiben von Tests mit testing/synctest.
Verwenden Sie synctest, wenn
- der Code Goroutines startet
- der Code
time.Sleepverwendet - der Code Timer oder Ticker verwendet
- der Code Kontext-Fristen verwendet
- der Code Retry- oder Backoff-Verhalten hat
- der Test derzeit willkürliche Sleeps verwendet
- der Test in CI flaky ist
- der Test langsam ist, weil er auf echte Zeit wartet
Vermeiden Sie synctest, wenn
- der Code synchron ist
- der Test von echtem Netzwerk-I/O abhängt
- der Test von externen Prozessen abhängt
- der Test tatsächlich ein Integrationstest ist
- Sie versuchen, Leistung zu messen
- der Code keinen sauberen Shutdown-Pfad hat
Bevorzugen Sie dieses Muster
func TestSomething(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// Vorbereiten.
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
// Ausführen.
_ = ctx
// Hintergrundarbeit abklingen lassen.
synctest.Wait()
// Künstliche Zeit voranschreiten lassen, falls nötig.
time.Sleep(1 * time.Second)
synctest.Wait()
// Behaupten.
})
}
Dieses Muster ist einfach:
- innerhalb der Blase einrichten
- Arbeit innerhalb der Blase starten
- darauf warten, dass Hintergrundaktivität abklingt
- künstliche Zeit nur voranschreiten lassen, wenn nötig
- nach der Synchronisation behaupten
Wo testing/synctest in echten Projekten verwendet werden sollte
Die besten Stellen, an denen man suchen sollte, sind in der Regel nicht in einfacher Geschäftslogik.
Suchen Sie nach Tests mit diesen Gerüchen:
grep -R "time.Sleep" .
grep -R "time.After" .
grep -R "WithTimeout" .
grep -R "WithDeadline" .
grep -R "NewTicker" .
grep -R "AfterFunc" .
Dann fragen Sie sich:
- Ist dieser Test langsam, weil er auf echte Zeit wartet?
- Ist dieser Test flaky, weil er annimmt, dass eine Goroutine bereits ausgeführt wurde?
- Kann dieser Test von Netzwerk und externen Prozessen isoliert werden?
- Kann die Hintergrundgoroutine sauber gestoppt werden?
- Würde künstliche Zeit die Behauptung klarer machen?
Gute Kandidaten leben oft in:
- Worker-Paketen
- Retry-Paketen
- Cache-Paketen
- Scheduler-Paketen
- Queue-Consumern
- HTTP-Client-Wrapper
- Timeout-Middleware
- Hintergrund-Bereinigungs-Code
- Rate-Limiting-Code
Beginnen Sie mit einem flaky Test. Migrations der gesamten Codebase auf einmal. Wenn Ihre Testsuite parallele tabelle-gesteuerte Tests neben async Code verwendet, behandelt Parallel Table-Driven Tests in Go die t.Parallel()-Muster und Race-Condition-Fallen, die sich natürlich mit dem synctest-Ansatz paaren.
Beispiel: Vorher und Nachher
Hier ist ein realistischer schlechter 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("vorübergehender Fehler")
}
return nil
})
if err != nil {
t.Fatalf("Retry lieferte einen Fehler: %v", err)
}
if calls != 3 {
t.Fatalf("Aufrufe = %d, erwartet 3", calls)
}
}
Dies wartet etwa eine Sekunde, weil zwei Retry-Verzögerungen auftreten.
Das mag nicht schlimm klingen, aber multiplizieren Sie es mit vielen Tests und mehreren Paketen. Langsame Tests führen dazu, dass Entwickler Tests seltener ausführen.
Nun die synctest-Version:
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("vorübergehender Fehler")
}
return nil
})
if err != nil {
t.Fatalf("Retry lieferte einen Fehler: %v", err)
}
if calls != 3 {
t.Fatalf("Aufrufe = %d, erwartet 3", calls)
}
})
}
Der Test behält den echten Verzögerungswert, die Suite bleibt schnell, und die Absicht ist klarer. Das ist der Hauptwert von testing/synctest.
Wie man synctest sicher einsetzt
Ich würde es schrittweise einführen.
Schritt 1: Flaky oder langsame Concurrent-Tests finden
Suchen Sie nach echten Sleeps und timeout-lastigen Tests. Die grep-Befehle im vorherigen Abschnitt sind ein guter Ausgangspunkt, um Kandidaten in der gesamten Codebase zu identifizieren.
Schritt 2: Ein Paket auswählen
Wählen Sie ein Paket, das klares asynchrones Verhalten hat, aber keine echten externen Dienste erfordert. Worker-Pakete, Retry-Helfer und Timer-getriebene Komponenten sind ideale erste Ziele.
Schritt 3: Einen Test konvertieren
Verpacken Sie den Test in synctest.Test und ersetzen Sie willkürliche Sleeps durch synctest.Wait, künstliche Zeit-Sleeps oder explizite Synchronisation. Die Konvertierung ist in der Regel klein – der schwerste Teil ist sicherzustellen, dass Goroutines saubere Shutdown-Pfade haben.
Schritt 4: Mit dem Race-Detektor ausführen
Führen Sie immer mit go test -race ./... nach der Konvertierung aus. Ein erfolgreicher synctest-Test bedeutet nicht, dass der Code race-frei ist; es bedeutet nur, dass das asynchrone Timing jetzt deterministisch ist.
Schritt 5: Goroutine-Lebenszyklus überprüfen
Stellen Sie sicher, dass jede vom Test gestartete Goroutine einen Weg hat, zu enden, bevor die Blase schließt. Wenn sie das nicht tut, wird synctest.Test das Leak aufdecken, anstatt es stillschweigend zu ignorieren.
Schritt 6: Nur dort wiederholen, wo die Klarheit verbessert wird
Konvertieren Sie Tests nicht nur aus Modegründen. Ein guter synctest-Test sollte messbar schneller, klarer lesbar oder weniger flaky sein als die Version, die er ersetzte – wenn nicht, war die Konvertierung nicht den Aufwand wert.
Meine meinungsstarken Regeln
Verwenden Sie diese als praktische Faustregeln.
Regel 1: Keine willkürlichen Sleeps in Concurrent-Unit-Tests
Ein Sleep, der darauf wartet, dass eine Goroutine vielleicht fertig wird, ist ein Geruch (Smell). Ersetzen Sie ihn durch Kanäle, WaitGroups, Callbacks, synctest.Wait oder künstliche Zeit – alles, was auf eine Bedingung wartet, statt zu hoffen, dass genug Zeit vergangen ist.
Regel 2: Halten Sie synctest-Tests selbstenthalten
Erstellen Sie Goroutines, Kanäle, Kontexte, Timer und Worker innerhalb der Blase. Vermeiden Sie geteilten Paket-Stufe-Zustand, der zwischen Tests lecken kann und die Isolation brechen kann, die synctest nützlich macht.
Regel 3: Verwenden Sie synctest nicht als Integrationstest-Wrapper
Wenn der Test mit einer echten Datenbank, einem echten Netzwerk oder einem externen Prozess spricht, halten Sie ihn von synctest fern, es sei denn, Sie haben einen sehr spezifischen Grund dafür.
Regel 4: Testen Sie Verhalten, nicht Scheduler-Glück
Das Ziel ist nicht, eine Goroutine zum Laufen zu zwingen. Das Ziel ist, beobachtbares Verhalten zu verifizieren, nachdem das System einen bedeutsamen Zustand erreicht hat, was synctest.Wait möglich macht, ohne von Timing-Annahmen abzuweichen.
Regel 5: Halten Sie Abbruch-Pfade explizit
Jede Hintergrundgoroutine sollte einen Shutdown-Pfad haben, und Tests sollten beweisen, dass dieser Pfad funktioniert, indem sie den Kontext abbrechen oder den Kanal schließen und dann verifizieren, dass die Goroutine sauber endet.
Schlussgedanken
testing/synctest ist eines dieser Go-Features, die klein aussehen, aber ändern, wie Sie eine Klasse von Tests schreiben. Es ersetzt gutes Concurrent-Design, den Race-Detektor oder die Notwendigkeit von Integrationstests nicht – aber es macht viele asynchrone Unit-Tests schneller, sauberer und weit weniger abhängig von Timing-Glück.
Das ist wichtig, weil Concurrent-Code bereits schwierig genug ist. Tests sollten Unsicherheit reduzieren, nicht hinzufügen. Für einen breiteren Blick auf Produktions-Go-Muster über Integration, Codestruktur und Datenzugriff hinweg, sehen Sie App Architecture in Production.
Die praktische Kernaussage ist einfach:
Verwenden Sie synctest für deterministische Unit-Tests rund um Goroutines, Timer, Timeouts, Retries und Abbrüche.
Halten Sie echte Sleeps aus Concurrent-Tests heraus, es sei denn, Sie haben einen sehr guten Grund.
Diese eine Gewohnheit wird viele Go-Testsuiten schneller und weniger flaky machen.
Die wichtigen aktuellen Fakten sind: testing/synctest wurde in Go 1.25 allgemein verfügbar, es stellt synctest.Test und synctest.Wait bereit, es führt Tests innerhalb einer isolierten Blase aus, und die Zeit innerhalb dieser Blase verwendet eine künstliche Uhr, die nur voranschreitet, wenn Goroutines dauerhaft blockiert sind.
Quellen
- https://pkg.go.dev/testing/synctest
- https://go.dev/blog/testing-time
- https://go.dev/blog/synctest
- [https://go.dev/blog/testing-time](“Testing Time (and other asynchronicities) - The Go Programming Language”)
- https://go.dev/doc/go1.25
- https://go.dev/blog/go1.25