Tests parallèles basés sur des tables en Go
Accélérer les tests Go avec l'exécution parallèle
Les tests basés sur des tableaux sont l’approche idiomatique en Go pour tester efficacement plusieurs scénarios. Lorsqu’ils sont combinés à l’exécution parallèle à l’aide de t.Parallel(), vous pouvez réduire considérablement le temps d’exécution du jeu de tests, surtout pour les opérations liées à l’E/S.
Cependant, l’exécution parallèle des tests introduit des défis uniques liés aux conditions de course et à l’isolation des tests qui nécessitent une attention particulière.

Compréhension de l’exécution parallèle des tests
Le package de test de Go fournit un support intégré pour l’exécution parallèle des tests via la méthode t.Parallel(). Lorsqu’un test appelle t.Parallel(), il signale au gestionnaire de tests qu’il peut s’exécuter en parallèle avec d’autres tests parallèles. Cela est particulièrement puissant lorsqu’il est combiné aux tests basés sur des tableaux, où vous avez de nombreux cas de test indépendants pouvant s’exécuter simultanément.
Le parallélisme par défaut est contrôlé par GOMAXPROCS, qui est généralement égal au nombre de cœurs de processeur sur votre machine. Vous pouvez l’ajuster avec l’indicateur -parallel : go test -parallel 4 limite les tests parallèles à 4, indépendamment du nombre de cœurs de processeur. Cela est utile pour contrôler l’utilisation des ressources ou lorsque les tests ont des exigences spécifiques de concurrence.
Pour les développeurs nouveaux dans les tests Go, comprendre les fondamentaux est crucial. Notre guide sur les meilleures pratiques pour les tests unitaires en Go couvre les tests basés sur des tableaux, les sous-tests et les bases du package de test qui forment la base de l’exécution parallèle.
Schéma de base des tests basés sur des tableaux en parallèle
Voici le schéma correct pour les tests basés sur des tableaux en parallèle :
func TestCalculate(t *testing.T) {
tests := []struct {
name string
a, b int
op string
expected int
wantErr bool
}{
{"addition", 2, 3, "+", 5, false},
{"subtraction", 5, 3, "-", 2, false},
{"multiplication", 4, 3, "*", 12, false},
{"division", 10, 2, "/", 5, false},
{"division par zéro", 10, 0, "/", 0, true},
}
for _, tt := range tests {
tt := tt // Capture la variable de boucle
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // Active l'exécution parallèle
result, err := Calculate(tt.a, tt.b, tt.op)
if (err != nil) != tt.wantErr {
t.Errorf("Calculate() error = %v, wantErr %v", err, tt.wantErr)
return
}
if result != tt.expected {
t.Errorf("Calculate(%d, %d, %q) = %d; want %d",
tt.a, tt.b, tt.op, result, tt.expected)
}
})
}
}
La ligne critique est tt := tt avant t.Run(). Cela capture la valeur actuelle de la variable de boucle, assurant que chaque sous-test parallèle opère sur sa propre copie des données du cas de test.
Le problème de capture de la variable de boucle
C’est l’une des erreurs les plus courantes lors de l’utilisation de t.Parallel() avec des tests basés sur des tableaux. En Go, la variable de boucle tt est partagée entre toutes les itérations. Lorsque les sous-tests s’exécutent en parallèle, ils peuvent tous faire référence à la même variable tt, qui est remplacée à mesure que la boucle continue. Cela entraîne des conditions de course et des échecs de test imprévisibles.
Incorrect (condition de course) :
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Tous les sous-tests peuvent voir la même valeur de tt !
result := Calculate(tt.a, tt.b, tt.op)
})
}
Correct (variable capturée) :
for _, tt := range tests {
tt := tt // Capture la variable de boucle
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Chaque sous-test a sa propre copie de tt
result := Calculate(tt.a, tt.b, tt.op)
})
}
L’affectation tt := tt crée une nouvelle variable portée par l’itération de la boucle, assurant que chaque goroutine a sa propre copie des données du cas de test.
Assurer l’indépendance des tests
Pour que les tests parallèles fonctionnent correctement, chaque test doit être complètement indépendant. Ils ne devraient pas :
- Partager un état global ou des variables
- Modifier des ressources partagées sans synchronisation
- Dépendre de l’ordre d’exécution
- Accéder aux mêmes fichiers, bases de données ou ressources réseau sans coordination
Exemple de tests parallèles indépendants :
func TestValidateEmail(t *testing.T) {
tests := []struct {
name string
email string
wantErr bool
}{
{"email valide", "user@example.com", false},
{"format invalide", "not-an-email", true},
{"domaine manquant", "user@", true},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := ValidateEmail(tt.email)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateEmail(%q) error = %v, wantErr %v",
tt.email, err, tt.wantErr)
}
})
}
}
Chaque cas de test opère sur ses propres données d’entrée sans état partagé, ce qui le rend sûr pour l’exécution parallèle.
Détection des conditions de course
Go propose un détecteur de conditions de course puissant pour capturer les accès concurrents aux données dans les tests parallèles. Exécutez toujours vos tests parallèles avec l’indicateur -race pendant le développement :
go test -race ./...
Le détecteur de conditions de course signalera tout accès concurrent à la mémoire partagée sans synchronisation appropriée. Cela est essentiel pour capturer les bugs subtils qui ne peuvent apparaître que sous des conditions de timing spécifiques.
Exemple de condition de course :
var counter int // État partagé - DANGEREUX !
func TestIncrement(t *testing.T) {
tests := []struct {
name string
want int
}{
{"test1", 1},
{"test2", 2},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
counter++ // CONDITION DE COURSE !
if counter != tt.want {
t.Errorf("counter = %d, want %d", counter, tt.want)
}
})
}
}
L’exécution de ce test avec -race détectera la modification concourante de counter. La solution est de rendre chaque test indépendant en utilisant des variables locales au lieu de l’état partagé.
Avantages en termes de performance
L’exécution parallèle peut réduire considérablement le temps d’exécution du jeu de tests. La vitesse dépend de :
- Nombre de cœurs de processeur : Plus de cœurs permettent à plus de tests de s’exécuter simultanément
- Caractéristiques des tests : Les tests liés à l’E/S bénéficient davantage que les tests liés au processeur
- Nombre de tests : Les jeux de tests plus grands voient des économies de temps absolues plus importantes
Mesurer les performances :
# Exécution séquentielle
go test -parallel 1 ./...
# Exécution parallèle (par défaut)
go test ./...
# Parallélisme personnalisé
go test -parallel 8 ./...
Pour les jeux de tests avec de nombreuses opérations E/S (requêtes de base de données, requêtes HTTP, opérations de fichiers), vous pouvez souvent obtenir un gain de 2 à 4 fois sur les systèmes modernes à plusieurs cœurs. Les tests liés au processeur peuvent voir moins de bénéfices en raison de la contention des ressources de processeur.
Contrôle du parallélisme
Vous avez plusieurs options pour contrôler l’exécution parallèle des tests :
1. Limiter le nombre maximum de tests parallèles :
go test -parallel 4 ./...
2. Définir GOMAXPROCS :
GOMAXPROCS=2 go test ./...
3. Exécution parallèle sélective :
Ne marquez que certains tests avec t.Parallel(). Les tests sans cette appelle s’exécutent séquentiellement, ce qui est utile lorsque certains tests doivent s’exécuter dans un ordre spécifique ou partager des ressources.
4. Exécution parallèle conditionnelle :
func TestExpensive(t *testing.T) {
if testing.Short() {
t.Skip("saut de test coûteux en mode court")
}
t.Parallel()
// Logique de test coûteuse
}
Schémas courants et bonnes pratiques
Schéma 1 : Configuration avant l’exécution parallèle
Si vous avez besoin de configuration partagée entre tous les cas de test, faites-la avant la boucle :
func TestWithSetup(t *testing.T) {
// La configuration s'exécute une seule fois, avant l'exécution parallèle
db := setupTestDatabase(t)
defer db.Close()
tests := []struct {
name string
id int
}{
{"user1", 1},
{"user2", 2},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Chaque test utilise db indépendamment
user := db.GetUser(tt.id)
// Logique de test...
})
}
}
Schéma 2 : Configuration par test
Pour les tests nécessitant une configuration isolée, faites-la à l’intérieur de chaque sous-test :
func TestWithPerTestSetup(t *testing.T) {
tests := []struct {
name string
data string
}{
{"test1", "data1"},
{"test2", "data2"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// Chaque test obtient sa propre configuration
tempFile := createTempFile(t, tt.data)
defer os.Remove(tempFile)
// Logique de test...
})
}
}
Schéma 3 : Mixte séquentiel et parallèle
Vous pouvez mélanger les tests séquentiels et parallèles dans le même fichier :
func TestSequential(t *testing.T) {
// Aucun t.Parallel() - s'exécute séquentiellement
// Bon pour les tests qui doivent s'exécuter dans un ordre spécifique
}
func TestParallel(t *testing.T) {
tests := []struct{ name string }{{"test1"}, {"test2"}}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // Ces tests s'exécutent en parallèle
})
}
}
Quand NE PAS utiliser l’exécution parallèle
L’exécution parallèle n’est pas toujours appropriée. Évitez-la lorsqu’ :
- Les tests partagent un état : Variables globales, singletons ou ressources partagées
- Les tests modifient des fichiers partagés : Fichiers temporaires, bases de données de test ou fichiers de configuration
- Les tests dépendent de l’ordre d’exécution : Certains tests doivent s’exécuter avant d’autres
- Les tests sont déjà rapides : L’overhead de la parallélisation peut dépasser les bénéfices
- Contraintes de ressources : Les tests consomment trop de mémoire ou de processeur lorsqu’ils sont parallélisés
Pour les tests liés à la base de données, envisagez l’utilisation de rollbacks de transactions ou de bases de données de test séparées par test. Notre guide sur les schémas de base de données multi-locataires en Go couvre les stratégies d’isolation qui fonctionnent bien avec les tests parallèles.
Avancé : Test de code concurrent
Lorsque vous testez du code concurrent (et non seulement l’exécution des tests en parallèle), vous avez besoin de techniques supplémentaires :
func TestConcurrentOperation(t *testing.T) {
tests := []struct {
name string
goroutines int
}{
{"2 goroutines", 2},
{"10 goroutines", 10},
{"100 goroutines", 100},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var wg sync.WaitGroup
results := make(chan int, tt.goroutines)
for i := 0; i < tt.goroutines; i++ {
wg.Add(1)
go func() {
defer wg.Done()
results <- performOperation()
}()
}
wg.Wait()
close(results)
// Vérification des résultats
count := 0
for range results {
count++
}
if count != tt.goroutines {
t.Errorf("attendu %d résultats, obtenu %d", tt.goroutines, count)
}
})
}
}
Exécutez toujours de tels tests avec -race pour détecter les conditions de course dans le code testé.
Intégration avec CI/CD
Les tests parallèles s’intègrent parfaitement avec les pipelines CI/CD. La plupart des systèmes CI fournissent plusieurs cœurs de processeur, rendant l’exécution parallèle très bénéfique :
# Exemple GitHub Actions
- name: Exécuter les tests
run: |
go test -race -coverprofile=coverage.out -parallel 4 ./...
go tool cover -html=coverage.out -o coverage.html
L’indicateur -race est particulièrement important en CI pour détecter les bugs de concurrence qui peuvent ne pas apparaître en développement local.
Débogage des échecs de tests parallèles
Lorsque les tests parallèles échouent de manière intermittente, le débogage peut être difficile :
- Exécuter avec
-race: Identifier les conditions de course - Réduire le parallélisme :
go test -parallel 1pour voir si les échecs disparaissent - Exécuter des tests spécifiques :
go test -run TestNamepour isoler le problème - Ajouter des logs : Utiliser
t.Log()pour tracer l’ordre d’exécution - Vérifier l’état partagé : Rechercher des variables globales, des singletons ou des ressources partagées
Si les tests passent séquentiellement mais échouent en parallèle, vous avez probablement un problème de condition de course ou d’état partagé.
Exemple concret : Tester des gestionnaires HTTP
Voici un exemple pratique de test de gestionnaires HTTP en parallèle :
func TestHTTPHandlers(t *testing.T) {
router := setupRouter()
tests := []struct {
name string
method string
path string
statusCode int
}{
{"GET users", "GET", "/users", 200},
{"GET user by ID", "GET", "/users/1", 200},
{"POST user", "POST", "/users", 201},
{"DELETE user", "DELETE", "/users/1", 204},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(tt.method, tt.path, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != tt.statusCode {
t.Errorf("attendu statut %d, obtenu %d", tt.statusCode, w.Code)
}
})
}
}
Chaque test utilise httptest.NewRecorder(), qui crée un enregistreur de réponse isolé, rendant ces tests sûrs pour l’exécution parallèle.
Conclusion
L’exécution parallèle des tests basés sur des tableaux est une technique puissante pour réduire le temps d’exécution des jeux de tests en Go. La clé du succès est de comprendre la nécessité de capturer les variables de boucle, d’assurer l’indépendance des tests et d’utiliser le détecteur de conditions de course pour détecter les problèmes de concurrence précocement.
N’oubliez pas :
- Toujours capturer les variables de boucle :
tt := ttavantt.Parallel() - Assurer que les tests sont indépendants sans état partagé
- Exécuter les tests avec
-racependant le développement - Contrôler le parallélisme avec l’indicateur
-parallelsi nécessaire - Éviter l’exécution parallèle pour les tests qui partagent des ressources
En suivant ces pratiques, vous pouvez en toute sécurité utiliser l’exécution parallèle pour accélérer vos jeux de tests tout en maintenant leur fiabilité. Pour plus de schémas de tests en Go, consultez notre guide complet sur les tests unitaires en Go, qui couvre les tests basés sur des tableaux, les mocks et d’autres techniques essentielles de test.
Lorsque vous construisez des applications Go plus importantes, ces pratiques de test s’appliquent à différents domaines. Par exemple, lors de la construction d’applications CLI avec Cobra & Viper, vous utiliserez des schémas similaires de tests parallèles pour tester les gestionnaires de commandes et l’analyse de configuration.
Liens utiles
- Fiche de rappel Go
- Tests unitaires en Go : Structure & Bonnes pratiques
- Construction d’applications CLI en Go avec Cobra & Viper
- Schémas de base de données multi-locataires avec exemples en Go
- ORMs pour PostgreSQL en Go : GORM vs Ent vs Bun vs sqlc