Testowanie równoległego kodu Go przy użyciu synctest
Przestań śpij w równoległych testach Go.
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.

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.Waitmoż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.WaitGrouppowią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 -racedo 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
- https://pkg.go.dev/testing/synctest
- https://go.dev/blog/testing-time
- https://go.dev/blog/synctest
- [https://go.dev/blog/testing-time](“Testowanie czasu (i innych asynchroniczności) - Język programowania Go”)
- https://go.dev/doc/go1.25
- https://go.dev/blog/go1.25