Testen von gleichzeitigem Go-Code mit synctest

Vermeiden Sie unnötiges Warten in parallelen Go-Tests.

Inhaltsverzeichnis

Das Testen von parallelem Go-Code erforderte bisher stets eine gewisse Disziplin. Goroutinen sind günstig, Channels sind einfach und die Kontextabsage ist idiomatisch – Hintergrundarbeiter und Timer sind in echten Go-Diensten allgegenwärtig.

Aber das zuverlässige Testen all dessen ist schwieriger als das Schreiben des Codes.

Testing concurrent Go code with synctest

Das übliche schlechte Muster ist bekannt:

go doSomething()

time.Sleep(100 * time.Millisecond)

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

Dieser Test kann auf Ihrem Laptop bestehen, im CI jedoch fehlschlagen. Oder er besteht für sechs Monate und schlägt dann auf einem ausgelasteten Runner fehl. Oder er ist langsam, weil jemand den Schlaf von 100 Millisekunden auf 2 Sekunden erhöht hat, „nur um auf der sicheren Seite zu sein".

Dies ist keine gute Testmethodik – es ist Würfeln mit einem Timer, und diese Wette wird teuerer, 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 ermöglicht einem Test, innerhalb eines isolierten „Bubbles" (Blase) zu laufen, gibt dieser Blase eine gefälschte Uhr und bietet eine Möglichkeit zu warten, bis Goroutinen innerhalb der Blase blockiert sind.

Das Ergebnis ist einfach, aber leistungsstark:

  • Keine willkürlichen Schlafen
  • Schnellere Timeout-Tests
  • Bestimmungsfähigere parallele Tests
  • Besseres Testen der Kontextabsage
  • Besseres Testen von Hintergrundgoroutinen
  • Weniger instabile CI

Die etwas eigensinnige Version: Wenn Ihr paralleler Go-Test von einem echten time.Sleep abhängt, sollten Sie diesen Test wahrscheinlich als verdächtig einstufen.

Was testing/synctest ist

testing/synctest ist ein Go-Standardbibliothekspaket zum Testen von parallelem Code.

Es bietet zwei Hauptfunktionen:

package synctest

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

synctest.Test führt eine Funktion innerhalb einer isolierten Testblase aus. Alle Goroutinen, die innerhalb dieser Blase gestartet werden, gehören ebenfalls zur Blase, die Zeit innerhalb der Blase ist gefälscht, und das Paket time arbeitet gegen diese gefälschte Uhr anstatt gegen die echte Wanduhr.

synctest.Wait wartet, bis alle anderen Goroutinen 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)
})

Dies lässt Ihren Test nicht 10 echte Sekunden warten. Innerhalb der Synctest-Blase kann die Zeit sofort voranschreiten, wenn die Blase blockiert ist und auf den Fortschritt der Zeit wartet – das ist der Kerntrick hinter dem Paket.

Warum parallele Go-Tests instabil sind

Wenn Sie mit Go-Testing im Allgemeinen neu sind, deckt Go Unit Testing: Structure & Best Practices das Testpaket, tabellenbasierte Tests und Mocking-Muster ab, die die Grundlage bilden, auf der dieser Artikel aufbaut. Parallele Tests sind in der Regel aus einem der folgenden drei Gründe instabil.

Erstens hängen sie vom Scheduler ab. Eine Goroutine kann auf Ihrem Gerät sofort ausgeführt werden und später im CI.

Zweitens hängen sie von der Echtzeit ab. Ein Test, der 50 Millisekunden schläft, geht davon aus, dass 50 Millisekunden ausreichend Zeit sind, damit die Hintergrundarbeit abgeschlossen ist.

Drittens beobachten sie den Zustand zu früh. Der Test überprüft das Ergebnis, bevor die Hintergrundoperation tatsächlich abgeschlossen ist.

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("background work did not finish")
	}
}

Dieser Test hat zwei Probleme.

Das offensichtliche Problem ist der Schlaf. Es gibt keine Garantie, dass 10 Millisekunden die richtige Zeitspanne sind.

Das weniger offensichtliche Problem ist der Data Race. Der Test schreibt done in einer Goroutine und liest es in einer anderen ohne Synchronisation.

Sie können dieses spezifische Beispiel mit einem Channel oder einer sync.WaitGroup beheben, und oft sollten Sie das auch tun. Aber wenn der zu testende Code Timer, Kontextfristen, time.AfterFunc, Hintergrundarbeiter oder verzögerte Bereinigung verwendet, kann der Test dennoch umständlich werden – und genau dort hilft testing/synctest.

Die Kernidee: Führen Sie den Test innerhalb einer Blase aus

Eine Synctest-Blase isoliert die darin erstellten Goroutinen.

Verwenden Sie es wie folgt:

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

Innerhalb der Blase:

  • Goroutinen, die vom Test gestartet werden, gehören zur Blase.
  • Timer und Schlafen verwenden eine gefälschte Uhr.
  • synctest.Wait kann warten, bis Hintergrundaktivität zur Ruhe kommt.
  • Der Test sollte vermeiden, von externen Goroutinen, echtem Netzwerk-I/O oder externen Prozessen abhängig zu sein.

Die Blase ist kein Zauberstab. Sie macht ein schlechtes Parallelitätsdesign 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 normalerweise eines von zwei Dingen:

I do not know how to wait for the event I actually care about.

oder:

I know what I care about, but the code under test does not expose a clean way to observe it.

Beides sind Designsignale, die ernst genommen werden sollten – sie weisen auf Stellen hin, an denen der Produktionscode von klarerer Observierbarkeit 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("got %q, want done", got)
		}
	default:
		t.Fatal("worker did not finish")
	}
}

Dieser Test wartet sechs echte Sekunden.

Das ist langsam. Wenn Sie viele solche Tests haben, wird die Suite schmerzhaft.

Ein besserer Test mit synctest kann die gefälschte Zeit sofort vorantreiben:

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

Der Test drückt weiterhin die geschäftliche Tatsache aus – der Arbeiter sollte nach 5 Sekunden fertig sein – aber er verbringt nicht 5 echte Sekunden damit. Das ist der Unterschied zwischen dem Testen zeitabhängigen Verhaltens und dem Verschwenden von Entwicklerzeit.

Testen von Kontext-Timeouts

Einer der besten Verwendungszwecke für testing/synctest ist das Testen von context.Context-Fristen und Timeouts. Das korrekte Propagieren von context.Canceled und context.DeadlineExceeded durch Service- und Handler-Schichten wird in Go Error Handling Architecture: Boundaries and Patterns eingehend behandelt – synctest ermöglicht es Ihnen, dieses Verhalten zu überprüfen, ohne dass echte Zeit vergeht.

Hier ist eine einfache Funktion, die wartet, bis ein Kontext abgesagt wird:

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

Ohne synctest würde das Testen dieses Verhaltens mit einem 30-Sekunden-Timeout entweder den Test langsam machen oder Sie zwingen, das Timeout 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("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")
		}
	})
}

Dies ist die Art von Test, die synctest angenehm macht.

Sie können realistische Timeout-Werte im Code beibehalten und dennoch Tests schnell ausführen.

Testen der Kontextabsage

Sie können auch die explizite Absage testen, ohne die Hintergrundgoroutine zu überholen.

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

Das wichtige Detail ist synctest.Wait.

Es gibt der Hintergrundgoroutine die Möglichkeit, die Absage zu beobachten und zur Ruhe zu kommen, bevor der Test das Ergebnis überprüft.

Was synctest.Wait tut

synctest.Wait wartet, bis alle anderen Goroutinen in der Blase dauerhaft blockiert sind.

In normaler Sprache bedeutet das:

Wait until the goroutines inside this test have reached a stable blocked point.

Dies ist nützlich, wenn der Test eine Goroutine startet und wissen muss, dass die Goroutine entweder beendet 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 did not run")
		}
	})
}

Dies ist absichtlich klein, aber es demonstriert die Idee.

synctest.Wait ist nicht nur ein netterer Schlaf – es ist ein Synchronisierungspunkt innerhalb der Blase, und diese Unterscheidung ist wichtiger, als sie zunächst scheint.

Ein Schlaf sagt:

I hope enough time has passed.

Wait sagt:

I want the bubble to reach a stable blocked state.

Das Zweite ist für Tests weitaus besser, da es einen beobachtbaren Zustand beschreibt, anstatt eine Vermutung über die vergangene Zeit zu treffen.

Gefälschte Zeit in einer Synctest-Blase

Innerhalb einer Synctest-Blase verwendet das Paket time eine gefälschte Uhr.

Die gefälschte Uhr startet zu einer festen Zeit. Sie wird nur dann vorangebracht, wenn jede Goroutine in der Blase dauerhaft blockiert ist und die Zeit voranschreiten muss, um etwas zu entblockieren.

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("got %v, want %v", elapsed, time.Hour)
		}
	})
}

Es liest sich so, als würde es eine Stunde warten.

Tut es nicht.

Dies ist nützlich zum Testen von:

  • Timeouts
  • Fristen
  • Wiederholungen
  • Backoff
  • Verzögerter Bereinigung
  • Ratenbegrenzungen
  • Timern
  • Tickern
  • Kontextabsage

Aber es gibt eine wichtige Regel: Gefälschte Zeit hilft nur Code, der das Paket time innerhalb der Blase verwendet.

Wenn Ihr Code von einem externen System, echtem Netzwerk-I/O oder außerhalb der Blase gemessener Zeit abhängt, kann synctest dies nicht deterministisch machen.

Testen einer Wiederholungs-Schleife

Wiederholungs-Schleifen sind eine häufige Quelle für langsame und instabile Tests.

Hier ist ein kleiner Wiederholungs-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 ausführt, 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("temporary failure")
			}
			return nil
		})

		if err != nil {
			t.Fatalf("Retry returned error: %v", err)
		}

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

Der Test repräsentiert zwei 10-Sekunden-Wartezeiten.

Er läuft dennoch schnell.

Hier ändert synctest die Ökonomie des Testens. Sie benötigen keine gefälschten winzigen Dauerzeiten mehr, die durch Tests verstreut sind, nur um langsame CI zu vermeiden.

Testen der Wiederholungsabsage

Sie können auch die Absage während der Wiederholungsverzö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("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")
		}
	})
}

Dieser Test überprüft, dass die Wiederholungs-Schleife auf die Absage reagiert, anstatt durch die Verzögerung zu schlafen.

Das ist genau die Art von Verhalten, die in der Produktion wichtig ist.

Testen von time.AfterFunc

time.AfterFunc ist ein weiterer guter Kandidat.

Nehmen wir an, Sie haben eine Funktion, die Bereinigung 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 gefälschte Zeit vorantreiben:

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

Dieser Test überprüft beide Seiten:

  • Die Bereinigung erfolgt nicht vor der Verzögerung.
  • Die Bereinigung erfolgt nach der Verzögerung.

Und es wartet nicht eine echte Minute.

Testen von Tickern

Ticker können auch mit gefälschter Zeit getestet werden, aber seien Sie vorsichtig. Ticker werden oft in langlaufenden Schleifen verwendet, und langlaufende Schleifen benötigen 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, want 3", counter.Ticks())
		}
	})
}

Dieses Beispiel hat ein absichtliches Design-Detail: Der Arbeiter hat einen Shutdown-Pfad.

Das ist nicht nur für Tests gut. Es ist gut für die Produktion.

Tests offenbaren oft, ob Ihre Goroutinen tatsächlich stoppen können.

synctest und Goroutine-Leaks

testing/synctest ist hier hilfreich, da synctest.Test wartet, bis Goroutinen in der Blase beendet sind, bevor es zurückkehrt, was bedeutet, dass geleakte Goroutinen schwerer zu ignorieren sind. Wenn eine Hintergrundgoroutine nie beendet wird, schlägt der Test fehl, anstatt stillschweigend Arbeit zurückzulassen – und das ist gut.

Paralleler Code sollte eine klare Ownership haben. Wenn eine Funktion eine Goroutine startet, sollte es einen expliziten Weg geben, sie zu stoppen, oder einen dokumentierten Grund, warum sie für immer leben darf. In Tests ist „für immer" fast nie akzeptabel.

Ein gutes Muster ist:

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

Dann lassen Sie die Goroutine stoppen, wenn der Kontext abgesagt wird.

Was „dauerhaft blockiert" in der Praxis bedeutet

Die offiziellen Docs verwenden den Begriff „durably blocked".

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 hierfür sind:

  • Empfangen von einem Channel, der innerhalb der Blase erstellt wurde
  • Senden an einen Channel, der innerhalb der Blase erstellt wurde
  • Warten auf eine sync.WaitGroup, die mit der Blase assoziiert ist
  • Schlafen mit time.Sleep
  • Warten auf bestimmte Timer-Operationen

Manche Dinge sind nicht dauerhaft blockiert, da etwas außerhalb der Blase sie entblocken kann.

Beispiele hierfür sind:

  • Netzwerk-I/O
  • Systemaufrufe
  • Externe Prozessoperationen
  • Einige Mutex-Wartezeiten
  • Interaktionen mit Goroutinen außerhalb der Blase

Aus diesem Grund sollten Synctest-Tests selbstständig sein und von externer Synchronisation frei 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 um asynchrones Verhalten herum.

Gute Kandidaten sind:

  • Kontextabsage
  • Kontext-Timeouts
  • Wiederholungs-Schleifen
  • Backoff-Logik
  • Verzögerte Bereinigung
  • Timer-gesteuerte Arbeiter
  • Ticker-gesteuerte Schleifen
  • Hintergrundgoroutinen
  • Timeout-Verhalten
  • Channel-Koordination
  • time.AfterFunc
  • Deterministisches Warten auf Goroutinen

Der beste Anwendungsfall ist Code, bei dem der schwierige Teil die Zeit oder die Planung ist, nicht externer I/O.

Wofür synctest nicht gut ist

testing/synctest ist kein Ersatz für alle Parallelitätstests.

Es ist kein vollständiger deterministischer Scheduler für jedes mögliche Race.

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 kein schlechtes Goroutine-Lebenszyklus-Design.

Es bedeutet nicht, dass Sie Channels, Kontexte, Ownership und Shutdown ignorieren können.

Verwenden Sie synctest für die richtige Ebene: Deterministische Unit-Tests für paralleles und zeitabhängiges Verhalten.

Verwenden Sie andere Werkzeuge für andere Ebenen:

  • verwenden Sie go test -race, um Data Races zu erkennen
  • verwenden Sie Integrationstests für echte Abhängigkeiten
  • verwenden Sie Lasttests für Durchsatz und Konkurrenz
  • verwenden Sie Benchmarks für Leistung
  • verwenden Sie Tracing und Profiling für Produktionsverhalten

synctest vs der Race-Detektor

testing/synctest und der Race-Detektor lösen unterschiedliche Probleme.

Der Race-Detektor findet unsicheren parallelen Speicherzugriff.

synctest hilft Ihnen, asynchrones Timing und Warten in Tests zu kontrollieren.

Sie sollten oft beide verwenden.

Zum Beispiel ist dies immer noch ein Race, auch innerhalb einer Synctest-Blase, wenn keine richtige Synchronisation vorhanden ist:

value := 0

go func() {
	value = 1
}()

_ = value

synctest.Wait kann einen Synchronisierungspunkt für einige Testmuster bereitstellen, aber es bedeutet nicht, dass jeder parallele Zugriff in Ihrem Code automatisch sicher ist.

Führen Sie parallele Tests mit aus:

go test -race ./...

Der Race-Detektor ist immer noch eines der besten Werkzeuge, die Go Ihnen bietet. In Kombination mit Go Linters: Essential Tools for Code Quality erhalten Sie eine solide Grundlage für statische Analyse und Runtime-Checks für jede parallele Codebasis.

synctest vs manuelle gefälschte Uhren

Vor testing/synctest verwendeten viele Teams manuelle gefälschte Uhren.

Das kann immer noch ein gutes Design sein.

Eine manuelle Clock-Schnittstelle könnte so aussehen:

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

Dann verwendet Produktionscode eine echte Uhr und Tests eine gefälschte Uhr.

Dies gibt explizite Kontrolle, aber es hat einen Kostenpunkt:

  • mehr Schnittstellen
  • mehr Plumbering
  • mehr testonly-Abstraktionen
  • mehr Möglichkeiten, wie Code die gefälschte Uhr versehentlich umgehen kann

synctest ist attraktiv, weil gewöhnlicher Code, der das Paket time verwendet, gegen gefälschte Zeit innerhalb der Testblase laufen kann.

Das reduziert den Bedarf an Clock-Injection in vielen Fällen.

Meine Meinung: Verwenden Sie synctest, wenn es Produktionscode einfacher hält. Verwenden Sie eine injizierte Clock nur, wenn die Clock-Kontrolle Teil Ihres Domain-Designs ist oder wenn Sie Kontrolle benötigen, die über das hinausgeht, was synctest bietet. Für einen breiteren Blick auf Dependency-Injection-Muster in Go – einschließlich wann und wie man testbare Abstraktionen injiziert – siehe Dependency Injection in Go: Patterns & Best Practices.

synctest vs Channels und WaitGroups

Ersetzen Sie gute Synchronisation nicht durch synctest.

Wenn Ihr Code einen Abschluss-Channel, einen Callback oder eine Wait-Methode exponieren 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 zu testende Verhalten Zeit, Kontextfristen, Hintergrundplanung oder asynchrone Callbacks beinhaltet.

Die besten Tests kombinieren oft beides:

  • Produktionscode hat explizite Shutdown- oder Abschluss-Signale
  • Synctest entfernt echtes Echtzeit-Warten
  • Wait macht Hintergrundaktivität deterministisch

Häufige Fehler

Fehler 1: Jeden Test in synctest einhüllen

Verwenden Sie nicht synctest überall. Wenn der Code synchron ist, ist eine einfache Testfunktion klarer, und das Hinzufügen des Bubble-Wrappers führt nur zu unnötiger Maschinerie, die Tests schwerer zu lesen und zu verstehen macht.

Fehler 2: Echten Netzwerk-I/O innerhalb der Blase testen

Halten Sie Synctest-Tests selbstständig. Wenn Ihr Test einen echten Netzwerksocket, externen Service, Datenbank oder Subprozess 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, bei denen Bubble-Isolation nicht gilt.

Fehler 3: Goroutinen lecken

Wenn Ihr Test eine Goroutine startet, stellen Sie sicher, dass sie einen klaren Exit-Pfad hat. Verwenden Sie Kontextabsage, geschlossene Channels oder explizite Stop-Methoden – eine Goroutine, die nie stoppt, ist sowohl ein Produktionsgeruch als auch ein Testgeruch, den Synctest aufdecken wird, anstatt zu verstecken.

Fehler 4: Abhängigkeit von paketweiterem Zustand

Paketweite Channels, Timer und WaitGroups können die Bubble-Isolation auf subtile Weise brechen. Bevorzugen Sie das Erstellen aller Testzustände innerhalb der synctest.Test-Funktion, sodass jede Ressource zur Blase gehört und ihre Lebensdauer klar auf den Test begrenzt ist.

Fehler 5: Gefälschte Zeit als echte Zeit behandeln

Gefälschte Zeit ist für deterministische Tests, nicht für Leistungsmessungen. Ein Test, der eine Stunde sofort vorantreibt, sagt Ihnen nichts Nützliches über CPU-Kosten, Lock-Konkurrenz, Speicherverbrauch oder echtes Planungsverhalten 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-Detector zusammen mit Ihren Synctest-Tests aus, um unsicheren parallelen Speicherzugriff zu erkennen, 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 Goroutinen startet
  • der Code time.Sleep verwendet
  • der Code Timer oder Ticker verwendet
  • der Code Kontextfristen verwendet
  • der Code Wiederholungs- oder Backoff-Verhalten hat
  • der Test derzeit willkürliche Schlafen verwendet
  • der Test im CI instabil 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 wirklich 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) {
		// 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.
	})
}

Dieses Muster ist einfach:

  • Einrichten innerhalb der Blase
  • Arbeit innerhalb der Blase starten
  • Warten, bis Hintergrundaktivität zur Ruhe kommt
  • Gefälschte Zeit nur vorantreiben, wenn nötig
  • Behauptungen nach Synchronisierung

Wo testing/synctest in echten Projekten verwendet werden sollte

Die besten Orte zum Suchen sind normalerweise 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" .

Fragen Sie dann:

  • Ist dieser Test langsam, weil er auf echte Zeit wartet?
  • Ist dieser Test instabil, weil er annimmt, dass eine Goroutine bereits lief?
  • Kann dieser Test von Netzwerk und externen Prozessen isoliert werden?
  • Kann die Hintergrundgoroutine sauber gestoppt werden?
  • Würde gefälschte Zeit die Behauptung klarer machen?

Gute Kandidaten leben oft in:

  • Arbeiter-Paketen
  • Wiederholungs-Paketen
  • Cache-Paketen
  • Scheduler-Paketen
  • Queue-Verbrauchern
  • HTTP-Client-Wrapper
  • Timeout-Middleware
  • Hintergrundbereinigungscode
  • Rate-Limiting-Code

Beginnen Sie mit einem instabilen Test. Migrieren Sie nicht die gesamte Codebasis auf einmal. Wenn Ihre Testsuite parallele tabellenbasierte Tests neben asynchronem Code verwendet, deckt Parallel Table-Driven Tests in Go die t.Parallel()-Muster und Race-Bedingungsfallen ab, die natürlich mit dem Synctest-Ansatz kombiniert werden.

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("temporary failure")
		}
		return nil
	})

	if err != nil {
		t.Fatalf("Retry returned error: %v", err)
	}

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

Dies wartet etwa eine Sekunde, da zwei Wiederholungsverzögerungen auftreten.

Das klingt vielleicht nicht schlimm, aber multiplizieren Sie es mit vielen Tests und mehreren Paketen. Langsame Tests lassen Entwickler Tests seltener ausführen.

Jetzt 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("temporary failure")
			}
			return nil
		})

		if err != nil {
			t.Fatalf("Retry returned error: %v", err)
		}

		if calls != 3 {
			t.Fatalf("calls = %d, want 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 einführt

Ich würde es schrittweise einführen.

Schritt 1: Finden Sie instabile oder langsame parallele Tests

Suchen Sie nach echten Schlafen und timeout-lastigen Tests. Die Grep-Befehle im vorherigen Abschnitt sind ein guter Ausgangspunkt, um Kandidaten über die Codebasis hinweg zu identifizieren.

Schritt 2: Wählen Sie ein Paket

Wählen Sie ein Paket, das klares asynchrones Verhalten hat, aber keine echten externen Dienste erfordert. Arbeiter-Pakete, Wiederholungs-Helfer und Timer-gesteuerte Komponenten sind ideale erste Ziele.

Schritt 3: Konvertieren Sie einen Test

Hüllen Sie den Test in synctest.Test ein und ersetzen Sie willkürliche Schlafen durch synctest.Wait, gefälschte Zeit-Schlafen oder explizite Synchronisation. Die Konvertierung ist normalerweise klein – der schwierigste Teil ist sicherzustellen, dass Goroutinen saubere Shutdown-Pfade haben.

Schritt 4: Führen Sie mit dem Race-Detektor aus

Führen Sie immer mit go test -race ./... nach der Konvertierung aus. Ein bestandener Synctest-Test bedeutet nicht, dass der Code race-frei ist; es bedeutet nur, dass das asynchrone Timing nun deterministisch ist.

Schritt 5: Überprüfen Sie den Goroutine-Lebenszyklus

Stellen Sie sicher, dass jede Goroutine, die vom Test gestartet wurde, einen Weg hat, zu exitieren, bevor die Blase schließt. Wenn nicht, wird synctest.Test das Leck aufdecken, anstatt es stillschweigend zu ignorieren.

Schritt 6: Wiederholen Sie nur, wo es die Klarheit verbessert

Konvertieren Sie Tests nicht nur aus Modegründen. Ein guter Synctest-Test sollte messbar schneller, klarer zu lesen oder weniger instabil sein als die Version, die er ersetzt hat – wenn nicht, war die Konvertierung nicht wertvoll.

Meine eigensinnigen Regeln

Verwenden Sie diese als praktische Daumenregeln.

Regel 1: Keine willkürlichen Schlafen in parallelen Unit-Tests

Ein Schlaf, der wartet, dass eine Goroutine vielleicht fertig wird, ist ein Geruch. Ersetzen Sie ihn durch Channels, WaitGroups, Callbacks, synctest.Wait oder gefälschte Zeit – alles, was auf eine Bedingung wartet, anstatt zu hoffen, dass genug Zeit vergangen ist.

Regel 2: Halten Sie Synctest-Tests selbstständig

Erstellen Sie Goroutinen, Channels, Kontexte, Timer und Arbeiter innerhalb der Blase. Vermeiden Sie paketweiten gemeinsamen Zustand, der zwischen Tests lecken kann und die Isolation bricht, 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 außerhalb von Synctest, 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 sinnvollen Zustand erreicht hat, was synctest.Wait ermöglicht, ohne von Timing-Annahmen abhängig zu sein.

Regel 5: Halten Sie Absagepfade explizit

Jede Hintergrundgoroutine sollte einen Shutdown-Pfad haben, und Tests sollten beweisen, dass dieser Pfad funktioniert, indem sie den Kontext absagen oder den Channel schließen und dann überprüfen, ob die Goroutine sauber exitiert.

Abschließende Gedanken

testing/synctest ist eines dieser Go-Features, das klein aussieht, aber ändert, wie Sie eine Klasse von Tests schreiben. Es ersetzt kein gutes Parallelitätsdesign, den Race-Detektor oder den Bedarf an Integrationstests – aber es macht viele asynchrone Unit-Tests schneller, sauberer und weit weniger abhängig von Timing-Glück.

Das ist wichtig, weil paralleler Code schon schwer genug ist. Tests sollten Unsicherheit reduzieren, nicht hinzufügen. Für eine breitere Sicht auf Produktions-Go-Muster über Integration, Code-Struktur und Datenzugriff hinweg, siehe App Architecture in Production.

Die praktische Takeaway ist einfach:

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.

Diese eine Gewohnheit wird viele Go-Testsuiten schneller und weniger instabil machen.


Die wichtigsten aktuellen Fakten sind: testing/synctest wurde in Go 1.25 allgemein verfügbar, es exponiert synctest.Test und synctest.Wait, es führt Tests innerhalb einer isolierten Blase aus, und die Zeit innerhalb dieser Blase verwendet eine gefälschte Uhr, die nur dann voranschreitet, wenn Goroutinen dauerhaft blockiert sind.

Quellen

Abonnieren

Neue Beiträge zu Systemen, Infrastruktur und KI-Engineering.