Testowanie równoległego kodu Go przy użyciu synctest

Przestań śpij w równoległych testach Go.

Page content

Testowanie równoległego kodu w Go zawsze wymagało pewnej dyscypliny. Goroutine są tanie, kanały są proste, a anulowanie kontekstu jest idiomatyczne — pracownicy tła i liczniki pojawiają się wszędzie w rzeczywistych usługach Go.

Ale testowanie wszystkich tych elementów w sposób niezawodny jest trudniejsze niż ich pisanie.

Testowanie równoległego kodu Go za pomocą synctest

Typowy, zły wzorzec jest znany:

go doSomething()

time.Sleep(100 * time.Millisecond)

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

Ten test może przejść na Twoim laptopie, ale nie przejść w CI. Albo może przechodzić przez sześć miesięcy, a potem zawiedzie na obciążonym runnerze. Albo może być powolny, bo ktoś zwiększył czas snu z 100 milisekund do 2 sekund „dla bezpieczeństwa".

To nie jest dobre testowanie — to hazardowanie z timerem, a stawka tego hazardu rośnie wraz z rozbieraniem się zestawu testów.

Pakiet testing/synctest daje twórcom Go lepszy sposób na testowanie wielu form kodu asynchronicznego i zależnego od czasu. Pozwala on testowi działać w odizolowanej bańce, wyposaża tę bańkę w fałszywe zegary i zapewnia sposób na czekanie, aż goroutine wewnątrz bańki zostaną zablokowane.

Wynik jest prosty, ale potężny:

  • Brak arbitralnych snów (time.Sleep)
  • Szybsze testy timeoutów
  • Bardziej deterministyczne testy równoległe
  • Lepsze testowanie anulowania kontekstu
  • Lepsze testowanie goroutine tła
  • Mniej niestabilnego CI

Lekko zdanie osobiste: jeśli Twój test równoległy Go zależy od rzeczywistego time.Sleep, powinieneś prawdopodobnie traktować ten test z podejrzeniem.

Czym jest testing/synctest

testing/synctest to pakiet biblioteki standardowej Go służący do testowania kodu równoległego.

Dostarcza on dwie główne funkcje:

package synctest

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

synctest.Test wykonuje funkcję w odizolowanej bańce testowej. Każda goroutine uruchomiona wewnątrz tej bańki staje się częścią bańki, czas wewnątrz bańki jest fałszywy, a pakiet time działa względem tego fałszywego zegara, a nie rzeczywistego zegara ściany.

synctest.Wait czeka, aż wszystkie inne goroutine w bańce będą trwale zablokowane. To brzmi abstrakcyjnie, ale praktyczny efekt jest łatwy do zrozumienia:

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

To nie sprawia, że Twój test czeka 10 rzeczywistych sekund. Wewnątrz bańki synctest czas może natychmiast się przesuwać, gdy bańka jest zablokowana i czeka, aż czas się przesunie do przodu — to jest kluczowy trik stojący za tym pakietem.

Dlaczego testy równoległe w Go są niestabilne

Jeśli jesteś nowy w ogólnym testowaniu Go, Testowanie jednostkowe w Go: Struktura i najlepsze praktyki omawia pakiet testowania, testy oparte na tabelach i wzorce mockowania, które stanowią fundament, na którym opiera się ten artykuł. Testy równoległe są zwykle niestabilne z jednego z trzech powodów.

Po pierwsze, zależą od planisty (schedulera). Goroutine może wykonać się natychmiast na Twoim komputerze, a później w CI.

Po drugie, zależą od rzeczywistego czasu. Test, który śpi przez 50 milisekund, zakłada, że 50 milisekund to wystarczający czas na zakończenie pracy tła.

Po trzecie, obserwują stan zbyt wcześnie. Test sprawdza wynik, zanim operacja tła faktycznie się zakończy.

Oto prosty przykład:

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

Ten test ma dwa problemy.

Oczywisty to sen. Nie ma gwarancji, że 10 milisekund to odpowiednia ilość czasu.

Mniej oczywisty to wyścig danych (data race). Test zapisuje done w jednej goroutine i odczytuje w drugiej bez synchronizacji.

Ten konkretny przykład można naprawić za pomocą kanału lub sync.WaitGroup i często tak właśnie należy zrobić. Ale gdy kod testowany używa timerów, terminów kontekstu, time.AfterFunc, pracowników tła lub opóźnionego czyszczenia, test nadal może być niewygodny — a to jest dokładnie to miejsce, w którym testing/synctest pomaga.

Pomysł przewodni: uruchamianie testu w bańce

Bańka synctest izoluje goroutine utworzone w jej wnętrzu.

Użyj jej w ten sposób:

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

Wewnątrz bańki:

  • Goroutine uruchomione przez test należą do bańki.
  • Timery i sny używają fałszywego zegara.
  • synctest.Wait może czekać, aż aktywność tła się uspokoi.
  • Test powinien unikać zależności od zewnętrznych goroutine, rzeczywistego I/O sieciowego lub zewnętrznych procesów.

Bańka to nie magia. Nie zamienia złego projektu równoległego w dobry. Ale daje Twemu testowi kontrolowane środowisko, w którym czas i zachowania blokujące są bardziej deterministyczne.

Problem z time.Sleep w testach

Prawdziwy time.Sleep w teście zazwyczaj oznacza jedno z dwóch rzeczy:

Nie wiem, jak czekać na zdarzenie, o które naprawdę mi chodzi.

albo:

Wiem, o co mi chodzi, ale testowany kod nie wystawia czystego sposobu na zaobserwowanie tego.

Oba to sygnały projektowe, które warto traktować poważnie — wskazują na miejsca, w których kod produkcyjny może skorzystać z czystszej obserwowalności lub bardziej jawnych mechanizmów koordynacji.

Rozważmy funkcję, która wykonuje pracę w tle:

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
}

Zły test może wyglądać tak:

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

Ten test czeka sześć rzeczywistych sekund.

To jest wolne. Jeśli masz wiele takich testów, zestaw staje się bolesny.

Lepszy test z synctest może przesunąć fałszywy czas natychmiast:

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

Test nadal wyraża fakt biznesowy — pracownik powinien skończyć po 5 sekundach — ale nie spędza 5 rzeczywistych sekund na tym. To jest różnica między testowaniem zachowania zależnego od czasu a marnowaniem czasu dewelopera.

Testowanie timeoutów kontekstu

Jednym z najlepszych zastosowań testing/synctest jest testowanie terminów i timeoutów context.Context. To, jak anulowanie kontekstu i budżety timeoutów powinny być strukturyzowane przez warstwy usługi, jest omówione w Go context.Context Done Right, a prawidłowe propagowanie context.Canceled i context.DeadlineExceeded przez te warstwy jest omówione w Architektura obsługi błędów w Go: Granice i wzorce — synctest pozwala zweryfikować to zachowanie bez upływu rzeczywistego czasu.

Oto prosta funkcja, która czeka, aż kontekst zostanie anulowany:

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

Bez synctest, testowanie tego z 30-sekundowym timeoutem albo spowolni test, albo zmusi Cię do zmiany timeoutu tylko na potrzeby testu.

Z synctest możesz szybko przetestować rzeczywisty czas timeoutu:

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

To jest ten rodzaj testu, który synctest sprawia, że staje się przyjemny.

Możesz zachować realistyczne wartości timeoutów w kodzie i nadal szybko uruchamiać testy.

Testowanie anulowania kontekstu

Możesz też testować jawne anulowanie, nie wyścigując z goroutine tła.

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

Ważnym detalem jest synctest.Wait.

Daje goroutine tła szansę na zaobserwowanie anulowania i ustabilizowanie się, zanim test sprawdzi wynik.

Co robi synctest.Wait

synctest.Wait czeka, aż wszystkie inne goroutine w bańce będą trwale zablokowane.

Zwykłym językiem oznacza to:

Czekaj, aż goroutine wewnątrz tego testu osiągną stabilny punkt blokady.

To jest przydatne, gdy test uruchamia goroutine i musi wiedzieć, że goroutine się skończyła albo czeka.

Na przykład:

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

To jest celowo małe, ale demonstruje pomysł.

synctest.Wait to nie po prostu lepszy sen — to punkt synchronizacji wewnątrz bańki, a ta różnica ma większe znaczenie, niż mogłoby się wydawać na pierwszy rzut oka.

Sen mówi:

Mam nadzieję, że minął wystarczający czas.

Wait mówi:

Chcę, aby bańka osiągnęła stabilny stan zablokowany.

Druga opcja jest znacznie lepsza dla testów, ponieważ opisuje obserwowalny stan, a nie zgadywanie co do upływu czasu.

Fałszywy czas w bańce synctest

Wewnątrz bańki synctest pakiet time używa fałszywego zegara.

Fałszywy zegar startuje od stałego czasu. Przesuwa się tylko wtedy, gdy każda goroutine w bańce jest trwale zablokowana i czas musi się przesunąć do przodu, aby odblokować coś.

Oznacza to, że ten test jest szybki:

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

Czyta się tak, jakby czekał godzinę.

Nie czeka.

To jest przydatne do testowania:

  • timeoutów
  • terminów
  • ponowień (retries)
  • cofania się (backoff)
  • opóźnionego czyszczenia
  • limitów szybkości
  • timerów
  • tyków (tickers)
  • anulowania kontekstu

Ale jest tu jedna ważna zasada: fałszywy czas pomaga tylko kodowi, który używa pakietu time wewnątrz bańki.

Jeśli Twój kod zależy od zewnętrznego systemu, rzeczywistego I/O sieciowego lub czasu mierzonego poza bańką, synctest nie może tego uczynić deterministycznym.

Testowanie pętli ponowień

Pętle ponowień są częstym źródłem wolnych i niestabilnych testów.

Oto mały pomocnik ponowień:

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
}

Zwykły test może zmniejszyć opóźnienie do 1 milisekundy, aby utrzymać zestaw szybkim.

To nie jest straszne, ale oznacza, że test nie ćwiczy już rzeczywistej wartości używanej przez kod produkcyjny.

Z synctest możesz zachować rzeczywiste opóźnienie:

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

Test reprezentuje dwa 10-sekundowe czekania.

Nadal działa szybko.

Tu synctest zmienia ekonomię testowania. Nie musisz już rozrzucać fałszywych, minimalnych czasów przez testy tylko po to, by uniknąć wolnego CI.

Testowanie anulowania ponowień

Możesz też testować anulowanie podczas opóźnienia ponowienia:

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

Ten test sprawdza, czy pętla ponowień reaguje na anulowanie, zamiast spać przez opóźnienie.

To jest dokładnie ten rodzaj zachowania, który ma znaczenie w produkcji.

Testowanie time.AfterFunc

time.AfterFunc to kolejne dobre zastosowanie.

Załóż, że masz funkcję, która planuje czyszczenie:

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
}

Test może przesunąć fałszywy czas:

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

Ten test weryfikuje obie strony:

  • Czyszczenie nie następuje przed opóźnieniem.
  • Czyszczenie następuje po opóźnieniu.

I nie czeka rzeczywistej minuty.

Testowanie tyków (tickers)

Tyki można również testować za pomocą fałszywego czasu, ale bądź ostrożny. Tyki są często używane w pętlach o długim czasie trwania, a pętle o długim czasie trwania potrzebują czystej ścieżki zatrzymania.

Oto mały licznik oparty na tykach:

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
}

Test może wyglądać tak:

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

Ten przykład ma celowy detal projektowy: pracownik ma ścieżkę zatrzymania.

To jest dobre nie tylko dla testów. Jest dobre dla produkcji.

Testy często ujawniają, czy Twoje goroutine mogą faktycznie się zatrzymać.

synctest a wycieki goroutine

testing/synctest jest tu pomocny, ponieważ synctest.Test czeka na wyjście goroutine z bańki przed powrotem, co oznacza, że wyciekłe goroutine są trudniejsze do zignorowania. Jeśli goroutine tła nigdy się nie kończy, test się nie powiedzie, zamiast cicho zostawić pracę za sobą — a to jest dobra rzecz.

Kod równoległy powinien mieć jasną własność. Jeśli funkcja uruchamia goroutine, powinna istnieć jawny sposób na jej zatrzymanie, lub udokumentowany powód, dla którego jest mu pozwolone żyć wiecznie. W testach „wiecznie" jest prawie nigdy akceptowalne. Własność oraz limit równoległości to nawyk projektowy, który ten artykuł łączy z: Ograniczanie równoległości w Go omawia wzorce po stronie produkcyjnej, których ścieżki wycieków bańka łapie w milisekundy.

Dobry wzorzec to:

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

A potem spraw, aby goroutine się zatrzymała, gdy kontekst zostanie anulowany.

Co oznacza „trwale zablokowany" w praktyce

Oficjalne dokumenty używają terminu „trwale zablokowany".

Nie musisz zapamiętywać każdego detalu dotyczącego środowiska wykonawczego, ale powinieneś rozumieć praktyczne znaczenie.

Goroutine jest trwale zablokowana, gdy jest zablokowana w sposób, który może zostać odblokowany tylko przez coś wewnątrz tej samej bańki synctest.

Przykłady obejmują:

  • odbieranie z kanału utworzonego wewnątrz bańki
  • wysyłanie do kanału utworzonego wewnątrz bańki
  • oczekiwanie na sync.WaitGroup powiązanego z bańką
  • spanie z time.Sleep
  • oczekiwanie na pewne operacje timerów

Niektóre rzeczy nie są trwale zablokowane, ponieważ coś poza bańką może je odblokować.

Przykłady obejmują:

  • I/O sieciowy
  • wywołania systemowe
  • operacje na zewnętrznych procesach
  • pewne oczekiwania na mutexy
  • interakcje z goroutine poza bańką

Dlatego testy synctest powinny być samowystarczalne i wolne od zewnętrznej synchronizacji, której bańka nie może zobaczyć. Nie używaj synctest jako opakowania dla testów integracyjnych, które rozmawiają z rzeczywistą siecią.

Do czego synctest jest dobry

testing/synctest jest szczególnie dobry dla testów jednostkowych wokół zachowań asynchronicznych.

Dobrymi kandydatami są:

  • anulowanie kontekstu
  • timeouty kontekstu
  • pętle ponowień
  • logika cofania się (backoff)
  • opóźnione czyszczenie
  • pracownicy sterowani timerami
  • pętle sterowane tykami
  • goroutine tła
  • zachowanie timeoutów
  • koordynacja kanałami
  • time.AfterFunc
  • deterministyczne czekanie na goroutine

Najlepszy przypadek użycia to kod, w którym trudną częścią jest czas lub planowanie, a nie zewnętrzne I/O.

Do czego synctest nie jest dobry

testing/synctest nie jest zamiennikiem dla wszystkich testów równoległych.

To nie jest pełny, deterministyczny planista dla każdego możliwego wyścigu.

To nie jest zamiennik dla wykrywacza wyścigów (race detector).

To nie jest zamiennik dla testów integracyjnych.

Nie sprawia, że rzeczywiste I/O sieciowe staje się deterministyczne.

Nie naprawia złego projektu cyklu życia goroutine.

Nie oznacza to, że możesz ignorować kanały, konteksty, własność i zatrzymanie.

Używaj synctest dla odpowiedniej warstwy: deterministycznych testów jednostkowych dla zachowań równoległych i zależnych od czasu.

Używaj innych narzędzi dla innych warstw:

  • używaj go test -race do wykrywania wyścigów danych
  • używaj testów integracyjnych dla rzeczywistych zależności
  • używaj testów obciążeniowych dla przepustowości i konkurencji
  • używaj benchmarków dla wydajności
  • używaj śledzenia (tracing) i profilowania dla zachowań produkcyjnych

synctest vs wykrywacz wyścigów

testing/synctest i wykrywacz wyścigów rozwiązują różne problemy.

Wykrywacz wyścigów znajduje niebezpieczny równoległy dostęp do pamięci.

synctest pomaga Ci kontrolować asynchroniczne czasy i czekanie w testach.

Często powinieneś używać obu.

Na przykład, to nadal jest wyścig nawet wewnątrz bańki synctest, jeśli nie ma prawidłowej synchronizacji:

value := 0

go func() {
	value = 1
}()

_ = value

synctest.Wait może zapewnić punkt synchronizacji dla niektórych wzorców testów, ale nie oznacza to, że każdy równoległy dostęp w Twoim kodzie jest automatycznie bezpieczny.

Uruchamiaj testy równoległe z:

go test -race ./...

Wykrywacz wyścigów nadal jest jednym z najlepszych narzędzi, jakie Go Ci daje. Łączenie go z Lintery Go: Podstawowe narzędzia dla jakości kodu daje Ci solidną bazę analizy statycznej i sprawdzania w czasie wykonania dla każdego kodobazu równoległego.

synctest vs ręczne fałszywe zegary

Przed testing/synctest wiele zespołów używało ręcznych fałszywych zegarów.

To nadal może być dobrym projektem.

Interfejs ręcznego zegara może wyglądać tak:

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

A potem kod produkcyjny używa rzeczywistego zegara, a testy używają fałszywego zegara.

Daje to jawną kontrolę, ale ma swoją cenę:

  • więcej interfejsów
  • więcej okablowania (plumbing)
  • więcej abstrakcji tylko dla testów
  • więcej sposobów, aby kod przypadkowo pominął fałszywy zegar

synctest jest atrakcyjny, ponieważ zwykły kod używający pakietu time może działać względem fałszywego czasu wewnątrz testowej bańki.

To zmniejsza potrzebę wstrzykiwania zegara w wielu przypadkach.

Moje zdanie: używaj synctest, gdy pozwala to utrzymać kod produkcyjny prostszym. Używaj wstrzykiwanego zegara tylko wtedy, gdy kontrola zegara jest częścią Twojego projektu domenowego lub gdy potrzebujesz kontroli poza tym, co synctest zapewnia. Aby uzyskać szerszy obraz wzorców wstrzykiwania zależności w Go — w tym kiedy i jak wstrzykiwać testowalne abstrakcje — zobacz Wstrzykiwanie zależności w Go: Wzorce i najlepsze praktyki.

synctest vs kanały i WaitGroups

Nie zastępuj dobrej synchronizacji synctest.

Jeśli Twój kod może wystawić kanał zakończenia, wywołanie zwrotne (callback) lub metodę Wait, to często jest dobrym projektem.

Na przykład:

type Server struct {
	done chan struct{}
}

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

Test może czekać na to bezpośrednio.

synctest jest najprzydatniejszy, gdy zachowanie pod testem dotyczy czasu, terminów kontekstu, planowania tła lub asynchronicznych wywołań zwrotnych.

Najlepsze testy często łączą oba:

  • kod produkcyjny ma jawne sygnały zatrzymania lub zakończenia
  • synctest eliminuje czekanie na rzeczywisty czas
  • Wait sprawia, że aktywność tła staje się deterministyczna

Częste błędy

Błąd 1: Otaczanie każdego testu synctest

Nie używaj synctest wszędzie. Jeśli kod jest synchroniczny, zwykła funkcja testowa jest jaśniejsza, a dodawanie opakowania bańki wprowadza tylko niepotrzebny mechanizm, który sprawia, że testy są trudniejsze do czytania i rozumienia.

Błąd 2: Testowanie rzeczywistego I/O sieciowego wewnątrz bańki

Utrzymuj testy synctest samowystarczalne. Jeśli Twój test używa rzeczywistego gniazda sieciowego, zewnętrznej usługi, bazy danych lub podprocesu, należy go umieścić w teście integracyjnym, a nie wewnątrz bańki synctest. Używaj fałszywych obiektów (fakes) dla testów jednostkowych i zostawiaj rzeczywiste zależności dla oddzielnych testów integracyjnych, gdzie izolacja bańki nie ma zastosowania. Gdy zależnością jest obsłużenie HTTP, httptest.NewTestServer z Go 1.27 eliminuje potrzebę rzeczywistego gniazda całkowicie — uruchamia serwer przez pamięciową fałszywą sieć, co jest dokładnie tym, czego potrzebuje bańka, i łączy się z synctest dla deterministycznego czasowania przejść tam i z powrotem, jak pokazano w Przewodniku po aktualizacji Go 1.27.

Błąd 3: Wycieki goroutine

Jeśli Twój test uruchamia goroutine, upewnij się, że ma wyraźną ścieżkę wyjścia. Używaj anulowania kontekstu, zamkniętych kanałów lub jawnych metod zatrzymania — goroutine, która nigdy się nie zatrzymuje, jest zarówno zapachem produkcyjnym, jak i zapachem testowym, który synctest ujawni, zamiast ukrywać.

Błąd 4: Zależność od stanu na poziomie pakietu

Kanały, timery i WaitGroups na poziomie pakietu mogą w subtelny sposób łamać izolację bańki. Wolisz tworzyć cały stan testu wewnątrz funkcji synctest.Test, aby każdy zasób należał do bańki, a jego czas życia był wyraźnie ograniczony do testu.

Błąd 5: Traktowanie fałszywego czasu jak rzeczywistego czasu

Fałszywy czas służy do deterministycznych testów, a nie do pomiaru wydajności. Test, który natychmiast przesuwa się o godzinę, nie mówi nic użytecznego o koszcie CPU, konkurencji o blokadę, użyciu pamięci lub rzeczywistym zachowaniu planisty w produkcji — używaj benchmarków i testów obciążeniowych dla tych pytań.

Błąd 6: Ignorowanie wykrywacza wyścigów

synctest nie jest zamiennikiem dla go test -race, a oba narzędzia rozwiązują różne problemy. Uruchamiaj wykrywacz wyścigów równolegle z testami synctest, aby łapać niebezpieczny równoległy dostęp do pamięci, którego bańka samodzielnie nie może wykryć.

Praktyczna lista kontrolna

Użyj tej listy kontrolnej podczas pisania testów z testing/synctest.

Używaj synctest, gdy

  • kod uruchamia goroutine
  • kod używa time.Sleep
  • kod używa timerów lub tyków
  • kod używa terminów kontekstu
  • kod ma zachowanie ponowień lub cofania
  • test obecnie używa arbitralnych snów
  • test jest niestabilny w CI
  • test jest wolny, ponieważ czeka na rzeczywisty czas

Unikaj synctest, gdy

  • kod jest synchroniczny
  • test zależy od rzeczywistego I/O sieciowego
  • test zależy od zewnętrznych procesów
  • test to w rzeczywistości test integracyjny
  • próbujesz mierzyć wydajność
  • kod nie ma czystej ścieżki zatrzymania

Wolisz ten wzorzec

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

Ten wzorzec jest prosty:

  • ustawiaj wewnątrz bańki
  • uruchamiaj pracę wewnątrz bańki
  • czekaj, aż aktywność tła się uspokoi
  • przesuń fałszywy czas tylko wtedy, gdy jest potrzebny
  • asertuj po synchronizacji

Gdzie używać testing/synctest w rzeczywistych projektach

Najlepsze miejsca do szukania to zwykle nie prosta logika biznesowa.

Szukaj testów z tymi zapachami:

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

A potem zapytaj:

  • Czy ten test jest wolny, ponieważ czeka na rzeczywisty czas?
  • Czy ten test jest niestabilny, ponieważ zakłada, że goroutine już się wykonała?
  • Czy ten test można odizolować od sieci i zewnętrznych procesów?
  • Czy goroutine tła można czysto zatrzymać?
  • Czy fałszywy czas sprawiłby, że asercja byłaby jaśniejsza?

Dobrymi kandydatami są często:

  • pakiety pracowników
  • pakiety ponowień
  • pakiety cache
  • pakiety planistów
  • konsumenty kolejek
  • opakowania klientów HTTP
  • pośredniki timeoutów (timeout middleware)
  • kod czyszczenia tła
  • kod limitowania szybkości

Zacznij od jednego niestabilnego testu. Nie migruj całego kodobazu naraz. Jeśli Twój zestaw testów używa równoległych testów opartych na tabelach obok kodu asynchronicznego, Równoległe testy oparte na tabelach w Go omawia wzorce t.Parallel() i pułapki wyścigów danych, które naturalnie łączą się z podejściem synctest.

Przykład: przed i po

Oto realistyczny zły 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)
	}
}

To czeka około sekundy, ponieważ występują dwa opóźnienia ponowień.

To może nie brzmieć źle, ale pomnóż to przez wiele testów i kilka pakietów. Wolne testy sprawiają, że deweloperzy rzadziej uruchamiają testy.

A teraz wersja synctest:

func TestRetryWithSynctest(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		calls := 0

		err := Retry(t.Context(), 3, 500*time.Millisecond, func() error {
			calls++
			if calls < 3 {
				return errors.New("temporary failure")
			}
			return nil
		})

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

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

Test zachowuje rzeczywistą wartość opóźnienia, zestaw pozostaje szybki, a intencja jest jaśniejsza. To jest główna wartość testing/synctest.

Jak bezpiecznie wdrożyć synctest

Wdrożyłbym to stopniowo.

Krok 1: Znajdź niestabilne lub wolne testy równoległe

Szukaj rzeczywistych snów i testów obciążonych timeoutami. Polecenia grep z poprzedniej sekcji to dobry punkt wyjścia do identyfikacji kandydatów w całym kodobazie.

Krok 2: Wybierz jeden pakiet

Wybierz pakiet, który ma wyraźne zachowania asynchroniczne, ale nie wymaga rzeczywistych zewnętrznych usług. Pakiety pracowników, pomocniki ponowień i komponenty sterowane timerami to idealne pierwsze cele.

Krok 3: Przekonwertuj jeden test

Otocz test w synctest.Test i zastąp arbitralne sny synctest.Wait, snami fałszywego czasu lub jawną synchronizacją. Konwersja jest zwykle mała — najtrudniejszą częścią jest upewnienie się, że goroutine mają czyste ścieżki zatrzymania.

Krok 4: Uruchamiaj z wykrywaczem wyścigów

Zawsze uruchamiaj z go test -race ./... po konwersji. Przechodzący test synctest nie oznacza, że kod jest wolny od wyścigów; oznacza tylko, że czasowanie asynchroniczne jest teraz deterministyczne.

Krok 5: Przeglądaj cykl życia goroutine

Upewnij się, że każda goroutine uruchomiona przez test ma sposób na wyjście, zanim bańka się zamknie. Jeśli nie ma, synctest.Test ujawni wyciek, zamiast go cicho ignorować.

Krok 6: Powtarzaj tylko tam, gdzie poprawia jasność

Nie konwertuj testów tylko z mody. Dobra konwersja synctest powinna być mierzalnie szybsza, jaśniejsza do czytania lub mniej niestabilna niż wersja, którą zastępuje — jeśli nie, konwersja nie była warta wysiłku.

Moje zdanie o zasadach

Używaj tych jako praktycznych reguł palca (rules of thumb).

Zasada 1: Brak arbitralnych snów w równoległych testach jednostkowych

Sen, który czeka, aby goroutine „może" się zakończyła, to zapach. Zastąp go kanałami, WaitGroupami, wywołaniami zwrotnymi, synctest.Wait lub fałszywym czasem — czymkolwiek, co czeka na warunek, a nie liczy na to, że minął wystarczający czas.

Zasada 2: Utrzymuj testy synctest samowystarczalnymi

Twórz goroutine, kanały, konteksty, timery i pracowników wewnątrz bańki. Unikaj współdzielonego stanu na poziomie pakietu, który może wyciekać między testami i łamać izolację, która czyni synctest przydatnym.

Zasada 3: Nie używaj synctest jako opakowania dla testów integracyjnych

Jeśli test rozmawia z rzeczywistą bazą danych, rzeczywistą siecią lub zewnętrznym procesem, trzymaj go z dala od synctest, chyba że masz bardzo konkretny powód, aby to robić.

Zasada 4: Testuj zachowanie, a nie szczęście planisty

Celem nie jest zmuszenie goroutine do działania. Celem jest weryfikacja obserwowanego zachowania, po tym, jak system osiągnął znaczący stan, co synctest.Wait umożliwia bez zależności od założeń dotyczących czasowania.

Zasada 5: Utrzymuj ścieżki anulowania jawnymi

Każda goroutine tła powinna mieć ścieżkę zatrzymania, a testy powinny udowadniać, że ta ścieżka działa, anulując kontekst lub zamykając kanał, a następnie weryfikując, czy goroutine kończy się czysto.

Myśli końcowe

testing/synctest to jedna z tych cech Go, które wyglądają na małe, ale zmieniają sposób, w jaki piszesz klasę testów. Nie zastępuje dobrego projektu równoległego, wykrywacza wyścigów ani potrzeby testów integracyjnych — ale sprawia, że wiele asynchronicznych testów jednostkowych staje się szybszych, czystszych i znacznie mniej zależnych od szczęścia w czasowaniu.

To ma znaczenie, ponieważ kod równoległy jest już wystarczająco trudny. Testy powinny zmniejszać niepewność, a nie do niej dokładać. Aby uzyskać szerszy obraz produkcyjnych wzorców Go obejmujących integrację, strukturę kodu i dostęp do danych, zobacz Architektura aplikacji w produkcji.

Praktyczny wniosek jest prosty:

Używaj synctest dla deterministycznych testów jednostkowych wokół goroutine, timerów, timeoutów, ponowień i anulowania.
Trzymaj rzeczywiste sny z dala od testów równoległych, chyba że masz bardzo dobry powód.

Ten jeden nawyk sprawi, że wiele zestawów testów Go będzie szybszych i mniej niestabilnych.


Ważne aktualne fakty to: testing/synctest stał się ogólnie dostępny w Go 1.25, wystawia synctest.Test i synctest.Wait, uruchamia testy w odizolowanej bańce, a czas wewnątrz tej bańki używa fałszywego zegara, który przesuwa się tylko wtedy, gdy goroutine są trwale zablokowane.

Źródła

Subskrybuj

Otrzymuj nowe wpisy o systemach, infrastrukturze i inżynierii AI.