testing/synctest en Go 1.25 : tester la concurrence sans sleep ni race

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écute f dans 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 :

  1. Les trois appels à debounced() planifient un time.AfterFunc à 50 ms (chaque appel annule et replanifie).
  2. 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 goroutine AfterFunc se réveille et exécute called++.
  3. L'horloge continue jusqu'à 100 ms, le test se réveille. synctest.Wait() confirme que la goroutine AfterFunc a bien terminé.
  4. L'assertion sur called est 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.

Commentaires (0)