Trois tests verts en local, deux rouges sur la CI. Le résultat dépend de la charge
du serveur ce jour-là. Ce n'est pas un bug de logique : c'est un
time.Sleep qui estime que 100 ms suffisent pour laisser la goroutine
finir.
Ce type de test ne garantit rien. Il parie.
testing/synctest, stable depuis Go 1.25 (août 2025), règle ce problème
à la racine. Plus de paris, plus d'attentes arbitraires : l'horloge avance sur
commande, à l'intérieur d'une bulle isolée.
Le test qui ment
Le debounce est un exemple classique de code concurrent avec du temps. L'idée : déclencher une action uniquement si aucun appel ne s'est produit depuis N millisecondes. Voici une implémentation simple.
func Debounce(fn func(), delay time.Duration) func() {
var timer *time.Timer
var mu sync.Mutex
return func() {
mu.Lock()
defer mu.Unlock()
if timer != nil {
timer.Stop()
}
timer = time.AfterFunc(delay, fn)
}
}
Et le test naïf qui va avec :
// ✗ Fragile
func TestDebounce(t *testing.T) {
called := 0
debounced := Debounce(func() { called++ }, 50*time.Millisecond)
debounced()
debounced()
debounced()
time.Sleep(200 * time.Millisecond) // les doigts croisés
if called != 1 {
t.Fatalf("want 1 call, got %d", called)
}
}
Ce test passe en local parce que la machine est rapide et que 200 ms est une
marge confortable. Sur une CI chargée, la goroutine interne de
time.AfterFunc peut ne pas avoir tourné encore quand
called est lu. Le test devient flaky, sans raison visible dans les logs.
Le détecteur de race (go test -race) ne trouve rien non plus.
Il n'y a pas d'accès concurrent à la même mémoire au même instant : le problème
est purement temporel. Deux outils différents, deux angles différents.
Ce que fait testing/synctest
testing/synctest isole le test dans une "bulle". À l'intérieur,
le package time utilise une horloge virtuelle que le test contrôle.
Cette horloge n'avance pas toute seule : elle saute uniquement quand
toutes les goroutines de la bulle sont durablement bloquées
(en attente d'un channel, d'un mutex, d'un timer... mais pas d'un syscall ou
d'une I/O réseau).
L'API de Go 1.25 ne contient que deux fonctions :
-
synctest.Test(t, f): exécutefdans une bulle, puis attend que toutes ses goroutines aient terminé avant de rendre la main au framework de test. -
synctest.Wait(): bloque jusqu'à ce que toutes les autres goroutines de la bulle soient durablement bloquées. C'est le point de synchronisation explicite pour savoir qu'un travail en arrière-plan est terminé.
Go 1.24 avait introduit un prototype expérimental derrière le flag
GOEXPERIMENT=synctest, avec synctest.Run() à la place de
synctest.Test(). Go 1.25 a promu le package en bibliothèque
standard stable et finalisé l'API. Pas de flag, pas de build tag, import direct.
Avant / après
Le même test de debounce, réécrit avec testing/synctest :
// ✓ Déterministe
import "testing/synctest"
func TestDebounce(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
called := 0
debounced := Debounce(func() { called++ }, 50*time.Millisecond)
debounced()
debounced()
debounced()
// Avance l'horloge virtuelle de 100 ms : instantané, zéro attente réelle.
// La goroutine AfterFunc (délai 50 ms) se réveille en cours de route.
time.Sleep(100 * time.Millisecond)
synctest.Wait()
if called != 1 {
t.Fatalf("want 1 call, got %d", called)
}
})
}
Ce qui se passe dans la bulle :
-
Les trois appels à
debounced()planifient untime.AfterFuncà 50 ms (chaque appel annule et replanifie). -
time.Sleep(100ms)bloque la goroutine du test. Toutes les goroutines de la bulle sont maintenant bloquées : le runtime avance l'horloge virtuelle à 50 ms, la goroutineAfterFuncse réveille et exécutecalled++. -
L'horloge continue jusqu'à 100 ms, le test se réveille.
synctest.Wait()confirme que la goroutineAfterFunca bien terminé. -
L'assertion sur
calledest fiable. Toujours. Sans pari.
La durée réelle du test : quelques microsecondes, pas 200 ms.
Ce que synctest ne couvre pas
testing/synctest n'est pas universel. Trois catégories lui échappent.
Les I/O réseau. Une goroutine bloquée sur une lecture réseau
n'est pas "durablement bloquée" au sens de synctest : un processus externe peut
la débloquer à tout moment. Si votre code fait de vrais appels HTTP,
synctest.Wait() ne peut pas raisonner sur son état. La solution :
remplacer la couche réseau par une interface avec une implémentation de test
déterministe (un net.Pipe(), un httptest.Server local).
Les syscalls et cgo. Même raison : le runtime Go ne contrôle pas ce que le système d'exploitation ou du code C font en arrière-plan. Ces appels ne sont pas considérés comme durablement bloquants.
Les sync.WaitGroup déclarés en variable de paquet
(var wg sync.WaitGroup). Ils ne peuvent pas être associés à une bulle.
Les déclarer dans la fonction de test résout le problème.
Les cas d'usage idéaux : timers, tickers, debounce, rate limiters, caches avec TTL,
retry avec backoff, tout code qui enchaîne time.After,
time.Sleep, time.AfterFunc avec des goroutines qui
communiquent par channels.
Conclusion
go test -race reste indispensable pour les data races réelles.
testing/synctest s'attaque à une catégorie différente : les tests dont
la fiabilité dépend de l'horloge murale. Les deux couvrent des angles orthogonaux,
ils se complètent.
Si vous avez du time.Sleep dans vos tests Go pour "laisser la goroutine
finir", c'est le signe que testing/synctest fera le boulot mieux et
plus vite. Go 1.25 est sorti en août 2025 : le package est dans la stdlib,
disponible sans configuration.
📚 Pour aller plus loin. Si les goroutines qui ne se terminent jamais sont votre problème, la suite logique est Goroutine leaks en Go : détecter, comprendre, corriger.