L’isolation des tests offerte par SQLite
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 porte sur l’architecture des tests de Curling IO v3. Elle s’adresse aux ingénieurs logiciels et aborde SQLite, les bases de données en mémoire, l’API de sauvegarde et l’isolation des tests plus en détail que nos articles habituels sur le produit.
Les tests de Curling IO n’ont besoin ni d’une base de données de test partagée, ni de fonctions de nettoyage, ni d’astuces transactionnelles. Chaque test a sa propre base de données. Il peut donc réussir seul ou dans la suite complète pour la même raison : rien d’autre ne peut toucher ses données.
Cela découle d’un choix de la version 3 : SQLite fonctionne dans le processus de l’application. Chaque test obtient une base SQLite en mémoire complètement indépendante, clonée depuis un modèle en quelques microsecondes avec l’API de sauvegarde de SQLite.
Le problème de la base de données partagée
Dans une configuration de test Postgres ou MySQL traditionnelle, chaque test communique avec le même serveur de base de données. Il faut une stratégie pour empêcher les tests de se contaminer :
Annulation de transaction. Encapsuler chaque test dans une transaction, puis l’annuler à la fin. Cela fonctionne jusqu’à ce que le code utilise ses propres transactions ou crée des processus qui doivent voir les données du test, le problème classique du bac à sable Ecto.
Vidage des tables. Supprimer toutes les lignes de toutes les tables entre les tests. C’est lent, et il faut respecter le bon ordre pour éviter les violations de clés étrangères.
Outil de nettoyage de base de données. Une gem ou bibliothèque qui combine les deux stratégies avec une configuration des tables à nettoyer, de la stratégie et du moment du nettoyage. Cela fonctionne, mais cette mécanique existe uniquement à cause de la base partagée.
Ce sont toutes des solutions de contournement à la même contrainte architecturale : un serveur de base de données, plusieurs tests.
SQLite en mémoire : une base de données par test
SQLite ouvre une base de données à partir d’un chemin de fichier. Passez plutôt :memory: pour obtenir une base en mémoire qui existe seulement pendant la durée de la connexion. Elle est rapide, sans entrées-sorties disque, et complètement isolée. Rien d’autre ne peut la voir, et fermer la connexion libère tout.
Exemple de configuration de base
Notre fonction de préparation des tests :
pub fn setup() -> sqlight.Connection {
let assert Ok(conn) = sqlight.open(":memory:")
let assert Ok(_) = sqlight.exec("PRAGMA foreign_keys=ON;", conn)
let assert Ok(_) = sqlight.exec(schema_sql, conn)
conn
}
La variable schema_sql contient le contenu de db/schema.sql, régénéré à partir des migrations par bin/reset. Les tests utilisent donc toujours le schéma actuel sans que quelqu’un doive penser à les mettre à jour.
Chaque test appelle setup(), reçoit une nouvelle base avec le schéma complet, insère les données nécessaires et exécute ses assertions. À la fin du test, le ramasse-miettes récupère la connexion et la base disparaît. Il n’y a rien à nettoyer.
pub fn should_reject_duplicate_registration_test() {
let conn = test_db.setup()
// Insert test-specific data
insert_org(conn, test_org())
insert_listing(conn, test_listing())
// ... test logic, assertions
// conn goes out of scope, database vanishes
}
Aucune contamination croisée n’est possible, car rien n’est partagé. Les tests A et B utilisent littéralement des bases différentes. Vous pouvez les exécuter en parallèle dans des processus BEAM sans coordination.
Le coût de l’exécution du schéma
Cette approche a un coût évident : chaque test exécute tout le schéma. Chez nous, cela représente 77 instructions CREATE TABLE, CREATE INDEX et CREATE VIEW. Nous l’avons mesuré :
- Ouverture d’une base SQLite en mémoire : 15 microsecondes.
- Exécution de 77 instructions de schéma : 1 100 microsecondes, soit 1,1 ms.
À 1,1 ms par test pour environ 500 tests qui ont besoin d’une base, cela donne environ 550 ms d’exécution du schéma. Ce n’est pas terrible, mais pas gratuit non plus. Le coût augmente linéairement avec le nombre de tests et la taille du schéma.
L’API de sauvegarde de SQLite : cloner les pages
SQLite possède une API de sauvegarde conçue pour copier des bases entre des connexions. Elle travaille au niveau des pages : elle copie les pages brutes de la base d’une connexion à l’autre sans analyser ou exécuter de nouveau le SQL.
Implémentation du clonage du modèle
L’idée : construire le schéma une seule fois dans une base modèle, puis la cloner pour chaque test.
Once at startup: open(":memory:") → execute full schema → template
Per test: open(":memory:") → backup_copy(template) → ready
L’API de sauvegarde demande trois appels : backup_init pour préparer la copie, backup_step avec -1 pour copier toutes les pages d’un coup, puis backup_finish pour libérer les ressources.
Notre pilote SQLite Gleam, sqlight, n’expose pas cette API. Il repose toutefois sur esqlite, une enveloppe NIF Erlang de la bibliothèque C de SQLite, qui expose toute l’API de sauvegarde. Puisque Gleam se compile en Erlang et fonctionne sur BEAM, son interface de fonctions étrangères (FFI) permet d’appeler directement les bibliothèques Erlang. Nous avons écrit un module Erlang de 12 lignes qui prend deux connexions sqlight, en réalité des enregistrements esqlite, et effectue le clonage :
-module(test_db_ffi).
-export([clone_db/1]).
clone_db(Template) ->
{ok, Dest} = esqlite3:open(":memory:"),
{ok, Backup} = esqlite3:backup_init(Dest, "main", Template, "main"),
'$done' = esqlite3:backup_step(Backup, -1),
ok = esqlite3:backup_finish(Backup),
{ok, Dest}.
Côté Gleam, nous déclarons la liaison FFI et l’appelons comme une fonction ordinaire :
@external(erlang, "test_db_ffi", "clone_db")
fn clone_db(
template: sqlight.Connection,
) -> Result(sqlight.Connection, Nil)
C’est un avantage de l’écosystème BEAM. Lorsque le pilote de haut niveau n’expose pas ce qu’il vous faut, la bibliothèque de bas niveau le fait presque toujours, et il est très simple de franchir la frontière FFI.
Le modèle est créé une seule fois par exécution de la suite de tests et mis en cache dans persistent_term de BEAM, un stockage global immuable optimisé pour les accès surtout en lecture :
fn template_db() -> sqlight.Connection {
case get_cached("test_template_db") {
Ok(conn) -> conn
Error(Nil) -> {
let assert Ok(conn) = sqlight.open(":memory:")
let assert Ok(_) = sqlight.exec("PRAGMA foreign_keys=ON;", conn)
let assert Ok(_) = sqlight.exec(schema_sql, conn)
cache("test_template_db", conn)
conn
}
}
}
pub fn setup() -> sqlight.Connection {
let assert Ok(conn) = clone_db(template_db())
let assert Ok(_) = sqlight.exec("PRAGMA foreign_keys=ON;", conn)
conn
}
Le PRAGMA foreign_keys=ON est exécuté après le clonage, car les paramètres PRAGMA de SQLite sont propres à la connexion et ne sont pas stockés dans le fichier de base de données. Une seule instruction, au coût négligeable.
Les chiffres
Au passage à la méthode de clonage, nous avions 594 tests et 77 objets de schéma. Nous sommes encore au début de la réécriture et approchons déjà les 1 000 tests. D’ici le lancement, nous prévoyons près du double d’objets de schéma et environ 5 000 tests.
Voici les mesures à 594 tests :
| Approche | Durée des tests serveur |
|---|---|
| Exécuter le schéma complet par test | 3,2 s |
| Cloner le modèle par l’API de sauvegarde | 2,6 s |
Le coût d’exécution du schéma est passé d’environ 550 ms à presque zéro. Les 2,6 secondes restantes correspondent à la logique réelle des tests, soit l’insertion de données, l’exécution de la logique métier et les assertions, ainsi qu’au démarrage de la machine virtuelle BEAM.
Les économies deviennent plus importantes à grande échelle. Une configuration Postgres traditionnelle viderait les tables entre les tests avec un TRUNCATE TABLE sur chaque table, dans un ordre respectant les clés étrangères, par un socket vers le serveur. Avec environ 100 tables et 5 000 tests, une estimation prudente pour notre réécriture complète, qui pourrait réalistement compter deux à trois fois plus de tests, cela représente 500 000 instructions de vidage envoyées au serveur. Même à moins d’une milliseconde par instruction, le total s’accumule.
Avec la sauvegarde, le clonage coûte environ 25 microsecondes par test, peu importe la taille du schéma. Pour 5 000 tests, cela donne 0,125 seconde au total. Aucun ordre de vidage à respecter, aucun aller-retour par socket.
Il s’agit d’une vraie suite de tests applicatifs couvrant les opérations de panier, le traitement des paiements, la validation des inscriptions, les frais d’association, les renonciations, la génération de tournois à la ronde, la planification des horaires, les pointages et plus encore. Ce ne sont pas des tests triviaux.
Pourquoi cela fonctionne si bien
L’API de sauvegarde est une bonne optimisation, mais le principal gain vient de SQLite dans le processus, qui élimine complètement le problème de la base partagée.
Avec Postgres, le processus de test communique avec un serveur de base de données par un socket. Chaque test qui écrit des données le fait au même endroit. L’architecture exige donc des stratégies d’isolation.
Avec SQLite dans le processus, la base vit dans la mémoire de ce processus. En créer une autre est une allocation mémoire, pas une connexion réseau. Aucun serveur avec lequel se coordonner, aucun pool de connexions à gérer. L’isolation découle gratuitement de l’architecture.
Cela signifie aussi :
- Aucune dépendance à l’ordre des tests. Chaque test est entièrement autonome.
- Un parallélisme sûr. Chaque processus BEAM reçoit sa propre base sans coordination.
- Aucun code de nettoyage : ni
teardown, niafter_each, nidatabase_cleaner. - Aucun test instable causé par un état partagé qui fuit entre les exécutions.
Les compromis
Il reste des compromis. Vous ne testez pas avec le même moteur de base de données qu’en production, sauf si vous utilisez aussi SQLite en production, comme nous. Des tests SQLite pour une production Postgres manqueraient les comportements propres à Postgres : types personnalisés, verrous consultatifs, opérateurs jsonb, etc.
Dans notre configuration, chaque test construit aussi ses données à partir de zéro. En pratique, c’est un avantage, car il déclare explicitement ses dépendances, mais cela demande plus de code de préparation par test qu’un jeu de données partagé. C’est toutefois un choix, pas une limite de la technique. Vous pourriez tout aussi bien insérer des données de départ dans la base modèle avant de la mettre en cache. Chaque clone commencerait avec ces données. Si votre suite profite d’un ensemble bien défini d’utilisateurs, d’organisations ou d’autres données de référence, préparer le modèle ainsi réduit simplement la préparation par test tout en conservant une isolation complète.
Au-delà de BEAM
Nos exemples utilisent Gleam et Erlang, mais l’API de sauvegarde est une fonction de SQLite au niveau C. La plupart des langages l’exposent :
Exemples en Python, Node.js et Rust
# Python 3.7+ has it built in
source = sqlite3.connect(":memory:")
source.executescript(schema_sql)
dest = sqlite3.connect(":memory:")
source.backup(dest)
// Node.js better-sqlite3
const template = new Database(":memory:");
template.exec(schemaSQL);
const dest = template.backup(":memory:");
// Rust rusqlite
let template = Connection::open_in_memory()?;
template.execute_batch(schema_sql)?;
let mut dest = Connection::open_in_memory()?;
let backup = backup::Backup::new(&template, &mut dest)?;
backup.run_to_completion(5, Duration::ZERO, None)?;
Le modèle est le même dans chaque langage : construire un modèle une fois, puis le cloner par test avec l’API de sauvegarde. Notre détour FFI par esqlite est nécessaire seulement parce que notre pilote Gleam ne l’expose pas encore.
L’ensemble de l’infrastructure
Voici toute notre infrastructure de test :
test_db.setup()clone une base modèle en cache avec l’API de sauvegarde SQLite.test_db.tz_db()retourne une base de fuseaux horaires en cache, chargée une seule fois plutôt que par test.persistent_termest le cache global BEAM du modèle de base et des données de fuseaux horaires.- Douze lignes de FFI Erlang enveloppent l’API de sauvegarde SQLite.
C’est toute l’infrastructure de test. Aucune extension de cadriciel de test, aucune gem de nettoyage de base, aucune stratégie de vidage. SQLite et 12 lignes d’Erlang.
Cet article est le sixième de la série sur les fondations de Curling IO. À suivre : les tests parallèles sans effort supplémentaire.