Les tests parallèles sans effort supplémentaire
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.
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é :
| Ressource | Isolation |
|---|---|
| Bases SQLite | Chaque test clone sa propre base en mémoire |
Cache persistent_term | Initialisation 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és | Aucun démarré dans les tests |
| Système de fichiers | Aucune é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
| Paquet | Tests | Avant | Après |
|---|---|---|---|
| server | environ 800 | environ 4,0 s | environ 0,85 s |
| shared | environ 100 | environ 0,5 s | environ 0,5 s |
| client | 1 | environ 0,2 s | environ 0,2 s |
| Total | environ 900 | environ 4,7 s | environ 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.