Tests
Écrire et lancer les tests d'un paquet Fraza avec node:test, et les tests d'intégration PostgreSQL sur un schéma jetable.
Chaque paquet de l'écosystème se teste avec node:test, le lanceur intégré à Node : aucune dépendance de test à installer, à suivre ni à auditer. Cette page s'adresse à qui modifie le core ou un module.
Lancer les tests
npm testLe script lance node --test test/. Chaque fichier *.test.js du dossier tourne dans son propre processus.
Les tests du core importent les fichiers de src/ directement. Certains de ces fichiers importent pourtant un autre sous-chemin du core par son nom, comme @fraza/core/shared/logger, que Node résout vers dist/ : lancer npm run build une fois avant npm test.
Où vivent les tests
Dans test/, à la racine du dépôt, jamais dans src/. Le champ files ne publie pas ce dossier, et le preset tsup écarte en plus tout fichier *.test.js : un test ne part jamais dans le paquet installé par un site.
Un test vise une fonction pure en priorité : signature d'une requête, calcul d'un montant, lecture d'une plage de versions. Il se passe de base, de réseau et de navigateur, et tourne en quelques millisecondes.
// test/semver.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { satisfies } from '../src/shared/lib/semver.js';
test('satisfies reads a bounded range', () => {
assert.equal(satisfies('0.1.14', '>=0.1.14 <0.2.0'), true);
assert.equal(satisfies('0.2.0', '>=0.1.14 <0.2.0'), false);
});Le nom d'un test et ses commentaires sont du code : ils s'écrivent en anglais. Voir Langue.
Tests d'intégration PostgreSQL
Un test qui a besoin d'une vraie base passe par describeWithDatabase, exporté sous @fraza/core/tools/test. Chaque suite reçoit un schéma jetable sur la base de FRAZA_TEST_DATABASE_URL, et le pool du core est dirigé vers ce schéma : le code testé appelle query() comme en production, et ses tables atterrissent dans le schéma jetable plutôt que dans public. Le schéma est supprimé à la fin de la suite, qu'elle réussisse ou non.
// test/invoices.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { query } from '@fraza/core/database';
import { describeWithDatabase } from '@fraza/core/tools/test';
describeWithDatabase('invoices', (db) => {
test('the table starts empty', async () => {
await query('CREATE TABLE invoices (id serial PRIMARY KEY)');
const { rows } = await db.query('SELECT count(*)::int AS n FROM invoices');
assert.equal(rows[0].n, 0);
});
});db se remplit avant le premier test : lire ses champs dans les tests, pas pendant leur déclaration.
| Export | Rôle |
|---|---|
describeWithDatabase(name, fn, { prefix }) | Déclare une suite sur un schéma jetable, ou la saute sans la variable. fn reçoit db, avec schema, url et query(sql, params). |
createTestSchema({ prefix }) | Crée le schéma et dirige le pool du core vers lui. Retourne schema, url, query et drop(), à appeler soi-même. |
hasTestDatabase() | true quand FRAZA_TEST_DATABASE_URL est définie. |
testDatabaseUrl() | L'URL de la base de test, ou null. |
withSearchPath(url, schema) | Ajoute à une URL de connexion le paramètre options qui ouvre chaque connexion dans schema. |
testSchemaName(prefix) | Un nom de schéma jetable neuf, daté de la minute en cours. |
parseTestSchemaName(name) | Relit un tel nom : { prefix, createdAt, pid }, createdAt à null pour un nom sans date, ou null pour un autre nom. |
migrateCore({ only }) | Crée dans le schéma jetable les tables du core qu'un module écrit, par les migrations du core. only liste des groupes ('cron', 'outbox', 'rate-limit', 'notifications', 'dashboard-layouts') ou des identifiants de migration, tous les groupes par défaut. |
migrationContext(db) | Le ctx que le runner passe à up(ctx) : sql, tableExists, columnExists, indexExists, constraintExists, sur db.query. |
registerNextResolveHook() | Permet à Node de charger next/server, next/headers, next/navigation et next/cache, que le core atteint hors d'un build Next. |
signStripePayload(payload, secret, timestamp?) | En-tête Stripe-Signature que Stripe enverrait pour ce corps. |
deliverStripeEvent(event, { secret, signature, timestamp }) | Signe l'événement et le remet au handler du webhook du core. Rend ce que la route répondrait. |
db.query passe par un pool à part, hors du code testé : il sert à préparer des données ou à vérifier un résultat sans dépendre de la fonction qu'on teste. Il lit les types comme le pool du core : une colonne DATE revient en chaîne YYYY-MM-DD, sans ::text. prefix, fraza_test par défaut, commence par une minuscule, ne prend que des minuscules, des chiffres et des soulignés, et compte 30 caractères au plus.
Les tables du core. Un module qui teste ses tâches planifiées ou ses courriels a besoin de fraza_cron_runs ou de fraza_email_outbox. migrateCore() les crée par les migrations du core, sans celles qui portent sur les tables d'authentification d'un vrai projet. Une clé étrangère vers fraza_auth_users se satisfait d'une table d'une colonne, CREATE TABLE fraza_auth_users (id TEXT PRIMARY KEY).
import { describeWithDatabase, migrateCore, migrationContext } from '@fraza/core/tools/test';
import { migrations } from '../src/migrations.js';
describeWithDatabase('migration 002', (db) => {
test('replays', async () => {
await migrateCore({ only: ['cron'] });
const ctx = migrationContext(db);
const migration = migrations.find((m) => m.id === '002-drop-job-lock');
await migration.up(ctx);
await migration.up(ctx);
assert.equal(await ctx.tableExists('fraza_booking_job_lock'), false);
});
});migrationContext(db) passe par le pool de db.query, hors de toute transaction : une migration y tourne comme le runner la lancerait, sans écrire dans le journal fraza_migrations, donc autant de fois que le test le demande.
registerNextResolveHook() s'appelle avant le premier import qui atteint next/* : le bundler de Next trouve next/server.js tout seul, le chargeur ESM de Node demande l'extension. Il demande Node 22.15 ou plus.
deliverStripeEvent sert à tester les abonnés d'un module sans Stripe : les fonctions passées à registerStripeEventHandler tournent comme en production, et le résultat vaut { received: true, handled: true }, ou une erreur dont getStatusCode de @fraza/core/api donne le statut. Il lit STRIPE_WEBHOOK_SECRET, que le test pose avant l'appel. signature: null envoie la livraison sans en-tête.
La variable FRAZA_TEST_DATABASE_URL
Sans elle, la suite est annoncée comme sautée, avec sa raison, et npm test reste vert : les tests unitaires tournent partout, y compris sans base.
Elle se pose dans le terminal qui lance les tests, d'abord en bash :
export FRAZA_TEST_DATABASE_URL="postgres://user:password@localhost:5432/fraza_test"
npm testpuis en PowerShell :
$env:FRAZA_TEST_DATABASE_URL = "postgres://user:password@localhost:5432/fraza_test"
npm testC'est une variable distincte de FRAZA_DATABASE_URL, et l'aide ne lit aucun fichier .env, pour qu'une exécution de tests n'atteigne jamais la base d'un site par erreur. Le compte doit pouvoir créer un schéma sur cette base.
Un serveur au certificat auto-signé s'accepte en ajoutant sslmode=no-verify à l'URL, comme pour FRAZA_DATABASE_URL : TLS est strict par défaut, dans les tests aussi. Voir Politique TLS.
Les schémas laissés par un processus interrompu
Un Ctrl+C ou un SIGTERM supprime d'abord les schémas encore ouverts du processus, puis laisse le signal terminer le processus comme il l'aurait fait sans l'aide. Une suppression bloquée par un verrou abandonne au bout de 5 secondes.
Un processus tué par SIGKILL, ou qui n'a pas pu finir sa suppression, laisse son schéma derrière lui. Le nom du schéma porte donc sa date de création, puisque PostgreSQL n'en garde aucune : <prefix>_<aaaammjjhhmm>_<pid>_<hex8>, la minute en UTC, le processus, puis quatre octets aléatoires, par exemple fraza_test_202610201405_4242_9f3c01ab.
Avant de créer son schéma, createTestSchema supprime ceux du même préfixe créés il y a plus de FRAZA_TEST_SCHEMA_TTL_HOURS heures, 6 par défaut. Un échec de ce ménage n'arrête pas la suite : un autre processus peut supprimer le même schéma au même moment, et ce qui reste part au passage suivant. 0 désactive le ménage. Le ménage ne touche ni un autre préfixe, ni un schéma au nom sans date laissé par une version précédente du core, dont l'âge est inconnu.
Pour faire le ménage à la main, sur tous les préfixes qui commencent par fraza_test :
npx fraza-module test-clean --dry-run
npx fraza-module test-cleanVoir fraza-module.
Notes
Un fichier de test, un processus. node --test isole chaque fichier : une suite qui redirige FRAZA_DATABASE_URL vers son schéma ne touche pas les autres fichiers. Deux suites d'un même fichier tournent l'une après l'autre, chacune sur son schéma.
Un test du core importe ses sources par chemin relatif, jamais par @fraza/core/.... Le second chemin mène à dist/, une autre copie du module : un registre tenu par une variable de module y serait un second singleton, distinct de celui que le code testé utilise. Le pool de la base fait exception, il est rangé sur globalThis et les deux copies le partagent : un fichier de src/ qui importe @fraza/core/database interroge bien le schéma jetable.