Tester le code Go concurrent avec synctest
« Cessez de dormir dans vos tests Go concurrents. »
Tester du code Go concurrent a toujours demandé une certaine discipline. Les goroutines sont économiques, les canaux sont simples et l’annulation via contexte est idiomatique — les travailleurs en arrière-plan et les minuteries sont omniprésents dans les services Go réels.
Mais tester fiabilment tout cela est plus difficile que de l’écrire.

Le mauvais schéma habituel est familier :
go doSomething()
time.Sleep(100 * time.Millisecond)
if !done {
t.Fatal("le travail en arrière-plan n'a pas terminé")
}
Ce test peut passer sur votre portable et échouer en CI. Ou il peut passer pendant six mois puis échouer sur une machine de construction chargée. Ou il peut être lent parce que quelqu’un a augmenté le temps d’attente de 100 millisecondes à 2 secondes « juste pour être sûr ».
Ce n’est pas une bonne pratique de test — c’est du jeu de hasard avec une minuterie, et ce pari devient de plus en plus coûteux à mesure que la suite de tests grandit.
Le paquet 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 dans une bulle isolée, donne à cette bulle une horloge factice et fournit un moyen d’attendre jusqu’à ce que les goroutines à l’intérieur de la bulle soient bloquées.
Le résultat est simple mais puissant :
- Pas d’attentes arbitraires
- Des tests de délai d’expiration 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
- Moins de CI instables (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 paquet 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 dans 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 paquet time fonctionne avec cette horloge factice plutôt que l’horloge murale réelle.
synctest.Wait attend jusqu’à ce que toutes les autres goroutines dans la bulle soient durablement bloquées. Cela peut paraître 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 passe — c’est l’astuce centrale derrière ce paquet.
Pourquoi les tests Go concurrents sont instables (flaky)
Si vous débutez dans les tests Go en général, Test unitaire Go : Structure & Meilleures Pratiques couvre le paquet de test, les tests à base de tables et les modèles de simulation (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 (scheduler). 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 suffisent 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 été effectuée.
Voici un exemple simple :
func TestBackgroundWorkBad(t *testing.T) {
done := false
go func() {
done = true
}()
time.Sleep(10 * time.Millisecond)
if !done {
t.Fatal("le travail en arrière-plan n'a pas terminé")
}
}
Ce test a deux problèmes.
Le plus évident est le sommeil (sleep). Il n’y a aucune garantie que 10 millisecondes soit la bonne quantité de temps.
Le moins évident est la course à l’état (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 vous devriez souvent le faire. Mais lorsque le code sous test utilise des minuteries, des délais d’expiration de contexte, time.AfterFunc, des travailleurs en arrière-plan ou un nettoyage différé, le test peut toujours devenir délicat — et c’est exactement là que testing/synctest est utile.
L’idée centrale : exécuter le test dans 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) {
// Testez le code concurrent ici.
})
}
À l’intérieur de la bulle :
- Les goroutines démarrées par le test appartiennent à la bulle.
- Les minuteries et les attentes utilisent une horloge factice.
synctest.Waitpeut attendre que l’activité en arrière-plan se stabilise.- Le test devrait éviter de dépendre de goroutines externes, d’E/R réseau réel ou de processus externes.
La bulle n’est pas magique. Elle ne rend pas une mauvaise conception de la concurrence bonne. Mais elle donne à votre test un environnement contrôlé où le comportement de temps et de blocage est plus déterministe.
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 façon propre de l'observer.
Ce sont tous deux des signaux de conception à prendre au sérieux — ils indiquent des endroits où le code de production pourrait bénéficier d’une observabilité plus propre ou de mécanismes de coordination plus explicites.
Considérez une fonction qui accomplit du 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("obtenu %q, attendu done", got)
}
default:
t.Fatal("le travailleur n'a pas terminé")
}
}
Ce test attend six secondes réelles.
C’est lent. Si vous avez beaucoup de tests comme celui-ci, la suite devient pénible.
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("obtenu %q, attendu done", got)
}
default:
t.Fatal("le travailleur n'a pas terminé")
}
})
}
Le test exprime toujours le fait métier — le travailleur doit terminer après 5 secondes — mais il ne dépense pas 5 secondes réelles à le faire. C’est la différence entre tester un comportement dépendant du temps et gaspiller du temps de développeur.
Tester les délais d’expiration de contexte
L’un des meilleurs usages de testing/synctest est de tester les délais d’expiration et les timeouts de context.Context. La façon dont l’annulation du contexte et les budgets de délai d’expiration doivent être structurés à travers les couches de service est abordée dans Go context.Context Bien Fait, et la propagation correcte de context.Canceled et context.DeadlineExceeded à travers ces couches est couverte dans Architecture de la Gestion des Erreurs en Go : Frontières et Modèles — synctest vous permet de vérifier ce comportement sans que le temps réel ne passe.
Voici une fonction simple qui attend 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 délai d’expiration de 30 secondes rendrait soit le test lent, soit vous obligerait à changer le délai d’expiration juste pour le test.
Avec synctest, vous pouvez tester la durée réelle du délai d’expiration 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("contexte annulé trop tôt : %v", err)
default:
}
time.Sleep(30 * time.Second)
synctest.Wait()
select {
case err := <-done:
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("obtenu %v, attendu %v", err, context.DeadlineExceeded)
}
default:
t.Fatal("le contexte n'a pas été annulé")
}
})
}
C’est le genre de test que synctest rend agréable.
Vous pouvez conserver des valeurs de délai d’expiration réalistes dans le code et exécuter les tests rapidement.
Tester l’annulation de contexte
Vous pouvez également tester l’annulation explicite sans entrer en 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("contexte annulé trop tôt : %v", err)
default:
}
cancel()
synctest.Wait()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("obtenu %v, attendu %v", err, context.Canceled)
}
default:
t.Fatal("le contexte n'a pas été annulé")
}
})
}
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 fait synctest.Wait
synctest.Wait attend jusqu’à ce que toutes les autres goroutines dans la bulle soient durablement bloquées.
En langage courant, cela signifie :
Attendez jusqu'à ce que les goroutines dans ce test aient atteint un point de blocage stable.
C’est utile lorsque le test démarre une goroutine et doit 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("la goroutine n'a pas exécuté")
}
})
}
C’est délibérément petit, mais cela démontre l’idée.
synctest.Wait n’est pas juste un sommeil plus joli — c’est un point de synchronisation à l’intérieur de la bulle, et cette distinction compte plus qu’il n’y paraît au premier abord.
Un sommeil dit :
J'espère que suffisamment de temps s'est écoulé.
Wait dit :
Je veux que la bulle atteigne un état de blocage stable.
Le second est bien meilleur pour les tests parce qu’il décrit une condition observable plutôt qu’un supposé sur le temps écoulé.
Le temps factice dans une bulle synctest
À l’intérieur d’une bulle synctest, le paquet time utilise une horloge factice.
L’horloge factice démarre à un temps fixe. Elle n’avance que lorsque toutes les goroutines de la bulle sont durablement bloquées 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("obtenu %v, attendu %v", elapsed, time.Hour)
}
})
}
Ça lit comme si on attendait une heure.
Ce n’est pas le cas.
C’est utile pour tester :
- les délais d’expiration (timeouts)
- les échéances (deadlines)
- les nouvelles tentatives (retries)
- le recul (backoff)
- le nettoyage différé
- les limites de débit (rate limits)
- les minuteries (timers)
- les tic-tacs (tickers)
- l’annulation de contexte
Mais il y a une règle importante : le temps factice n’aide que le code qui utilise le paquet time à l’intérieur de la bulle.
Si votre code dépend d’un système externe, d’un E/R réseau réel ou d’un temps mesuré en dehors de la bulle, synctest ne peut pas le rendre déterministe.
Tester une boucle de nouvelle tentative (retry)
Les boucles de nouvelle tentative sont une source courante de tests lents et instables.
Voici un petit aide à la nouvelle tentative :
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 vraie valeur 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("échec temporaire")
}
return nil
})
if err != nil {
t.Fatalf("Retry a renvoyé une erreur : %v", err)
}
if calls != 3 {
t.Fatalf("calls = %d, attendu 3", calls)
}
})
}
Le test représente deux attentes de 10 secondes.
Il s’exécute toujours rapidement.
C’est là que synctest change l’économie des tests. Vous n’avez plus besoin de fausses durées minuscules disséminées dans les tests juste pour éviter une CI lente.
Tester l’annulation de nouvelle tentative
Vous pouvez également tester l’annulation pendant le délai de nouvelle tentative :
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("échec temporaire")
})
}()
synctest.Wait()
cancel()
synctest.Wait()
select {
case err := <-errCh:
if !errors.Is(err, context.Canceled) {
t.Fatalf("obtenu %v, attendu %v", err, context.Canceled)
}
default:
t.Fatal("Retry n'a pas renvoyé après annulation")
}
})
}
Ce test vérifie que la boucle de nouvelle tentative 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 cas d’usage.
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("le nettoyage s'est produit trop tôt")
default:
}
time.Sleep(1 * time.Minute)
synctest.Wait()
select {
case <-cache.Cleaned():
default:
t.Fatal("le nettoyage n'a pas eu lieu")
}
})
}
Ce test vérifie les deux côtés :
- Le nettoyage ne se produit pas avant le délai.
- Le nettoyage se produit bien après le délai.
Et n’attend pas une minute réelle.
Tester les tic-tacs (tickers)
Les tic-tacs (tickers) peuvent également être testés avec un temps factice, mais faites attention. Les tic-tacs sont souvent utilisés dans des boucles longue durée, et les boucles longue durée ont besoin d’un chemin d’arrêt propre.
Voici un petit compteur basé sur un tic-tac :
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, attendu 3", counter.Ticks())
}
})
}
Cet exemple a un détail de conception délibéré : le travailleur 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 dans la bulle se terminent avant de renvoyer, ce qui signifie que les goroutines qui fuient sont plus difficiles à ignorer. Si une goroutine en arrière-plan ne se termine 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 éternellement. Dans les tests, « éternellement » est presque jamais acceptable. La propriété plus un plafond de concurrence est l’habitude de conception que cet article associe : Mettre une limite à la Concurrence en Go couvre les modèles côté production dont les chemins de fuite sont attrapés par une bulle en quelques millisecondes.
Un bon modèle 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ée » signifie en pratique
Les documents officiels utilisent le terme « durablement bloquée ».
Vous n’avez pas besoin de mémoriser chaque détail d’exécution, 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 vers un canal créé à l’intérieur de la bulle
- attendre sur un
sync.WaitGroupassocié à la bulle - dormir avec
time.Sleep - attendre sur certaines opérations de minuterie
Certaines choses ne sont pas durablement bloquées parce que quelque chose en dehors de la bulle peut les débloquer.
Les exemples incluent :
- E/R réseau
- appels système
- opérations de processus externes
- certaines attentes sur des mutex
- interactions avec des goroutines en dehors de la bulle
C’est pourquoi les tests synctest doivent être autosuffisants et libres de toute synchronisation externe que la bulle ne peut pas voir. N’utilisez pas synctest comme enveloppe autour de tests d’intégration qui parlent au réseau réel.
À quoi synctest est bon
testing/synctest est particulièrement bon pour les tests unitaires autour du comportement asynchrone.
Les bons candidats incluent :
- l’annulation de contexte
- les délais d’expiration de contexte
- les boucles de nouvelle tentative
- la logique de recul (backoff)
- le nettoyage différé
- les travailleurs pilotés par minuterie
- les boucles pilotées par tic-tac
- les goroutines en arrière-plan
- le comportement de délai d’expiration
- la coordination par canaux
time.AfterFunc- l’attente déterministe pour les goroutines
Le meilleur cas d’usage est le code dont la partie difficile est le temps ou le planification, et non l’E/R externe.
À 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 courses.
Ce n’est pas un remplacement pour les tests d’intégration.
Il ne rend pas l’E/R réseau réel déterministe.
Il ne corrige pas une mauvaise conception du 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 -racepour détecter les courses à l’état (data races) - 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 de production
synctest vs le détecteur de courses
testing/synctest et le détecteur de courses résolvent des problèmes différents.
Le détecteur de courses trouve les accès mémoire concurrents non sûrs.
synctest vous aide à contrôler le temps 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 modèles de test, mais cela ne signifie pas que tous les accès concurrents dans votre code sont automatiquement sûrs.
Exécutez les tests concurrents avec :
go test -race ./...
Le détecteur de courses est toujours l’un des meilleurs outils que Go vous donne. L’associer avec Linters Go : Outils Essentiels pour la Qualité du Code vous donne une base solide d’analyse statique et de vérification à l’exécution pour tout dépôt de code concurrent.
synctest vs horloges factices manuelles
Avant testing/synctest, beaucoup d’équipes utilisaient des horloges factices manuelles.
Cela peut toujours être une bonne conception.
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 câblage (plumbing)
- plus d’abstractions de test uniquement
- plus de façons pour le code de contourner l’horloge factice accidentellement
synctest est attrayant parce que le code ordinaire qui utilise le paquet time peut s’exécuter avec un temps factice à l’intérieur de la bulle de test.
Cela réduit le besoin d’injection d’horloge dans beaucoup de cas.
Mon opinion : utilisez synctest lorsque cela rend le code de production plus simple. Utilisez une horloge injectée uniquement lorsque le contrôle de l’horloge fait partie de votre conception de domaine ou lorsque vous avez besoin d’un contrôle en dehors de ce que synctest fournit. Pour une vue plus large des modèles d’injection de dépendances en Go — y compris quand et comment injecter des abstractions testables — voyez Injection de Dépendances en Go : Modèles & Meilleures Pratiques
synctest vs canaux et WaitGroups
N’replacez pas une bonne synchronisation avec synctest.
Si votre code peut exposer un canal de complétion, un rappel (callback) ou une méthode Wait, c’est souvent une bonne conception.
Par exemple :
type Server struct {
done chan struct{}
}
func (s *Server) Done() <-chan struct{} {
return s.done
}
Un test peut attendre dessus directement.
synctest est le plus utile lorsque le comportement sous test implique du temps, des échéances de contexte, une planification en arrière-plan ou des rappels asynchrones.
Les meilleurs tests combinent souvent les deux :
- le code de production a des signaux explicites d’arrêt ou de complétion
- synctest élimine l’attente du 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 ordinaire est plus claire, et ajouter l’enveloppe de bulle n’introduit que des mécanismes inutiles qui rendent les tests plus difficiles à lire et à comprendre.
Erreur 2 : Tester l’E/R réseau réel dans la bulle
Gardez les tests synctest autosuffisants. Si votre test utilise un socket réseau réel, un service externe, une base de données ou un sous-processus, il appartient dans un test d’intégration plutôt qu’à l’intérieur d’une bulle synctest. Utilisez des faux (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. Lorsque la dépendance est un gestionnaire HTTP, httptest.NewTestServer de Go 1.27 élimine le besoin d’un socket réel — il exécute le serveur sur un réseau factice en mémoire, ce dont la bulle a exactement besoin, et s’apparie avec synctest pour un minutage déterministe aller-retour comme montré dans le Guide de migration Go 1.27.
Erreur 3 : Faire 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 indicateur de problème en production et dans les tests, et synctest le révélera au lieu de le cacher.
Erreur 4 : Dépendre d’un état au niveau du paquet
Les canaux, minuteries et WaitGroups au niveau du paquet peuvent casser l’isolation de la bulle de manière subtile. Préférez de créer tout l’état du test à l’intérieur de la fonction synctest.Test pour 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 le temps réel
Le temps factice est pour les tests déterministes, pas pour la mesure des performances. Un test qui fait avancer une heure instantanément ne vous dit rien d’utile sur le coût CPU, la contention de verrous, l’utilisation de la mémoire ou le comportement de planification réel en production — utilisez les benchmarks et les tests de charge pour ces questions.
Erreur 6 : Ignorer le détecteur de courses
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 courses avec vos tests synctest pour attraper les accès mémoire concurrents non sûrs 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.
Utilisez synctest lorsque
- le code démarre des goroutines
- le code utilise
time.Sleep - le code utilise des minuteries ou des tic-tacs
- le code utilise des échéances de contexte
- le code a un comportement de nouvelle tentative ou de recul
- le test utilise actuellement des attentes arbitraires
- le test est instable (flaky) en CI
- le test est lent parce qu’il attend du temps réel
Évitez synctest lorsque
- le code est synchrone
- le test dépend de l’E/R réseau réel
- 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érez ce modèle
func TestSomething(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// Disposition (Arrange).
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
// Action (Act).
_ = ctx
// Laisser le travail en arrière-plan se stabiliser.
synctest.Wait()
// Faire avancer le temps factice si nécessaire.
time.Sleep(1 * time.Second)
synctest.Wait()
// Assertion.
})
}
Ce modèle est simple :
- configurer à 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
- faire l’assertion après la synchronisation
Où utiliser testing/synctest dans les projets réels
Les meilleurs endroits à regarder sont généralement pas dans la logique métier simple.
Recherchez des tests avec ces odeurs (smells) :
grep -R "time.Sleep" .
grep -R "time.After" .
grep -R "WithTimeout" .
grep -R "WithDeadline" .
grep -R "NewTicker" .
grep -R "AfterFunc" .
Puis demandez-vous :
- Ce test est-il lent parce qu’il attend du temps réel ?
- Ce test est-il instable parce qu’il suppose qu’une goroutine a déjà exécuté ?
- 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’assertion plus claire ?
Les bons candidats résident souvent dans :
- les paquets de travailleurs (workers)
- les paquets de nouvelle tentative
- les paquets de cache
- les paquets de planificateurs
- les consommateurs de file d’attente
- les enveloppes de client HTTP
- le middleware de délai d’expiration
- le code de nettoyage en arrière-plan
- le code de limitation de débit
Commencez par un test instable. Ne migrez pas tout le dépôt de code d’un coup. Si votre suite de tests utilise des tests à base de tables parallèles en plus du code asynchrone, Tests à Base de Tables Parallèles en Go couvre les modèles t.Parallel() et les pièges de course qui s’apparient naturellement à 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("échec temporaire")
}
return nil
})
if err != nil {
t.Fatalf("Retry a renvoyé une erreur : %v", err)
}
if calls != 3 {
t.Fatalf("calls = %d, attendu 3", calls)
}
}
Cela attend environ une seconde parce que deux délais de nouvelle tentative se produisent.
Cela peut ne pas sembler mal, mais multipliez par beaucoup de tests et plusieurs paquets. 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("échec temporaire")
}
return nil
})
if err != nil {
t.Fatalf("Retry a renvoyé une erreur : %v", err)
}
if calls != 3 {
t.Fatalf("calls = %d, attendu 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 principale valeur de testing/synctest.
Comment adopter synctest en sécurité
Je l’adopterais graduellement.
Étape 1 : Trouver des tests concurrents instables ou lents
Recherchez les attentes réelles et les tests lourds en délai d’expiration. Les commandes grep de la section précédente sont un bon point de départ pour identifier les candidats dans tout le dépôt de code.
Étape 2 : Choisir un paquet
Choisissez un paquet qui a un comportement asynchrone clair mais qui ne nécessite pas de services externes réels. Les paquets de travailleurs, les aides à la nouvelle tentative et les composants pilotés par minuterie sont des premières cibles idéales.
Étape 3 : Convertir un test
Enveloppez le test dans synctest.Test et remplacez les attentes arbitraires par synctest.Wait, des attentes avec 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 courses
Toujours exécuter avec go test -race ./... après conversion. Un test synctest qui passe ne signifie pas que le code est exempt de courses ; cela signifie seulement que le minutage asynchrone est maintenant déterministe.
Étape 5 : Réviser le cycle de vie des goroutines
Assurez-vous que chaque goroutine démarrée par le test a un moyen de se terminer avant que la bulle ne se ferme. Si ce n’est pas le cas, synctest.Test révélera la fuite au lieu 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 remplace — si ce n’est pas le cas, la conversion n’a pas été de la peine.
Mes règles dogmatiques
Utilisez celles-ci comme règles pratiques.
Règle 1 : Pas d’attentes arbitraires dans les tests unitaires concurrents
Une attente qui attend qu’une goroutine finisse peut-être est une odeur (smell). Remplacez-la par des canaux, des WaitGroups, des rappels, synctest.Wait, ou le temps factice — tout ce qui attend une condition plutôt que d’espérer que suffisamment de temps s’est écoulé.
Règle 2 : Garder les tests synctest autosuffisants
Créez les goroutines, canaux, contextes, minuteries et travailleurs à l’intérieur de la bulle. Évitez l’état partagé au niveau du paquet, qui peut fuiter entre les tests et casser l’isolation qui rend synctest utile.
Règle 3 : N’utiliser pas synctest comme enveloppe de test d’intégration
Si le test parle à une base de données réelle, au réseau réel ou à un processus externe, gardez-le hors de synctest à moins que vous n’ayez une raison très spécifique de le faire.
Règle 4 : Tester le comportement, pas la chance du planificateur
L’objectif n’est pas de forcer une goroutine à exécuter. L’objectif 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 de suppositions de minutage.
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 se termine proprement.
Pensées finales
testing/synctest est l’une de ces fonctionnalités Go qui semble petite mais qui change la façon dont vous écrivez une classe de tests. Il ne remplace pas une bonne conception de la concurrence, le détecteur de courses, ou le besoin de tests d’intégration — mais il rend beaucoup de tests unitaires asynchrones plus rapides, plus propres et beaucoup moins dépendants de la chance de minutage.
Cela compte parce que le code concurrent est déjà assez difficile. Les tests devraient réduire l’incertitude, pas l’ajouter. Pour une vue plus large des modèles Go de production à travers l’intégration, la structure du code et l’accès aux données, voyez Architecture d’Application en Production.
La conclusion pratique est simple :
Utilisez synctest pour des tests unitaires déterministes autour des goroutines, minuteries, délais d'expiration, nouvelles tentatives et annulation.
Gardez les attentes réelles hors des tests concurrents sauf si vous avez une très bonne raison.
Cette seule habitude rendra beaucoup de 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 dans 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
- https://pkg.go.dev/testing/synctest
- https://go.dev/blog/testing-time
- https://go.dev/blog/synctest
- [https://go.dev/blog/testing-time](“Testing Time (and other asynchronicities) - The Go Programming Language”)
- https://go.dev/doc/go1.25
- https://go.dev/blog/go1.25