Aller au contenu principal

Les tests parallèles sans effort supplémentaire

· 6 minutes de lecture
Dave Rapin
Dave Rapin
Fondateur chez Curling IO
Mise à jour : 1er octobre 2026

Cet article présente notre exploration initiale de Gleam pour Curling IO v3. Nous avons finalement choisi Rust et TypeScript. Pourquoi nous avons choisi Rust pour la version 3 explique cette décision et ce que nous avons conservé de cette exploration.

À propos de cet article

Cette note technique d’implémentation vient du développement de Curling IO v3. Elle s’adresse aux ingénieurs logiciels et aborde Gleam, Erlang, EUnit, l’isolation des tests et l’exécution parallèle plus en détail que nos articles habituels sur le produit.

En écrivant l’article précédent sur nos bases SQLite par test, je décrivais comment chaque test reçoit sa propre base en mémoire, sans connexion ni état partagé. Une idée m’est venue : si rien n’est partagé, pouvons-nous simplement tous les exécuter en même temps?

Nous pouvions, et notre suite de tests serveur est passée d’environ 4 secondes à environ 0,85 seconde pour quelque 800 tests. Aucun changement dans les tests eux-mêmes. Un seul module Erlang de 25 lignes.

Pourquoi l’exécution séquentielle était le choix par défaut​

L’exécuteur de tests de Gleam, gleeunit, délègue au cadriciel EUnit d’Erlang. Par défaut, EUnit exécute les modules de test un à la fois. C’est le choix prudent, car la plupart des suites partagent quelque part un état modifiable : une connexion à une base, un processus nommé ou un fichier sur disque. Exécuter ces tests simultanément produit des échecs qui passent localement mais échouent en intégration continue, ou qui passent le mardi et échouent le mercredi.

Nos tests ne partagent aucun état modifiable. Chaque test clone sa propre base. Deux tests ne touchent jamais la même connexion. L’article précédent explique toute la configuration, mais en résumé, test_db.setup() clone une base modèle en cache par l’API de sauvegarde SQLite et retourne une nouvelle connexion indépendante. À la fin du test, le ramasse-miettes récupère la connexion et la base disparaît.

Ce qui est réellement isolé​

Avant d’activer le parallélisme, nous avons vérifié chaque catégorie d’état partagé :

RessourceIsolation
Bases SQLiteChaque test clone sa propre base en mémoire
Cache persistent_termInitialisation idempotente, la première valeur écrite est conservée (base modèle, fuseaux horaires, configuration des journaux)
Tables ETS (limiteur de débit)Sans nom, chaque test crée la sienne avec ets:new
Processus nommésAucun démarré dans les tests
Système de fichiersAucune écriture dans les tests

Les entrées persistent_term sont des caches écrits une seule fois. Plusieurs tests peuvent tenter d’initialiser la base modèle simultanément, mais la première initialisation est conservée, et les appels suivants ne font que lire la valeur en cache. C’est sûr.

Les tables ETS des tests, pour le limiteur de débit, sont créées sans l’option named_table. Chaque appel à ets:new retourne donc une référence de table unique. Aucun conflit.

L’implémentation​

EUnit prend en charge l’enveloppe {inparallel, Tests}, qui distribue les fonctions de test entre les ordonnanceurs BEAM. Elle existe depuis des années, mais gleeunit ne l’expose pas. Nous avons proposé une demande de fusion pour ajouter une fonction main_parallel(). On nous a fait remarquer que cela convenait mieux à une solution propre au projet qu’à une modification du cœur de gleeunit. C’est logique. Nous avons fermé la demande et écrit un module Erlang local au projet qui reproduit la découverte des tests et appelle EUnit directement.

-module(parallel_test_runner).
-export([main/0]).

main() ->
Options = [verbose, no_tty,
{report, {gleeunit_progress, [{colored, true}]}},
{scale_timeouts, 10}],
Files = filelib:wildcard("**/*.{erl,gleam}", "test"),
Modules = [to_module(list_to_binary(F)) || F <- Files],
case eunit:test({inparallel, Modules}, Options) of
ok -> erlang:halt(0);
_ -> erlang:halt(1)
end.

to_module(Path) ->
case filename:extension(Path) of
<<".gleam">> ->
binary_to_atom(
binary:replace(
binary:replace(Path, <<".gleam">>, <<"">>),
<<"/">>, <<"@">>, [global]),
utf8);
<<".erl">> ->
binary_to_atom(
binary:replace(lists:last(binary:split(Path, <<"/">>, [global])),
<<".erl">>, <<"">>),
utf8)
end.

Ce module fait la même chose que gleeunit : chercher les fichiers de test avec un motif glob et convertir leurs noms en atomes de module. Il enveloppe toutefois la liste dans {inparallel, ...} plutôt que de la passer à plat. Il réutilise gleeunit_progress de la dépendance Hex pour afficher un point coloré par test. La sortie des tests reste donc la même.

Le point d’entrée tient en une ligne :

@external(erlang, "parallel_test_runner", "main")
pub fn main() -> Nil

Aucun fork, aucun sous-module. Le gleeunit standard reste une dépendance dans gleam.toml, car nous l’utilisons encore pour l’affichage de progression. Seul le paquet serveur utilise l’exécuteur parallèle. Les paquets partagé et client gardent le main() séquentiel standard de gleeunit, car ils ont beaucoup moins de tests et n’en ont pas besoin.

Les résultats​

PaquetTestsAvantAprès
serverenviron 800environ 4,0 senviron 0,85 s
sharedenviron 100environ 0,5 senviron 0,5 s
client1environ 0,2 senviron 0,2 s
Totalenviron 900environ 4,7 senviron 1,6 s

Les paquets partagé et client n’ont pas changé, car ils s’exécutaient déjà en moins d’une seconde.

EUnit sérialise les rappels de son écouteur dans un seul processus. Les points de progression arrivent donc toujours dans l’ordre. La sortie standard de chaque test est livrée après sa fin. Les sorties des tests simultanés ne s’entremêlent donc pas. La sortie des tests est identique à celle d’avant, simplement plus rapide.

Garder cette approche fonctionnelle​

Cela fonctionne parce que l’architecture des tests est saine. Si quelqu’un ajoute un test qui enregistre un processus nommé avec un atome fixe ou écrit dans un chemin de fichier partagé, deux copies de ce test entreront en conflit et produiront des échecs instables. Les règles sont simples :

  • Ne partagez pas de connexions entre les tests. Utilisez test_db.setup().
  • N’enregistrez pas les processus avec des noms fixes. Si un processus nommé est nécessaire, incluez le nom du module de test.
  • N’utilisez pas de tables ETS nommées. Transmettez plutôt la référence de table.
  • N’écrivez pas dans le système de fichiers pendant les tests.

Si un test ne peut pas suivre ces règles, EUnit permet de mélanger {inparallel, ...} et {inorder, ...} dans une même exécution. Nous pourrions conserver une liste de modules séquentiels dans l’exécuteur et les regrouper séparément :

Sequential = [some_integration_test],
Parallel = Modules -- Sequential,
eunit:test([{inparallel, Parallel}, {inorder, Sequential}], Options)

Les modules séquentiels s’exécuteraient un à la fois pendant que les autres resteraient parallèles. Nous n’en avons pas encore eu besoin, mais quelques lignes suffiraient.

Le gain inattendu​

Rien de cela n’était prévu. Nous avions choisi des bases SQLite par test pour éliminer le nettoyage, les dépendances d’ordre et les tests instables causés par des fuites d’état. Nous sommes encore un peu surpris qu’une accélération permanente de 4,7 fois, dont nous profiterons pendant toute la vie du projet, ait demandé un après-midi et 25 lignes d’Erlang.

Trente-sept lignes au total, avec les 12 lignes du module FFI de l’article précédent, pour isoler les bases par test et exécuter les tests en parallèle sur tous les cœurs disponibles. Aucune extension de cadriciel de test, aucune configuration. Le tout se compile avec gleam build et s’exécute avec gleam test.


Cet article est le septième de la série sur les fondations de Curling IO. À suivre : un panneau d’administration en direct sans écrire de JavaScript.