Tester du code Go concurrent avec synctest

Arrêtez de dormir dans les tests Go concurrents.

Sommaire

Tester le code Go concurrent a toujours exigé une certaine discipline. Les goroutines sont peu coûteuses, les canaux sont simples et l’annulation de contexte est idiomatique — les workers en arrière-plan et les minuteries sont omniprésents dans les services Go réels.

Mais tester tout cela de manière fiable est plus difficile que de l’écrire.

Testing concurrent Go code with synctest

Le schéma courant, et malheureux, est bien connu :

go doSomething()

time.Sleep(100 * time.Millisecond)

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

Ce test peut passer sur votre portable et échouer en CI. Ou il peut passer pendant six mois puis échouer sur un runner surchargé. Ou encore il peut être lent parce que quelqu’un a augmenté la mise en veille de 100 millisecondes à 2 secondes « par sécurité ».

Ce n’est pas une bonne pratique de test — c’est jouer avec un timer, et ce pari devient plus coûteux à mesure que la suite de tests grandit.

Le package testing/synctest offre aux développeurs Go une meilleure façon de tester de nombreuses formes de code asynchrone et dépendant du temps. Il permet à un test de s’exécuter à l’intérieur d’une bulle isolée, donne à cette bulle une horloge factice et fournit un moyen d’attendre que les goroutines à l’intérieur de la bulle soient bloquées.

Le résultat est simple mais puissant :

  • Pas de mises en veille arbitraires
  • Des tests de timeout plus rapides
  • Des tests concurrents plus déterministes
  • Un meilleur test de l’annulation de contexte
  • Un meilleur test des goroutines en arrière-plan
  • Une CI moins instable (flaky)

La version légèrement dogmatique : si votre test Go concurrent dépend d’un time.Sleep réel, vous devriez probablement considérer ce test comme suspect.

Qu’est-ce que testing/synctest

testing/synctest est un package de la bibliothèque standard Go pour tester le code concurrent.

Il fournit deux fonctions principales :

package synctest

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

synctest.Test exécute une fonction à l’intérieur d’une bulle de test isolée. Toute goroutine démarrée à l’intérieur de cette bulle fait également partie de la bulle, le temps à l’intérieur de la bulle est factice, et le package time fonctionne avec cette horloge factice plutôt qu’avec l’horloge murale réelle.

synctest.Wait attend que toutes les autres goroutines de la bulle soient durables bloquées. Cela semble abstrait, mais l’effet pratique est facile à comprendre :

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

Cela ne fait pas attendre votre test 10 secondes réelles. À l’intérieur de la bulle synctest, le temps peut avancer instantanément lorsque la bulle est bloquée et attend que le temps avance — c’est l’astuce principale derrière le package.

Pourquoi les tests Go concurrents sont instables

Si vous êtes nouveau dans les tests Go en général, Go Unit Testing: Structure & Best Practices couvre le package de test, les tests basés sur des tableaux et les motifs d’imitation (mocking) qui forment la base sur laquelle cet article s’appuie. Les tests concurrents sont généralement instables pour l’une des trois raisons suivantes.

Premièrement, ils dépendent du planificateur. Une goroutine peut s’exécuter immédiatement sur votre machine et plus tard en CI.

Deuxièmement, ils dépendent du temps réel. Un test qui dort pendant 50 millisecondes suppose que 50 millisecondes sont suffisantes pour que le travail en arrière-plan se termine.

Troisièmement, ils observent l’état trop tôt. Le test vérifie le résultat avant que l’opération en arrière-plan n’ait réellement terminé.

Voici un exemple simple :

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

Ce test présente deux problèmes.

Le plus évident est la mise en veille. Il n’y a aucune garantie que 10 millisecondes soient le bon laps de temps.

Le moins évident est la condition de course (data race). Le test écrit dans done dans une goroutine et le lit dans une autre sans synchronisation.

Vous pouvez corriger cet exemple spécifique avec un canal ou un sync.WaitGroup, et souvent vous devriez le faire. Mais lorsque le code sous test utilise des minuteries, des délais de contexte, time.AfterFunc, des workers en arrière-plan ou un nettoyage différé, le test peut devenir maladroit — et c’est exactement là que testing/synctest aide.

L’idée principale : exécuter le test à l’intérieur d’une bulle

Une bulle synctest isole les goroutines créées à l’intérieur.

Utilisez-la comme ceci :

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

À l’intérieur de la bulle :

  • Les goroutines démarrées par le test appartiennent à la bulle.
  • Les minuteries et les mises en veille utilisent une horloge factice.
  • synctest.Wait peut attendre que l’activité en arrière-plan se stabilise.
  • Le test devrait éviter de dépendre de goroutines externes, d’E/S réseau réelles ou de processus externes.

La bulle n’est pas magique. Elle ne rend pas un mauvais design concurrent bon. Mais elle donne à votre test un environnement contrôlé où le temps et le comportement de blocage sont plus déterministes.

Le problème avec time.Sleep dans les tests

Un time.Sleep réel dans un test signifie généralement l’une des deux choses suivantes :

Je ne sais pas comment attendre l'événement qui m'intéresse vraiment.

ou :

Je sais ce qui m'intéresse, mais le code sous test n'expose pas de moyen propre pour l'observer.

Les deux sont des signaux de design qu’il faut prendre au sérieux — ils pointent vers des endroits où le code de production pourrait bénéficier d’une meilleure observabilité ou de mécanismes de coordination plus explicites.

Considérons une fonction qui complète un travail en arrière-plan :

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
}

Un mauvais test pourrait ressembler à ceci :

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

Ce test attend six secondes réelles.

C’est lent. Si vous avez de nombreux tests comme celui-ci, la suite devient douloureuse.

Un meilleur test avec synctest peut faire avancer le temps factice instantanément :

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

Le test exprime toujours le fait métier — le worker devrait finir après 5 secondes — mais il ne passe pas 5 secondes réelles à le faire. C’est la différence entre tester un comportement dépendant du temps et gaspiller le temps des développeurs.

Tester les timeouts de contexte

L’un des meilleurs usages de testing/synctest est de tester les délais et timeouts de context.Context. La propagation correcte de context.Canceled et context.DeadlineExceeded à travers les couches de service et de gestionnaire est couverte en profondeur dans Go Error Handling Architecture: Boundaries and Patterns — synctest vous permet de vérifier ce comportement sans que le temps réel ne s’écoule.

Voici une fonction simple qui attend jusqu’à ce qu’un contexte soit annulé :

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

Sans synctest, tester cela avec un timeout de 30 secondes rendrait le test lent ou vous obligerait à changer le timeout juste pour le test.

Avec synctest, vous pouvez tester la durée réelle du timeout rapidement :

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

C’est ce genre de test que synctest rend agréable.

Vous pouvez conserver des valeurs de timeout réalistes dans le code et exécuter les tests rapidement.

Tester l’annulation de contexte

Vous pouvez également tester l’annulation explicite sans faire de course avec la goroutine en arrière-plan.

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

Le détail important est synctest.Wait.

Il donne à la goroutine en arrière-plan une chance d’observer l’annulation et de se stabiliser avant que le test ne vérifie le résultat.

Ce que synctest.Wait fait

synctest.Wait attend que toutes les autres goroutines de la bulle soient durables bloquées.

En langage courant, cela signifie :

Attendre que les goroutines à l'intérieur de ce test aient atteint un point de blocage stable.

Cela est utile lorsque le test démarre une goroutine et a besoin de savoir que la goroutine a soit terminé, soit est en attente.

Par exemple :

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

Ceci est intentionnellement minimal, mais cela démontre l’idée.

synctest.Wait n’est pas juste une mise en veille plus élégante — c’est un point de synchronisation à l’intérieur de la bulle, et cette distinction importe plus qu’il n’y paraît.

Une mise en veille dit :

J'espère que suffisamment de temps s'est écoulé.

Wait dit :

Je veux que la bulle atteigne un état bloqué stable.

Le deuxième est bien meilleur pour les tests car il décrit une condition observable plutôt qu’une supposition sur le temps écoulé.

Temps factice dans une bulle synctest

À l’intérieur d’une bulle synctest, le package time utilise une horloge factice.

L’horloge factice démarre à un temps fixe. Elle n’avance que lorsque chaque goroutine de la bulle est durablement bloquée et que le temps doit avancer pour débloquer quelque chose.

Cela signifie que ce test est rapide :

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

Il semble attendre une heure.

Ce n’est pas le cas.

Cela est utile pour tester :

  • les timeouts
  • les délais
  • les tentatives (retries)
  • le backoff
  • le nettoyage différé
  • les limites de débit (rate limits)
  • les minuteries
  • les tickers
  • l’annulation de contexte

Mais il y a une règle importante : le temps factice n’aide que le code qui utilise le package time à l’intérieur de la bulle.

Si votre code dépend d’un système externe, d’E/S réseau réelle ou d’un temps mesuré en dehors de la bulle, synctest ne peut pas rendre cela déterministe.

Tester une boucle de retry

Les boucles de retry sont une source commune de tests lents et instables.

Voici un petit helper de retry :

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
}

Un test normal pourrait réduire le délai à 1 milliseconde juste pour garder la suite rapide.

Ce n’est pas terrible, mais cela signifie que le test n’exerce plus la valeur réelle utilisée par le code de production.

Avec synctest, vous pouvez conserver le délai réel :

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

Le test représente deux attentes de 10 secondes.

Il s’exécute encore rapidement.

C’est là que synctest change l’économie des tests. Vous n’avez plus besoin de durées factices minuscules dispersées dans les tests juste pour éviter une CI lente.

Tester l’annulation de retry

Vous pouvez également tester l’annulation pendant le délai de retry :

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

Ce test vérifie que la boucle de retry répond à l’annulation au lieu de dormir pendant le délai.

C’est exactement le genre de comportement qui compte en production.

Tester time.AfterFunc

time.AfterFunc est un autre bon candidat.

Supposons que vous ayez une fonction qui planifie un nettoyage :

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
}

Le test peut faire avancer le temps factice :

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

Ce test vérifie les deux côtés :

  • Le nettoyage n’a pas lieu avant le délai.
  • Le nettoyage a lieu après le délai.

Et il n’attend pas une minute réelle.

Tester les tickers

Les tickers peuvent également être testés avec le temps factice, mais attention. Les tickers sont souvent utilisés dans des boucles à long terme, et les boucles à long terme ont besoin d’un chemin d’arrêt propre.

Voici un petit compteur basé sur un ticker :

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
}

Un test pourrait ressembler à ceci :

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

Cet exemple a un détail de design délibéré : le worker a un chemin d’arrêt.

Ce n’est pas seulement bon pour les tests. C’est bon pour la production.

Les tests révèlent souvent si vos goroutines peuvent réellement s’arrêter.

synctest et les fuites de goroutines

testing/synctest est utile ici car synctest.Test attend que les goroutines de la bulle sortent avant de retourner, ce qui signifie que les goroutines fuites sont plus difficiles à ignorer. Si une goroutine en arrière-plan ne sort jamais, le test échoue au lieu de laisser silencieusement du travail derrière — et c’est une bonne chose.

Le code concurrent devrait avoir une propriété claire. Si une fonction démarre une goroutine, il devrait y avoir un moyen explicite de l’arrêter, ou une raison documentée pour laquelle elle est autorisée à vivre pour toujours. Dans les tests, « pour toujours » est presque toujours inacceptable.

Un bon motif est :

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

Puis faites en sorte que la goroutine s’arrête lorsque le contexte est annulé.

Ce que « durablement bloqué » signifie en pratique

La documentation officielle utilise le terme « durablement bloqué ».

Vous n’avez pas besoin de mémoriser chaque détail du runtime, mais vous devriez comprendre la signification pratique.

Une goroutine est durablement bloquée lorsqu’elle est bloquée d’une manière qui ne peut être débloquée que par quelque chose à l’intérieur de la même bulle synctest.

Les exemples incluent :

  • recevoir depuis un canal créé à l’intérieur de la bulle
  • envoyer à un canal créé à l’intérieur de la bulle
  • attendre sur un sync.WaitGroup associé à la bulle
  • dormir avec time.Sleep
  • attendre sur certaines opérations de minuterie

Certaines choses ne sont pas durablement bloquées car quelque chose en dehors de la bulle peut les débloquer.

Les exemples incluent :

  • E/S réseau
  • appels système
  • opérations de processus externes
  • certaines attentes de mutex
  • interactions avec des goroutines en dehors de la bulle

C’est pourquoi les tests synctest devraient être autonomes et libres de synchronisation externe que la bulle ne peut pas voir. N’utilisez pas synctest comme un wrapper autour de tests d’intégration qui parlent au réseau réel.

Ce pour quoi synctest est bon

testing/synctest est particulièrement bon pour les tests unitaires autour du comportement asynchrone.

Les bons candidats incluent :

  • annulation de contexte
  • timeouts de contexte
  • boucles de retry
  • logique de backoff
  • nettoyage différé
  • workers pilotés par minuterie
  • boucles pilotées par ticker
  • goroutines en arrière-plan
  • comportement de timeout
  • coordination de canaux
  • time.AfterFunc
  • attente déterministe de goroutines

Le meilleur cas d’utilisation est le code où la partie difficile est le temps ou la planification, pas l’E/S externe.

Ce pour quoi synctest n’est pas bon

testing/synctest n’est pas un remplacement pour tous les tests de concurrence.

Ce n’est pas un planificateur déterministe complet pour chaque course possible.

Ce n’est pas un substitut au détecteur de race.

Ce n’est pas un remplacement pour les tests d’intégration.

Il ne rend pas l’E/S réseau réelle déterministe.

Il ne corrige pas un mauvais design de cycle de vie des goroutines.

Cela ne signifie pas que vous pouvez ignorer les canaux, les contextes, la propriété et l’arrêt.

Utilisez synctest pour la bonne couche : tests unitaires déterministes pour le comportement concurrent et dépendant du temps.

Utilisez d’autres outils pour d’autres couches :

  • utilisez go test -race pour détecter les conditions de course
  • utilisez les tests d’intégration pour les dépendances réelles
  • utilisez les tests de charge pour le débit et la contention
  • utilisez les benchmarks pour les performances
  • utilisez le traçage et le profilage pour le comportement en production

synctest vs le détecteur de race

testing/synctest et le détecteur de race résolvent des problèmes différents.

Le détecteur de race trouve l’accès mémoire concurrent non sécurisé.

synctest vous aide à contrôler le timing asynchrone et l’attente dans les tests.

Vous devriez souvent utiliser les deux.

Par exemple, c’est toujours une course même à l’intérieur d’une bulle synctest s’il n’y a pas de synchronisation appropriée :

value := 0

go func() {
	value = 1
}()

_ = value

synctest.Wait peut fournir un point de synchronisation pour certains motifs de test, mais cela ne signifie pas que chaque accès concurrent dans votre code est automatiquement sécurisé.

Exécutez les tests concurrents avec :

go test -race ./...

Le détecteur de race est toujours l’un des meilleurs outils que Go vous offre. L’associer à Go Linters: Essential Tools for Code Quality vous donne une base solide d’analyse statique et de vérification à l’exécution pour n’importe quelle base de code concurrente.

synctest vs horloges factices manuelles

Avant testing/synctest, de nombreuses équipes utilisaient des horloges factices manuelles.

Cela peut toujours être un bon design.

Une interface d’horloge manuelle pourrait ressembler à ceci :

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

Puis le code de production utilise une horloge réelle et les tests utilisent une horloge factice.

Cela donne un contrôle explicite, mais cela a un coût :

  • plus d’interfaces
  • plus de plomberie
  • plus d’abstractions dédiées aux tests
  • plus de façons pour le code de contourner accidentellement l’horloge factice

synctest est attrayant car le code ordinaire qui utilise le package time peut fonctionner avec le temps factice à l’intérieur de la bulle de test.

Cela réduit le besoin d’injection d’horloge dans de nombreux cas.

Mon avis : utilisez synctest quand il garde le code de production plus simple. Utilisez une horloge injectée uniquement lorsque le contrôle de l’horloge fait partie de votre design de domaine ou lorsque vous avez besoin d’un contrôle en dehors de ce que synctest fournit. Pour une vue plus large sur les motifs d’injection de dépendance en Go — y compris quand et comment injecter des abstractions testables — voir Dependency Injection in Go: Patterns & Best Practices.

synctest vs canaux et WaitGroups

Ne remplacez pas une bonne synchronisation par synctest.

Si votre code peut exposer un canal de terminaison, un callback ou une méthode Wait, c’est souvent un bon design.

Par exemple :

type Server struct {
	done chan struct{}
}

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

Un test peut attendre directement dessus.

synctest est le plus utile lorsque le comportement sous test implique du temps, des délais de contexte, de la planification en arrière-plan ou des callbacks asynchrones.

Les meilleurs tests combinent souvent les deux :

  • le code de production a des signaux d’arrêt ou de terminaison explicites
  • synctest supprime l’attente en temps réel
  • Wait rend l’activité en arrière-plan déterministe

Erreurs courantes

Erreur 1 : Envelopper chaque test dans synctest

N’utilisez pas synctest partout. Si le code est synchrone, une fonction de test normale est plus claire, et ajouter le wrapper de bulle introduit uniquement une mécanique inutile qui rend les tests plus difficiles à lire et à raisonner.

Erreur 2 : Tester l’E/S réseau réelle à l’intérieur de la bulle

Gardez les tests synctest autonomes. Si votre test utilise un socket réseau réel, un service externe, une base de données ou un sous-processus, il appartient à un test d’intégration plutôt qu’à l’intérieur d’une bulle synctest. Utilisez des fakes pour les tests unitaires et réservez les dépendances réelles pour des tests d’intégration séparés où l’isolation de la bulle ne s’applique pas.

Erreur 3 : Fuir des goroutines

Si votre test démarre une goroutine, assurez-vous qu’elle a un chemin de sortie clair. Utilisez l’annulation de contexte, des canaux fermés ou des méthodes d’arrêt explicites — une goroutine qui ne s’arrête jamais est à la fois un signe de problème en production et un signe de problème dans les tests que synctest révélera plutôt que cacher.

Erreur 4 : Dépendre d’un état au niveau du package

Les canaux, minuteries et WaitGroups au niveau du package peuvent briser l’isolation de la bulle de manière subtile. Préférez créer tout l’état du test à l’intérieur de la fonction synctest.Test afin que chaque ressource appartienne à la bulle et que sa durée de vie soit clairement limitée au test.

Erreur 5 : Traiter le temps factice comme du temps réel

Le temps factice est pour des tests déterministes, pas pour la mesure des performances. Un test qui avance d’une heure instantanément ne vous dit rien d’utile sur le coût CPU, la contention de verrouillage, l’utilisation de la mémoire ou le comportement de planification réel en production — utilisez des benchmarks et des tests de charge pour ces questions.

Erreur 6 : Ignorer le détecteur de race

synctest n’est pas un remplacement pour go test -race, et les deux outils résolvent des problèmes différents. Exécutez le détecteur de race avec vos tests synctest pour attraper l’accès mémoire concurrent non sécurisé que la bulle seule ne peut pas détecter.

Une liste de contrôle pratique

Utilisez cette liste de contrôle lors de l’écriture de tests avec testing/synctest.

Utiliser synctest quand

  • le code démarre des goroutines
  • le code utilise time.Sleep
  • le code utilise des minuteries ou des tickers
  • le code utilise des délais de contexte
  • le code a un comportement de retry ou de backoff
  • le test utilise actuellement des mises en veille arbitraires
  • le test est instable en CI
  • le test est lent car il attend le temps réel

Éviter synctest quand

  • le code est synchrone
  • le test dépend d’E/S réseau réelle
  • le test dépend de processus externes
  • le test est en réalité un test d’intégration
  • vous essayez de mesurer les performances
  • le code n’a pas de chemin d’arrêt propre

Préférer ce motif

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

Ce motif est simple :

  • mettre en place à l’intérieur de la bulle
  • démarrer le travail à l’intérieur de la bulle
  • attendre que l’activité en arrière-plan se stabilise
  • faire avancer le temps factice uniquement si nécessaire
  • affirmer après synchronisation

Où utiliser testing/synctest dans les projets réels

Les meilleurs endroits à regarder ne sont généralement pas dans la logique métier simple.

Cherchez les tests avec ces odeurs :

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

Puis demandez :

  • Ce test est-il lent car il attend le temps réel ?
  • Ce test est-il instable car il suppose qu’une goroutine a déjà tourné ?
  • Ce test peut-il être isolé du réseau et des processus externes ?
  • La goroutine en arrière-plan peut-elle être arrêtée proprement ?
  • Le temps factice rendrait-il l’affirmation plus claire ?

Les bons candidats vivent souvent dans :

  • les packages de workers
  • les packages de retry
  • les packages de cache
  • les packages de planification
  • les consommateurs de file d’attente
  • les wrappers de client HTTP
  • le middleware de timeout
  • le code de nettoyage en arrière-plan
  • le code de limitation de débit

Commencez par un test instable. N’ayez pas la base de code entière en une fois. Si votre suite de tests utilise des tests basés sur des tableaux parallèles avec du code asynchrone, Parallel Table-Driven Tests in Go couvre les motifs t.Parallel() et les pièges de conditions de course qui s’accordent naturellement avec l’approche synctest.

Exemple : avant et après

Voici un mauvais test réaliste :

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

Cela attend environ une seconde car deux délais de retry se produisent.

Cela ne semble pas si mal, mais multipliez-le par de nombreux tests et plusieurs packages. Les tests lents font que les développeurs exécutent les tests moins souvent.

Maintenant la version 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)
		}
	})
}

Le test conserve la valeur de délai réelle, la suite reste rapide et l’intention est plus claire. C’est la valeur principale de testing/synctest.

Comment adopter synctest en toute sécurité

Je l’adopterais progressivement.

Étape 1 : Trouver des tests concurrents instables ou lents

Recherchez les mises en veille réelles et les tests lourds en timeouts. Les commandes grep de la section précédente sont un bon point de départ pour identifier les candidats dans toute la base de code.

Étape 2 : Choisir un package

Choisissez un package qui a un comportement asynchrone clair mais ne nécessite pas de services externes réels. Les packages de workers, les helpers de retry et les composants pilotés par minuterie sont des cibles idéales en premier.

Étape 3 : Convertir un test

Enveloppez le test dans synctest.Test et remplacez les mises en veille arbitraires par synctest.Wait, des mises en veille de temps factice ou une synchronisation explicite. La conversion est généralement petite — la partie la plus difficile est de s’assurer que les goroutines ont des chemins d’arrêt propres.

Étape 4 : Exécuter avec le détecteur de race

Toujours exécuter avec go test -race ./... après la conversion. Un test synctest qui passe ne signifie pas que le code est libre de race ; cela signifie seulement que le timing asynchrone est maintenant déterministe.

Étape 5 : Passer en revue le cycle de vie des goroutines

Assurez-vous que chaque goroutine démarrée par le test a un moyen de sortir avant que la bulle ne se ferme. Si ce n’est pas le cas, synctest.Test révélera la fuite plutôt que de l’ignorer silencieusement.

Étape 6 : Répéter uniquement là où cela améliore la clarté

Ne convertissez pas les tests juste pour la mode. Un bon test synctest devrait être mesurablement plus rapide, plus clair à lire ou moins instable que la version qu’il a remplacée — si ce n’est pas le cas, la conversion n’en valait pas la peine.

Mes règles dogmatiques

Utilisez-les comme règles pratiques.

Règle 1 : Pas de mises en veille arbitraires dans les tests unitaires concurrents

Une mise en veille qui attend qu’une goroutine finisse peut-être est une odeur. Remplacez-la par des canaux, WaitGroups, callbacks, synctest.Wait ou du temps factice — n’importe quoi qui attend une condition plutôt que d’espérer que suffisamment de temps s’est écoulé.

Règle 2 : Garder les tests synctest autonomes

Créez des goroutines, canaux, contextes, minuteries et workers à l’intérieur de la bulle. Évitez l’état partagé au niveau du package, qui peut fuir entre les tests et briser l’isolation qui rend synctest utile.

Règle 3 : Ne pas utiliser synctest comme wrapper de test d’intégration

Si le test parle à une base de données réelle, un réseau réel ou un processus externe, gardez-le hors de synctest sauf si vous avez une raison très spécifique de le faire.

Règle 4 : Tester le comportement, pas la chance du planificateur

Le but n’est pas de forcer une goroutine à tourner. Le but est de vérifier le comportement observable après que le système a atteint un état significatif, ce que synctest.Wait rend possible sans dépendre des hypothèses de timing.

Règle 5 : Garder les chemins d’annulation explicites

Chaque goroutine en arrière-plan devrait avoir un chemin d’arrêt, et les tests devraient prouver que ce chemin fonctionne en annulant le contexte ou en fermant le canal puis en vérifiant que la goroutine sort proprement.

Pensées finales

testing/synctest est l’une de ces fonctionnalités Go qui semble petite mais change la façon dont vous écrivez une classe de tests. Il ne remplace pas un bon design de concurrence, le détecteur de race ou le besoin de tests d’intégration — mais il rend de nombreux tests unitaires asynchrones plus rapides, plus propres et beaucoup moins dépendants de la chance de timing.

Cela compte car le code concurrent est déjà assez difficile. Les tests devraient réduire l’incertitude, pas l’ajouter. Pour une vue plus large des motifs Go en production à travers l’intégration, la structure du code et l’accès aux données, voir App Architecture in Production.

La conclusion pratique est simple :

Use synctest for deterministic unit tests around goroutines, timers, timeouts, retries, and cancellation.
Keep real sleeps out of concurrent tests unless you have a very good reason.

Cette seule habitude rendra de nombreuses suites de tests Go plus rapides et moins instables.


Les faits importants actuels sont : testing/synctest est devenu généralement disponible dans Go 1.25, il expose synctest.Test et synctest.Wait, il exécute les tests à l’intérieur d’une bulle isolée, et le temps à l’intérieur de cette bulle utilise une horloge factice qui n’avance que lorsque les goroutines sont durablement bloquées.

Sources

S'abonner

Recevez de nouveaux articles sur les systèmes, l'infrastructure et l'ingénierie IA.