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

Przestań zasypiać podczas współbieżnych testów w Go.

Page content

Testowanie kodu Go uruchamianego współbieżnie zawsze wymagało pewnej dyscypliny. Gorutyny są tanie, kanały proste, a anulowanie kontekstu jest idiomsatyczne — pracownicy tła i timery są wszędzie w rzeczywistych usługach Go.

Jednak testowanie tego wszystkiego w sposób niezawodny jest trudniejsze niż samo pisanie kodu.

Testowanie współbieżnego kodu Go za pomocą synctest

Typowy, zły wzorzec jest dobrze znany:

go doSomething()

time.Sleep(100 * time.Millisecond)

if !done {
	t.Fatal("praca tła nie została ukończona")
}

Ten test może przejść na Twoim laptopie, ale zawieść w CI. Może też przechodzić przez sześć miesięcy, a potem zawieść na obciążonym runnerze. Albo może być powolny, ponieważ ktoś zwiększył czas uśpienia z 100 milisekund do 2 sekund „dla pewności”.

To nie jest dobre testowanie — to hazard z timerem, a ten hazard staje się coraz droższy, wraz ze wzrostem zestawu testów.

Pakiet testing/synctest daje developerom Go lepszy sposób na testowanie wielu form kodu asynchronicznego i zależnego od czasu. Pozwala on uruchomić test wewnątrz izolowanej „bąbelki”, zapewnia tej bąbelki fałszywy zegar i dostarcza sposób na oczekiwanie, aż gorutyny wewnątrz bąbelki zostaną zablokowane.

Rezultat jest prosty, ale potężny:

  • Brak arbitralnych uśpien
  • Szybsze testy timeoutów
  • Bardziej deterministyczne testy współbieżne
  • Lepsze testowanie anulowania kontekstu
  • Lepsze testowanie gorutin tła
  • Mniej losowych awarii w CI

Nieco opiniowana wersja: jeśli Twój współbieżny test Go zależy od rzeczywistego time.Sleep, powinieneś traktować ten test jako podejrzany.

Czym jest testing/synctest

testing/synctest to pakiet ze standardowej biblioteki Go służący do testowania kodu współbieżnego.

Dostarcza dwie główne funkcje:

package synctest

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

synctest.Test uruchamia funkcję wewnątrz izolowanej bąbelki testowej. Każda gorutyna uruchomiona wewnątrz tej bąbelki jest również jej częścią, czas wewnątrz bąbelki jest fałszywy, a pakiet time działa względem tego fałszywego zegara, a nie rzeczywistego zegara ściennego.

synctest.Wait oczekuje, aż wszystkie inne gorutyny w bąbelce zostaną 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 oznacza, że Twój test będzie czekał 10 rzeczywistych sekund. Wewnątrz bąbelki synctest czas może przesuwać się natychmiastowo, gdy bąbelka jest zablokowana i czeka na przemieszczenie się czasu do przodu — to jest główny trik stojący za tym pakietem.

Dlaczego współbieżne testy Go są niestabilne

Jeśli jesteś nowy w testowaniu Go ogólnie, Testowanie jednostkowe w Go: Struktura i najlepsze praktyki omawia pakiet testing, testy oparte na tabelach i wzorce mockowania, które stanowią podstawę, na której buduje ten artykuł. Współbieżne testy są zwykle niestabilne z jednego z trzech powodów.

Po pierwsze, zależą one od planisty. Gorutyna może uruchomić się natychmiast na Twojej maszynie, a później w CI.

Po drugie, zależą one od rzeczywistego czasu. Test, który śpi przez 50 milisekund, zakłada, że 50 milisekund to wystarczająco dużo czasu, aby praca tła została ukończona.

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

Oto prosty przykład:

func TestBackgroundWorkBad(t *testing.T) {
	done := false

	go func() {
		done = true
	}()

	time.Sleep(10 * time.Millisecond)

	if !done {
		t.Fatal("praca tła nie została ukończona")
	}
}

Ten test ma dwa problemy.

Ten oczywisty to uśpienie. Nie ma gwarancji, że 10 milisekund to odpowiednia ilość czasu.

Ten mniej oczywisty to wyścig danych. Test zapisuje done w jednej gorutynie i odczytuje ją w drugiej bez synchronizacji.

Możesz naprawić ten konkretny przykład za pomocą kanału lub sync.WaitGroup, i często powinieneś to robić. Ale gdy kod podlegający testowi używa timerów, limitów kontekstu, time.AfterFunc, pracowników tła lub opóźnionego sprzątania, test może stać się niezręczny — i to dokładnie tam, gdzie testing/synctest pomaga.

Główna idea: uruchom test wewnątrz bąbelki

Bąbelka synctest izoluje gorutyny tworzone wewnątrz niej.

Używaj go w ten sposób:

func TestSomethingConcurrent(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		// Testuj tutaj kod współbieżny.
	})
}

Wewnątrz bąbelki:

  • Gorutyny uruchomione przez test należą do bąbelki.
  • Timery i uśpienia używają fałszywego zegara.
  • synctest.Wait może czekać na ustabilizowanie się aktywności tła.
  • Test powinien unikać zależności od zewnętrznych gorutin, rzeczywistej transmisji sieciowej lub zewnętrznych procesów.

Bąbelka nie jest czarną magią. Nie sprawia, że zły projekt współbieżnościowy staje się dobry. Ale daje Twojemu testowi kontrolowane środowisko, w którym zachowanie czasu i blokowania jest bardziej deterministyczne.

Problem z time.Sleep w testach

Rzeczywiste time.Sleep w teście zazwyczaj oznacza jedną z dwóch rzeczy:

Nie wiem, jak czekać na zdarzenie, które mnie naprawdę interesuje.

lub:

Wię, co mnie interesuje, ale kod podlegający testowi nie wystawia czystego sposobu na jego obserwację.

Oba są sygnałami projektowymi, których należy poważnie potraktować — wskazują one na miejsca, w których kod produkcyjny może skorzystać na czystszym nadzorowaniu lub bardziej eksplikityjnych mechanizmach koordynacji.

Rozważmy funkcję, która kończy 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("otrzymano %q, oczekiwano done", got)
		}
	default:
		t.Fatal("pracownik nie skończył")
	}
}

Ten test czeka sześć rzeczywistych sekund.

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

Lepszy test z synctest może przyspieszyć 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("otrzymano %q, oczekiwano done", got)
			}
		default:
			t.Fatal("pracownik nie skończył")
		}
	})
}

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 limitów i timeoutów context.Context. Poprawne propagowanie context.Canceled i context.DeadlineExceeded przez warstwy usług i handlerów zostało omówione szczegółowo 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 sprawiłoby, że test byłby powolny lub zmusiłby Cię do zmiany timeoutu tylko dla testu.

Z synctest możesz szybko przetestować rzeczywistą długość 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("kontekst anulowany zbyt wcześnie: %v", err)
		default:
		}

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

		select {
		case err := <-done:
			if !errors.Is(err, context.DeadlineExceeded) {
				t.Fatalf("otrzymano %v, oczekiwano %v", err, context.DeadlineExceeded)
			}
		default:
			t.Fatal("kontekst nie został anulowany")
		}
	})
}

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

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

Testowanie anulowania kontekstu

Możesz również testować jawne anulowanie bez wyścigu z gorutiną 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("kontekst anulowany zbyt wcześnie: %v", err)
		default:
		}

		cancel()
		synctest.Wait()

		select {
		case err := <-done:
			if !errors.Is(err, context.Canceled) {
				t.Fatalf("otrzymano %v, oczekiwano %v", err, context.Canceled)
			}
		default:
			t.Fatal("kontekst nie został anulowany")
		}
	})
}

Ważnym szczegółem jest synctest.Wait.

Daje on gorutynie tła szansę na obserwację anulowania i ustabilizowanie się przed sprawdzeniem wyniku przez test.

Co robi synctest.Wait

synctest.Wait oczekuje, aż wszystkie inne gorutyny w bąbelce zostaną trwale zablokowane.

W normalnym języku oznacza to:

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

To jest przydatne, gdy test uruchamia gorutynę i musi wiedzieć, że gorutyna要么 skończyła,要么 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("gorutyna nie została uruchomiona")
		}
	})
}

To jest celowo małe, ale demonstruje ideę.

synctest.Wait to nie tylko ładniejsze uśpienie — to punkt synchronizacji wewnątrz bąbelki, i ta różnica ma większe znaczenie, niż się wydaje na pierwszy rzut oka.

Uśpienie mówi:

Mam nadzieję, że minęło wystarczająco dużo czasu.

Wait mówi:

Chcę, aby bąbelka osiągnęła stabilny stan zablokowania.

Drugi jest znacznie lepszy dla testów, ponieważ opisuje obserwowalny warunek, a nie zgadywanie co do upływającego czasu.

Fałszywy czas w bąbelce synctest

Wewnątrz bąbelki synctest pakiet time używa fałszywego zegara.

Fałszywy zegar zaczyna się od stałego czasu. Przesuwa się on tylko wtedy, gdy każda gorutyna w bąbelce jest trwale zablokowana i czas musi się przemieścić do przodu, aby odblokować coś.

To oznacza, ż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("otrzymano %v, oczekiwano %v", elapsed, time.Hour)
		}
	})
}

Wygląda, jakby czekał godzinę.

Nie czeka.

To jest przydatne do testowania:

  • timeoutów
  • limitów
  • ponownych prób
  • cofania (backoff)
  • opóźnionego sprzątania
  • limitów przepustowości
  • timerów
  • tickerów
  • anulowania kontekstu

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

Jeśli Twój kod zależy od zewnętrznego systemu, rzeczywistej transmisji sieciowej lub czasu mierzonym poza bąbelką, synctest nie może sprawić, by to było deterministyczne.

Testowanie pętli ponownych prób

Pętle ponownych prób są częłym źródłem powolnych i niestabilnych testów.

Oto mały pomocnik ponownych prób:

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
}

Normalny test mógłby zmniejszyć opóźnienie do 1 milisekundy tylko po to, aby utrzymać zestaw szybkim.

To nie jest straszne, ale oznacza to, ż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("tymczasowa awaria")
			}
			return nil
		})

		if err != nil {
			t.Fatalf("Retry zwrócił błąd: %v", err)
		}

		if calls != 3 {
			t.Fatalf("wołania = %d, oczekiwano 3", calls)
		}
	})
}

Test reprezentuje dwa 10-sekundowe oczekiwania.

Nadal uruchamia się szybko.

Tutaj synctest zmienia ekonomię testowania. Nie musisz już rozsypywać fałszywych, miniaturowych wartości przez testy tylko po to, aby uniknąć powolnego CI.

Testowanie anulowania ponownych prób

Możesz również testować anulowanie podczas opóźnienia ponownych prób:

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("tymczasowa awaria")
			})
		}()

		synctest.Wait()

		cancel()
		synctest.Wait()

		select {
		case err := <-errCh:
			if !errors.Is(err, context.Canceled) {
				t.Fatalf("otrzymano %v, oczekiwano %v", err, context.Canceled)
			}
		default:
			t.Fatal("Retry nie zwrócił się po anulowaniu")
		}
	})
}

Ten test sprawdza, czy pętla ponownych prób 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łóżmy, że masz funkcję, która planuje sprzątanie:

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 przyspieszyć 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("sprzątanie nastąpiło zbyt wcześnie")
		default:
		}

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

		select {
		case <-cache.Cleaned():
		default:
			t.Fatal("sprzątanie nie nastąpiło")
		}
	})
}

Ten test weryfikuje obie strony:

  • Sprzątanie nie następuje przed opóźnieniem.
  • Sprzątanie następuje po opóźnieniu.

I nie czeka rzeczywistej minuty.

Testowanie tickerów

Tickery mogą być również testowane z fałszywym czasem, ale bądź ostrożny. Tickery są często używane w długotrwałych pętlach, a długotrwałe pętle wymagają czystej ścieżki zamykania.

Oto mały licznik oparty na tickerze:

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

Ten przykład ma celowy szczegół projektowy: pracownik ma ścieżkę zamykania.

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

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

synctest i wycieki gorutin

testing/synctest jest pomocny tutaj, ponieważ synctest.Test czeka na wyjście gorutin w bąbelce przed powrotem, co oznacza, że wyciekające gorutyny są trudniejsze do zignorowania. Jeśli gorutyna tła nigdy nie wyjdzie, test zawiedzie zamiast cicho pozostawiać pracę — i to jest dobra rzecz.

Współbieżny kod powinien mieć jasną własność. Jeśli funkcja uruchamia gorutynę, powinna istnieć eksplikityjna ścieżka do jej zatrzymania, lub udokumentowany powód, dlaczego może żyć wiecznie. W testach „wiecznie” jest prawie zawsze nieakceptowalne.

Dobrym wzorcem jest:

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

Następnie spraw, aby gorutyna się zatrzymała, gdy kontekst zostanie anulowany.

Co oznacza „trwale zablokowane” w praktyce

Oficjalna dokumentacja używa terminu „trwale zablokowane”.

Nie musisz zapamiętywać każdego szczegółu czasu wykonania, ale powinieneś zrozumieć praktyczne znaczenie.

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

Przykłady obejmują:

  • odbieranie z kanału utworzonego wewnątrz bąbelki
  • wysyłanie do kanału utworzonego wewnątrz bąbelki
  • oczekiwanie na sync.WaitGroup powiązany z bąbelką
  • uśpienie z time.Sleep
  • oczekiwanie na pewne operacje timerów

Pewne rzeczy nie są trwale zablokowane, ponieważ coś poza bąbelką może je odblokować.

Przykłady obejmują:

  • transmisję sieciową
  • wywołania systemowe
  • operacje zewnętrznych procesów
  • niektóre oczekiwania na mutexy
  • interakcje z gorutinami poza bąbelką

Dlatego testy synctest powinny być samodzielne i wolne od zewnętrznej synchronizacji, której bąbelka nie widzi. Nie używaj synctest jako otoczki wokół testów integracyjnych, które rozmawiają z rzeczywistą siecią.

Do czego synctest jest dobry

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

Dobrymi kandydatami są:

  • anulowanie kontekstu
  • timeouty kontekstu
  • pętle ponownych prób
  • logika cofania (backoff)
  • opóźnione sprzątanie
  • pracownicy napędzani timerami
  • pętle napędzane tickerami
  • gorutyny tła
  • zachowanie timeoutów
  • koordynacja kanałów
  • time.AfterFunc
  • deterministyczne oczekiwanie na gorutyny

Najlepszym przypadkiem użycia jest kod, w którym trudną częścią jest czas lub planowanie, a nie zewnętrzna transmisja I/O.

Do czego synctest NIE jest dobry

testing/synctest nie jest zastępstwem dla wszystkich testów współbieżnościowych.

Nie jest to pełny deterministyczny planista dla każdej możliwej rywalizacji.

Nie jest to zastępstwo dla detektora wyścigów.

Nie jest to zastępstwo dla testów integracyjnych.

Nie sprawia, że rzeczywista transmisja sieciowa jest deterministyczna.

Nie naprawia złego projektu cyklu życia gorutin.

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

Używaj synctest dla odpowiedniej warstwy: deterministycznych testów jednostkowych dla współbieżnego i zależnego od czasu zachowania.

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 rywalizacji
  • używaj benchmarków dla wydajności
  • używaj śledzenia i profilowania dla zachowań produkcyjnych

synctest vs detektor wyścigów

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

Detektor wyścigów znajduje niebezpieczny współbieżny dostęp do pamięci.

synctest pomaga Ci kontrolować asynchroniczny czas i oczekiwanie w testach.

Powinieneś często używać obu.

Na przykład, to nadal jest wyścig nawet wewnątrz bąbelki synctest, jeśli nie ma odpowiedniej synchronizacji:

value := 0

go func() {
	value = 1
}()

_ = value

synctest.Wait może dostarczyć punkt synchronizacji dla niektórych wzorców testowych, ale nie oznacza to, że każdy współbieżny dostęp w Twoim kodzie jest automatycznie bezpieczny.

Uruchamiaj współbieżne testy z:

go test -race ./...

Detektor wyścigów nadal jest jednym z najlepszych narzędzi, jakie Go Ci daje. Połączenie go z Lintery Go: Niezbędne narzędzia do jakości kodu daje Ci solidną podstawę analizy statycznej i sprawdzania czasu wykonania dla każdej współbieżnej bazy kodu.

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ć dobry projekt.

Ręczny interfejs zegara może wyglądać tak:

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

Następnie kod produkcyjny używa rzeczywistego zegara, a testy używają fałszywego zegara.

To daje eksplikityjną kontrolę, ale ma koszt:

  • więcej interfejsów
  • więcej instalacji
  • więcej abstrakcji tylko dla testów
  • więcej sposobów, w których kod może przypadkowo ominąć fałszywy zegar

synctest jest atrakcyjny, ponieważ zwyczajny kod używający pakietu time może działać przeciwko fałszemu czasowi wewnątrz bąbelki testowej.

To redukuje potrzebę wstrzykiwania zegara w wielu przypadkach.

Moja opinia: używaj synctest, gdy utrzymuje on kod produkcyjny prostszym. Używaj wstrzykanego zegara tylko wtedy, gdy kontrola zegara jest częścią Twojego projektu domenowego lub gdy potrzebujesz kontroli poza tym, co synctest dostarcza. Dla szerszego spojrzenia na wzorce 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, 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 najbardziej przydatny, gdy zachowanie podlegające testowi obejmuje czas, limity kontekstu, planowanie tła lub asynchroniczne callbacki.

Najlepsze testy często łączą oba:

  • kod produkcyjny ma eksplikityjne sygnały zamykania lub zakończenia
  • synctest usuwa oczekiwanie na rzeczywisty czas
  • Wait sprawia, że aktywność tła jest 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 otoczki bąbelki tylko wprowadza niepotrzebne mechanizmy, które sprawiają, że testy są trudniejsze do odczytania i rozumu.

Błąd 2: Testowanie rzeczywistej transmisji sieciowej wewnątrz bąbelki

Utrzymuj testy synctest samodzielne. Jeśli Twój test używa rzeczywistego gniazda sieciowego, zewnętrznej usługi, bazy danych lub procesu podrzędne, należy umieścić go w teście integracyjnym, a nie wewnątrz bąbelki synctest. Używaj fałszywych elementów dla testów jednostkowych i rezerwuj rzeczywiste zależności dla osobnych testów integracyjnych, gdzie izolacja bąbelki nie ma zastosowania.

Błąd 3: Wyciek gorutin

Jeśli Twój test uruchamia gorutynę, upewnij się, że ma jasną ścieżkę wyjścia. Używaj anulowania kontekstu, zamkniętych kanałów lub eksplikityjnych metod stop — gorutyna, która nigdy się nie zatrzymuje, to zarówno zapach produkcyjny, jak i testowy, który synctest ujawni, a nie ukryje.

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

Kanały, timery i WaitGroups na poziomie pakietu mogą złamać izolację bąbelki w subtelne sposoby. Preferuj tworzenie całego stanu testu wewnątrz funkcji synctest.Test, aby każda zasoba należała do bąbelki, a jej czas życia był jasno zakreślony dla testu.

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

Fałszywy czas jest dla deterministycznych testów, a nie pomiaru wydajności. Test, który przesuwa jedną godzinę natychmiastowo, nie mówi Ci nic użytecznego o koszcie CPU, rywalizacji o zamek, użyciu pamięci lub rzeczywistym zachowaniu planowania w produkcji — używaj benchmarków i testów obciążeniowych dla tych pytań.

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

synctest nie jest zastępstwem dla go test -race, a te dwa narzędzia rozwiązują różne problemy. Uruchamiaj detektor wyścigów obok swoich testów synctest, aby złapać niebezpieczny współbieżny dostęp do pamięci, którego sama bąbelka nie może wykryć.

Praktyczna lista kontrolna

Używaj tej listy kontrolnej przy pisaniu testów z testing/synctest.

Używaj synctest, gdy

  • kod uruchamia gorutyny
  • kod używa time.Sleep
  • kod używa timerów lub tickerów
  • kod używa limitów kontekstu
  • kod ma zachowanie ponownych prób lub cofania
  • test aktualnie używa arbitralnych uśpien
  • test jest niestabilny w CI
  • test jest powolny, ponieważ czeka na rzeczywisty czas

Unikaj synctest, gdy

  • kod jest synchroniczny
  • test zależy od rzeczywistej transmisji sieciowej
  • test zależy od zewnętrznych procesów
  • test jest naprawdę testem integracyjnym
  • próbujesz zmierzyć wydajność
  • kod nie ma czystej ścieżki zamykania

Preferuj ten wzorzec

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

		// Działaj.
		_ = ctx

		// Pozwól pracy tła na ustabilizowanie się.
		synctest.Wait()

		// Przesuń fałszywy czas, jeśli potrzebne.
		time.Sleep(1 * time.Second)
		synctest.Wait()

		// Assertuj.
	})
}

Ten wzorzec jest prosty:

  • ustaw wewnątrz bąbelki
  • uruchom pracę wewnątrz bąbelki
  • czekaj na ustabilizowanie się aktywności tła
  • przesuń fałszywy czas tylko wtedy, gdy potrzebne
  • assertuj 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" .

Następnie zapytaj:

  • Czy ten test jest powolny, bo czeka na rzeczywisty czas?
  • Czy ten test jest niestabilny, bo zakłada, że gorutyna już się uruchomiła?
  • Czy ten test można izolować od sieci i zewnętrznych procesów?
  • Czy gorutyna tła może zostać czysto zatrzymana?
  • Czy fałszywy czas sprawiłby, że asercja byłaby jaśniejsza?

Dobry kandydaci często mieszczą się w:

  • pakietach pracowników
  • pakietach ponownych prób
  • pakietach cache
  • pakietach planistów
  • konsumentach kolejek
  • otoczkach klientów HTTP
  • middleware timeoutów
  • kodzie sprzątania tła
  • kodzie limitowania przepustowości

Zacznij od jednego niestabilnego testu. Nie migruj całej bazy kodu 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 warunków wyścigowych, 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("tymczasowa awaria")
		}
		return nil
	})

	if err != nil {
		t.Fatalf("Retry zwrócił błąd: %v", err)
	}

	if calls != 3 {
		t.Fatalf("wołania = %d, oczekiwano 3", calls)
	}
}

To czeka około jednej sekundy, ponieważ występują dwa opóźnienia ponownych prób.

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

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("tymczasowa awaria")
			}
			return nil
		})

		if err != nil {
			t.Fatalf("Retry zwrócił błąd: %v", err)
		}

		if calls != 3 {
			t.Fatalf("wołania = %d, oczekiwano 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 adoptować synctest

Adoptowałbym go stopniowo.

Krok 1: Znajdź niestabilne lub powolne współbieżne testy

Szukaj rzeczywistych uśpien i testów obciążonych timeoutami. Polecenia grep z poprzedniej sekcji są dobrym punktem wyjścia do identyfikacji kandydatów w całej bazie kodu.

Krok 2: Wybierz jeden pakiet

Wybierz pakiet, który ma jasne zachowanie asynchroniczne, ale nie wymaga rzeczywistych zewnętrznych usług. Pakiety pracowników, pomocniki ponownych prób i komponenty napędzane timerami są idealnymi pierwszymi celami.

Krok 3: Konwertuj jeden test

Otocz test synctest.Test i zastąp arbitralne uśpienia synctest.Wait, uśpieniami fałszywego czasu lub eksplikityjną synchronizacją. Konwersja jest zwykle mała — najtrudniejszą częścią jest upewnienie się, że gorutyny mają czyste ścieżki zamykania.

Krok 4: Uruchom z detektorem 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 asynchroniczny czas jest teraz deterministyczny.

Krok 5: Przeglądaj cykl życia gorutin

Upewnij się, że każda gorutyna uruchomiona przez test ma sposób na wyjście przed zamknięciem bąbelki. Jeśli nie, synctest.Test ujawni wyciek, zamiast cicho go ignorować.

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

Nie konwertuj testów tylko dla mody. Dobry test synctest powinien być mierzalnie szybszy, jaśniejszy do odczytania lub mniej niestabilny niż wersja, którą zastąpił — jeśli nie, konwersja nie była warta tego.

Moje opiniowane zasady

Używaj tych jako praktycznych zasad kciuka.

Zasada 1: Brak arbitralnych uśpien w współbieżnych testach jednostkowych

Uśpienie, które czeka na to, aby gorutyna może skończyć, to zapach. Zastąp je kanałami, WaitGroupami, callbackami, synctest.Wait lub fałszywym czasem — czymkolwiek, co czeka na warunek, a nie liczy na to, że minęło wystarczająco dużo czasu.

Zasada 2: Utrzymuj testy synctest samodzielne

Twórz gorutyny, kanały, konteksty, timery i pracowników wewnątrz bąbelki. Unikaj stanu współdzielonego na poziomie pakietu, który może wyciekać między testami i łamać izolację, która sprawia, że synctest jest przydatny.

Zasada 3: Nie używaj synctest jako otoczki testu integracyjnego

Jeśli test rozmawia z rzeczywistą bazą danych, rzeczywistą siecią lub zewnętrznym procesem, utrzymuj go poza synctest, chyba że masz bardzo konkretny powód do tego.

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

Celem nie jest zmuszanie gorutyny do uruchomienia. Celem jest weryfikacja obserwowalnego zachowania po tym, jak system osiągnie znaczący stan, co synctest.Wait sprawia możliwym bez polegania na założeniach czasowych.

Zasada 5: Utrzymuj ścieżki anulowania eksplikityjne

Każda gorutyna tła powinna mieć ścieżkę zamykania, a testy powinny udowodnić, że ta ścieżka działa, anulując kontekst lub zamykając kanał, a następnie weryfikując, czy gorutyna wychodzi czysto.

Ostateczne myśli

testing/synctest to jedna z tych funkcji Go, która wygląda mało, ale zmienia sposób, w jaki piszesz klasę testów. Nie zastępuje dobrego projektu współbieżnościowego, detektora wyścigów ani potrzeby testów integracyjnych — ale sprawia, że wiele asynchronicznych testów jednostkowych jest szybszych, czystszych i znacznie mniej zależnych od szczęścia czasowego.

To ma znaczenie, ponieważ współbieżny kod jest już wystarczająco trudny. Testy powinny redukować niepewność, a nie ją dodawać. Dla szerszego widoku na produkcyjne wzorce Go obejmujące integrację, strukturę kodu i dostęp do danych, zobacz Architektura aplikacji w produkcji.

Praktyczne podsumowanie jest proste:

Używaj synctest dla deterministycznych testów jednostkowych wokół gorutin, timerów, timeoutów, ponownych prób i anulowania.
Utrzymuj rzeczywiste uśpienia poza współbieżnymi testami, 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: testing/synctest stał się ogólnie dostępny w Go 1.25, wystawia synctest.Test i synctest.Wait, uruchamia testy wewnątrz izolowanej bąbelki, a czas wewnątrz tej bąbelki używa fałszywego zegara, który przesuwa się tylko wtedy, gdy gorutyny są trwale zablokowane.

Źródła

Subskrybuj

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