Voici comment tester du code simultané avec Testing/Synctest : Go 1.24

par Go12m2025/04/06
Read on Terminal Reader

Trop long; Pour lire

Cet article expliquera la motivation derrière cette expérience, montrera comment utiliser le package synctest et discutera de son avenir potentiel.
featured image - Voici comment tester du code simultané avec Testing/Synctest : Go 1.24
Go HackerNoon profile picture

Une des caractéristiques de signature de Go est le support intégré pour la concurrence.Goroutines et canaux sont des primitifs simples et efficaces pour écrire des programmes simultanés.


Toutefois, le test de programmes concomitants peut être difficile et sujet à des erreurs.


Dans Go 1.24, nous présentons un nouveau package expérimental testing/synctest pour soutenir le test du code concurrent.Ce post expliquera la motivation derrière cette expérience, démontrera comment utiliser le package synctest et discutera de son avenir potentiel.

évaluation / synchronisation


Dans Go 1.24, le paquet testing/synctest est expérimental et n'est pas soumis à la promesse de compatibilité Go. Il n'est pas visible par défaut. Pour l'utiliser, compile ton code avec GOEXPERIMENT=synctest défini dans ton environnement.

évaluation / synchronisationSynthèse = synthèse

Il est difficile de tester des programmes simultanés

Pour commencer, prenons un exemple simple.


La fonction context.AfterFunc dispose qu'une fonction soit appelée dans sa propre goroutine après l'annulation d'un contexte. Voici un test possible pour AfterFunc :

context.AfterFunctionAprès fonctionnement
func TestAfterFunc(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) calledCh := make(chan struct{}) // clos quand AfterFunc est appelé context.AfterFunc(ctx, func() { close(calledCh) }) // TODO: Affirmer que l'AfterFunc n'a pas été appelé. cancel() // TODO: Affirmer que l'AfterFunc a été appelé. } 
func TestAfterFunc(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) calledCh := make(chan struct{}) // clos quand AfterFunc est appelé context.AfterFunc(ctx, func() { close(calledCh) }) // TODO: Affirmer que l'AfterFunc n'a pas été appelé. cancel() // TODO: Affirmer que l'AfterFunc a été appelé. }

Nous voulons vérifier deux conditions dans ce test: La fonction n'est pas appelée avant que le contexte ne soit annulé, et la fonction est appelée après que le contexte est annulé.

il est


Nous pouvons facilement tester que la fonction n'a pas été appelée yet, mais comment vérifier qu'elle ne sera pas appelée ?


et encorene sera pas


Une approche courante consiste à attendre un certain temps avant de conclure qu'un événement ne se produira pas.

// funcCalled indique si la fonction a été appelée. funcCalled := func() bool { select { case <-calledCh: return true case <-time.After(10 * time.Millisecond): return false } } si funcCalled() { t.Fatalf("AfterFunc function called before context is canceled") } cancel() si!funcCalled() { t.Fatalf("AfterFunc function not called after context is canceled") } 
// funcCalled indique si la fonction a été appelée. funcCalled := func() bool { select { case <-calledCh: return true case <-time.After(10 * time.Millisecond): return false } } si funcCalled() { t.Fatalf("AfterFunc function called before context is canceled") } cancel() si!funcCalled() { t.Fatalf("AfterFunc function not called after context is canceled") }

Ce test est lent: 10 millisecondes n'est pas beaucoup de temps, mais il s'ajoute à de nombreux tests.


Ce test est également flou: 10 millisecondes est un long temps sur un ordinateur rapide, mais il n'est pas rare de voir des pauses de plusieurs secondes sur des systèmes partagés et surchargés CI.

CI


Nous pouvons rendre le test moins flou au détriment de le rendre plus lent, et nous pouvons le rendre moins lent au détriment de le rendre plus flou, mais nous ne pouvons pas le rendre à la fois rapide et fiable.

Introduction au package de test/synctest

Le paquet testing/synctest résout ce problème.Il nous permet de réécrire ce test pour être simple, rapide et fiable, sans aucune modification au code qui est testé.

évaluation / synchronisation


Le package ne contient que deux fonctions : Run et Wait.


Exécution Précédent


Run appelle une fonction dans une nouvelle goroutine. Cette goroutine et toutes les goroutines commencées par elle existent dans un environnement isolé que nous appelons une bubble. Wait attend que chaque goroutine dans la bulle de la goroutine actuelle bloque une autre goroutine dans la bulle.

Exécution bubble et Précédent


Récrivons notre test ci-dessus en utilisant le paquet testing/synctest.

évaluation / synchronisation
func TestAfterFunc(t *testing.T) { synctest.Run(func() { ctx, cancel := context.WithCancel(context.Background()) funcCalled := false context.AfterFunc(ctx, func() { funcCalled = true }) synctest.Wait() si funcCalled { t.Fatalf("AfterFunc fonction appelée avant le contexte est annulé") } cancel() synctest.Wait() si!funcCalled { t.Fatalf("AfterFunc fonction n'est pas appelée après le contexte est annulé") }) } func TestAfterFunc(t *testing.T) { synctest.Run(func() { ctx, cancel := context.WithCancel(context.Background()) funcCalled := false context.AfterFunc(ctx, func() { funcCalled = true }) synctest.Wait() si funcCalled { t.Fatalf("AfterFunc function called before context is canceled") } cancel() synctest.Wait() si!funcCalled { t.Fatalf("AfterFunc function not called after context is canceled") } } } 

Cela est presque identique à notre test original, mais nous avons emballé le test dans un appel synctest.Run et nous appelons synctest.Wait avant d'affirmer que la fonction a été appelée ou non.

connexion.Runconnexion. attendez


La fonction Wait attend que chaque goroutine dans la bulle de l'appelant bloque.Lorsqu'elle revient, nous savons que le paquet contextuel a soit appelé la fonction, soit ne l'appellera pas jusqu'à ce que nous prenions une autre action.

Précédent


Ce test est maintenant à la fois rapide et fiable.


Le test est aussi plus simple : nous avons remplacé le canal calledCh par un boolean.Avant, nous avions besoin d'utiliser un canal pour éviter une course de données entre le goroutine de test et le goroutine AfterFunc, mais la fonction Wait fournit maintenant cette synchronisation.

calledCh Le codeAprès fonctionnementPrécédent


Le détecteur de course comprend les appels Wait, et ce test passe lors de l'exécution avec -race. Si nous supprimons le deuxième appel Wait, le détecteur de course signalera correctement une course de données dans le test.

Précédent -coursePrécédent

Test du temps

Le code concurrentiel traite souvent du temps.


Tester le code qui fonctionne avec le temps peut être difficile.L'utilisation du temps réel dans les tests provoque des tests lents et flaks, comme nous l'avons vu ci-dessus.L'utilisation du temps faux nécessite d'éviter les fonctions de package time et de concevoir le code en cours de test pour fonctionner avec une horloge faux facultative.

temps


Le package testing/synctest simplifie le test du code qui utilise le temps.

évaluation / synchronisation


Goroutines dans la bulle commençant par Run utilisent une fausse horloge. Dans la bulle, les fonctions du paquet temps fonctionnent sur la fausse horloge.Exécution temps


Pour démontrer, écrivons un test pour la fonction context.WithTimeout. WithTimeout crée un enfant d'un contexte, qui expire après un délai donné.

context.WithTimeout Le code Avec le temps
func TestWithTimeout(t *testing.T) { synctest.Run(func() { const timeout = 5 * time.Second ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() // Wait juste moins que le timeout. time.Sleep(timeout - time.Nanosecond) synctest.Wait() si err : ctx.Err(); err!= nil { t.Fatalf("before timeout, ctx.Err() = %v; want nil", err) } // Wait le reste du chemin jusqu'au timeout time.Sleeptime(Nanosecond) synctest.Wait() si err : ct= ctx.Err(); err!= context.Deadlinefunc TestWithTimeout(t *testing.T) { synctest.Run(func() { const timeout = 5 * time.Second ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() // Wait juste moins que le timeout. time.Sleep(timeout - time.Nanosecond) synctest.Wait() si err := ctx.Err(); err!= nil { t.Fatalf("before timeout, ctx.Err() = %v; want nil", err) } // Wait le reste du chemin jusqu'au timeout time.Sleeptime(Nanosecond) synctest.Wait() si : err= ctx.Err(); err!= context.DeadlineExceeded

Nous écrivons ce test comme si nous travaillions en temps réel.La seule différence est que nous emballons la fonction de test dans synctest.Run, et appelons synctest.Wait après chaque appel time.Sleep pour attendre que les chronomètres du paquet contextuel soient terminés.

connexion.Runconnexion. attendez temps.Dormir

Bloquage et la bulle

Un concept clé dans testing/synctest est que la bulle devient permanemment bloquée. Cela se produit lorsque chaque goroutine dans la bulle est bloquée, et ne peut être débloquée que par une autre goroutine dans la bulle.

évaluation / synchronisationdurablement bloqué


Quand une bulle est bloquée durablement :

  • S'il y a un appel Wait en suspens, il retourne.
  • D'autre part, le temps avance jusqu'à la prochaine fois qui pourrait débloquer une goroutine, le cas échéant.
  • D'autre part, la bulle est verrouillée et Run panique.
  • S'il y a un appel Wait en suspens, il retourne.
  • Précédent
  • Autrement, le temps avance jusqu'à la prochaine fois qui pourrait débloquer une goroutine, le cas échéant.
  • Sans cela, la bulle est verrouillée et Run panique.
  • Exécution


    Une bulle n'est pas bloquée de façon durable si une goroutine est bloquée mais peut être réveillée par un événement à l'extérieur de la bulle.


    La liste complète des opérations qui bloquent durablement une goroutine est :

    • a envoyer ou recevoir sur un canal nil
    • a envoyer ou recevoir bloqué sur un canal créé dans la même bulle
    • une déclaration sélectionnée où chaque cas est bloquant durablement
    • time.Sleep
    • sync.Cond.Wait
    • sync.WaitGroup.Wait
  • a envoyer ou recevoir sur un canal nil
  • un envoi ou une réception bloqué sur un canal créé dans la même bulle
  • une déclaration sélectionnée où chaque cas est bloquant durablement
  • temps.Dormir
  • temps.Dormir
  • sync.Cond.Wait
  • sync.Cond.Wait Le code
  • sync.WaitGroup.Waitsync.WaitGroup.Wait Le code

    Mutexes

    Les opérations sur un sync.Mutex ne sont pas bloquées de façon durable.

    sync.Mutex et


    Il est courant pour les fonctions d'acquérir un mutex global. Par exemple, un certain nombre de fonctions dans le paquet réfléchir utilisent un cache global gardé par un mutex. Si une goroutine dans une bulle synctest bloque tout en acquérant un mutex détenu par une goroutine en dehors de la bulle, elle n'est pas bloquée de façon durable - elle est bloquée, mais sera débloquée par une goroutine à partir de l'extérieur de sa bulle.


    Parce que les mutexes ne sont généralement pas tenus pendant de longues périodes de temps, nous les excluons simplement de la considération de testing/synctest.

    évaluation / synchronisation

    Canaux

    Les canaux créés à l'intérieur d'une bulle se comportent différemment de ceux créés à l'extérieur.


    Les opérations de canal ne bloquent durablement que si le canal est en bulle (créé dans la bulle).


    Ces règles garantissent qu'une goroutine n'est bloquée durablement que lorsqu'elle communique avec des goroutines à l'intérieur de sa bulle.

    I/O

    Les opérations I/O externes, telles que la lecture à partir d'une connexion réseau, ne sont pas bloquées de façon durable.


    Les lectures réseau peuvent être débloquées par des écrits provenant de l'extérieur de la bulle, peut-être même d'autres processus.Même si le seul écrivain d'une connexion réseau est également dans la même bulle, le temps d'exécution ne peut pas distinguer entre une connexion attendant d'arriver plus de données et celle où le noyau a reçu des données et est en cours de livraison.


    Par exemple, la fonction net.Pipe crée une paire de net.Conn qui utilisent une connexion réseau en mémoire et peuvent être utilisées dans les tests de synchronisation.

  • net.Pipe Le codenet.Conn Le code

    La durée de vie de la bulle

    La fonction Run déclenche une goroutine dans une nouvelle bulle. Il revient lorsque chaque goroutine dans la bulle a quitté. Il panique si la bulle est bloquée de façon durable et ne peut pas être débloquée en avançant le temps.

    Exécution


    L'exigence que chaque goroutine dans la sortie de la bulle avant le retour de Run signifie que les tests doivent être prudents pour nettoyer les goroutines de fond avant de terminer.

    Test du code réseau

    Voyons un autre exemple, cette fois en utilisant le paquet testing/synctest pour tester un programme en réseau.

    évaluation / synchronisationnet / HTTP


    Un client HTTP qui envoie une demande peut inclure un en-tête « Expect: 100-continue» pour dire au serveur que le client a des données supplémentaires à envoyer.Le serveur peut alors répondre avec une réponse d'information continue 100 pour demander le reste de la demande, ou avec un autre statut pour dire au client que le contenu n'est pas nécessaire.Par exemple, un client téléchargeant un grand fichier pourrait utiliser cette fonction pour confirmer que le serveur est prêt à accepter le fichier avant de l'envoyer.


    Notre test confirme que lors de l'envoi d'un en-tête « Expect: 100-continue», le client HTTP n'envoie pas le contenu d'une demande avant que le serveur ne le demande, et qu'il envoie le contenu après avoir reçu une réponse continue.


    Souvent, les tests d'un client et d'un serveur de communication peuvent utiliser une connexion réseau loopback.En travaillant avec testing/synctest, cependant, nous voudrions généralement utiliser une connexion réseau fausse pour nous permettre de détecter quand toutes les goroutines sont bloquées sur le réseau.Nous allons commencer ce test en créant un http.Transport (un client HTTP) qui utilise une connexion réseau en mémoire créée par net.Pipe.

    évaluation / synchronisationHTTP Transportnet.Pipe Le code
    func Test(t *testing.T) { synctest.Run(func() { srvConn, cliConn := net.Pipe() defer srvConn.Close() defer cliConn.Close() tr := &http.Transport{ DialContext: func(ctx context.Context, réseau, chaîne d'adresse) (net.Conn, error) {retour cliConn, nil }, // Réglage d'un délai non zéro permet de gérer "Expect: 100-continue". // Puisque le test suivant ne dort pas, // nous ne rencontrerons jamais ce délai, // même si le test prend beaucoup de temps pour fonctionner sur une machine lente.func Test(t *testing.T) { synctest.Run(func() { srvConn, cliConn := net.Pipe() defer srvConn.Close() defer cliConn.Close() tr := &http.Transport{ DialContext: func(ctx context.Context, réseau, chaîne d'adresse) (net.Conn, error) {retour cliConn, nil }, // Réglage d'un délai non-zéro permet de gérer "Expect: 100-continue". // Puisque le test suivant ne dort pas, // nous ne rencontrerons jamais ce délai, // même si le test prend beaucoup de temps pour fonctionner sur une machine lente.


    Nous envoyons une demande sur ce transport avec l'en-tête « Expect: 100-continue».La demande est envoyée dans une nouvelle goroutine, car elle ne sera pas complétée avant la fin du test.

     corps := "request body" go func() { req, _ := http.NewRequest("PUT", "http://test.tld/", strings.NewReader(body)) req.Header.Set("Expect", "100-continue") resp., err := tr.RoundTrip(req) si err!= nil { t.Errorf("RoundTrip: une erreur inattendue %v", err) } autre resp. {.Body.Close() }() 
    body := "request body" go func() { req, _ := http.NewRequest("PUT", "http://test.tld/", strings.NewReader(body)) req.Header.Set("Expect", "100-continue") resp., err := tr.RoundTrip(req) si err!= nil { t.Errorf("RoundTrip: une erreur inattendue %v", err) } autre { resp.Body.Close() }()


    Nous lisons les en-têtes de requête envoyés par le client.

     req, err := http.ReadRequest(bufio.NewReader(srvConn)) si err!= nil { t.Fatalf("ReadRequest: %v", err) } 
    req, err := http.ReadRequest(bufio.NewReader(srvConn)) si err!= nil { t.Fatalf("ReadRequest: %v", err) }


    Maintenant, nous arrivons au cœur du test.Nous voulons affirmer que le client n'enverra pas encore le corps de la demande.


    Nous commençons une nouvelle goroutine en copiant le corps envoyé au serveur dans un strings.Builder, en attendant que tous les goroutines dans la bulle bloquent, et en vérifiant que nous n'avons pas encore lu quoi que ce soit du corps.

    Strings.Builder et


    Si nous oublions l'appel synctest.Wait, le détecteur de course se plaindra correctement d'une course de données, mais avec le Wait ceci est sûr.

    connexion. attendezPrécédent
     var gotBody strings.Builder go io.Copy(&gotBody, req.Body) synctest.Wait() si got := gotBody.String(); got!= "" { t.Fatalf("avant d'envoyer 100 Continuer, lire inattendu corps: %q", got) } 
    var gotBody strings.Builder go io.Copy(&gotBody, req.Body) synctest.Wait() si obtenu := gotBody.String(); obtenu!= "" { t.Fatalf("avant d'envoyer 100 Continuer, lire inattendu corps: %q", obtenu) }


    Nous écrivons une réponse « 100 Continues » au client et vérifions qu’il envoie maintenant le corps de la demande.

     srvConn.Write([]byte("HTTP/1.1 100 Continuer\r\n\n\n")) synctest.Wait() si obtenu := gotBody.String(); obtenu!= corps { t.Fatalf("après envoyer 100 Continuer, lire corps %q, vouloir %q", obtenu, corps) } 
    srvConn.Write([]byte("HTTP/1.1 100 Continuer\r\n\r\n")) synctest.Wait() si obtenu := gotBody.String(); obtenu!= corps { t.Fatalf("après envoyer 100 Continuer, lire corps %q, vouloir %q", obtenu, corps) }

    Et finalement, nous finissons par envoyer la réponse « 200 OK » pour conclure la demande.


    Nous avons lancé plusieurs goroutines au cours de ce test. L'appel synctest.Run attendra que tous sortent avant de revenir.

    connexion.Run
     srvConn.Write([]byte("HTTP/1.1 200 OK\r\n\r\n") }) } 
    srvConn.Write([]byte("HTTP/1.1 200 OK\r\n\r\n") }) }

    Ce test peut être facilement étendu pour tester d'autres comportements, tels que la vérification que le corps de la demande n'est pas envoyé si le serveur ne le demande pas, ou qu'il est envoyé si le serveur ne répond pas dans un délai.

    Status de l'expérience

    Nous introduisons testing/synctest dans Go 1.24 en tant que package expérimental. En fonction des commentaires et de l'expérience, nous pouvons le libérer avec ou sans modifications, continuer l'expérience ou le supprimer dans une version future de Go.

    évaluation / synchronisationexpérimentale


    Le paquet n'est pas visible par défaut. Pour l'utiliser, compile ton code avec GOEXPERIMENT=synctest défini dans ton environnement.

    Synthèse = synthèse


    Si vous essayez testing/synctest, veuillez signaler vos expériences, positives ou négatives, sur go.dev/issue/67434.évaluation / synchronisationgo.dev/issue/67434« HR »

    Crédits: Damien Neil

    Crédits :Damien Neil


    Photo par Gabriel Gusmao sur Unsplash

    Photo de Gabriel Gusmao sur UnsplashGabriel GusmaoUnsplash


    Cet article est disponible sur The Go Blog sous une licence CC BY 4.0 DEED.

    Cet article est disponible sur The Go Blog sous licence CC BY 4.0 DEED.

    Le blog de GoLe blog de la ville


    Trending Topics

    blockchaincryptocurrencyhackernoon-top-storyprogrammingsoftware-developmenttechnologystartuphackernoon-booksBitcoinbooks