Outils de test
@fraza/core/tools/test, schémas PostgreSQL jetables, projet sous test, serveur de test, bucket S3 en mémoire et livraison d'événements Stripe.
@fraza/core/tools/test donne aux tests d'un site ou d'un module une vraie base PostgreSQL sans jamais toucher celle de développement : chaque suite travaille dans un schéma jetable, supprimé à la fin. Les tests se lancent avec node:test, le lanceur intégré à Node, sans dépendance à installer. Pour un parcours pas à pas, voir Tester son projet.
Import
import { describeWithProject, testUser, apiCall, startTestServer, startFakeS3 } from '@fraza/core/tools/test';Outil de test, serveur seulement. Rien dans le code d'exécution du site ne l'importe, et il ne part jamais dans un bundle.
Le script test du package.json charge d'abord @fraza/core/tools/test/register :
"scripts": {
"test": "node --import @fraza/core/tools/test/register --test test/"
}tools/test/register enregistre le hook qui permet à Node de charger next/server, next/link et les autres entrées de Next, que le code d'un site ou d'un module atteint vite. Il lit aussi FRAZA_TEST_DATABASE_URL dans les fichiers d'environnement du projet quand le terminal ne la pose pas. Voir Variables d'environnement.
Les tests vivent dans test/, à la racine du projet ou du paquet. Un paquet ne les publie pas : son champ files ne liste pas ce dossier, et le preset tsup écarte en plus tout fichier *.test.js.
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', 'events') ou des identifiants de migration, tous les groupes par défaut. |
standInRoles() | Crée dans le schéma jetable les deux tables de rôles que lit getUserPermissions(), sans clé étrangère. Tout compte y est un compte client. À appeler dans le before d'une suite dont le code public passe par clientSessionOf(). |
grantTeamAccess(userId) | Fait d'un compte un compte d'équipe : un rôle qui porte admin.access. Pour vérifier qu'un module traite l'équipe en visiteur sur le site. Sur un schéma de createTestProject, passer plutôt par testUser({ roles: ['admin'] }). |
declareModuleEvents(manifest) | Déclare les événements du manifest.events d'un module, comme le démarrage. Hors production, emit() refuse un événement que personne ne déclare : un test qui fait tourner du code qui annonce un événement l'appelle d'abord. Voir Événements. |
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, next/cache, next/link, next/image et next/dynamic, que le core et les modules atteignent hors d'un build Next. node --import @fraza/core/tools/test/register l'appelle avant le premier fichier de test. |
createTestProject({ prefix, modules }) | Un schéma jetable initialisé comme par fraza-db init : tables du core et des modules activés, migrations, rôles et permissions. Voir Tester un projet. |
describeWithProject(name, fn, options) | Comme describeWithDatabase, sur un schéma de createTestProject. |
testUser({ roles, name, email, password }) | Un compte vérifié avec ses rôles (admin, user ou un identifiant de rôle) et une session ouverte. email vaut par défaut une adresse unique en @exemple.test, name vaut Compte de test. Rend { id, email, name, token, cookieName, cookie }. |
apiCall(user, method, path, options) | Un appel HTTP au site comme le ferait un navigateur : cookie de session, en-tête Origin sur une écriture. user vaut null pour un appel anonyme. Options : body, envoyé en JSON, form, un FormData envoyé en multipart, baseUrl, NEXT_PUBLIC_URL ou http://localhost:3000 par défaut, origin: false pour retirer l'en-tête Origin, et headers. Les redirections ne sont pas suivies. Rend { status, headers, data, text }, data à null quand le corps n'est pas du JSON. |
loginAs(context, user, baseUrl) | Ouvre la session de user dans un contexte Playwright, sans passer par le formulaire. |
startTestServer(options) | Lance le site sous next dev, sur un port libre et un schéma de projet jetable. Options : port, premier port essayé, 3100 par défaut, prefix, env, des variables de plus pour le serveur, log et timeoutMs. Rend { url, port, db, stop }. Voir Serveur de test. |
projectModuleFlags(cwd) | Les FRAZA_MODULE_* des fichiers d'environnement du projet, et rien d'autre. |
projectTestDatabaseUrl(cwd) | FRAZA_TEST_DATABASE_URL des fichiers d'environnement du projet, ou null. Appelée par tools/test/register. |
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. |
startFakeS3() | Un bucket en mémoire derrière un serveur HTTP local, vers lequel pointent les FRAZA_STORAGE_*. Rend { objects, bodies, refuse, state, put, close }. Voir Un bucket en mémoire. |
Trois constantes accompagnent ces fonctions : TEST_DATABASE_URL_KEY, le nom de la variable, CORE_TABLE_GROUPS, les groupes de migrateCore avec leurs migrations, et TEST_SERVER_DIST_DIR, le dossier de build du serveur de test.
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. Depuis Node 22.15, le hook tourne dans le même fil (module.registerHooks). De Node 20.12, la version minimale du core, à Node 22.14, il passe par module.register(), qui ne vaut que pour les imports qui suivent : appeler la fonction avant d'importer le code testé, ou passer par node --import @fraza/core/tools/test/register. Un second appel ne fait rien.
deliverStripeEvent sert à tester les abonnés d'un module sans Stripe : les fonctions passées à registerStripeEventHandler tournent comme en production. Le résultat est ce que la route répondrait, jamais une exception : { received: true, handled: true }, handled: false quand aucun abonné ne traite ce type d'événement, ou une réponse d'erreur dont getStatusCode de @fraza/core/api donne le statut, 400 pour une signature absente ou fausse, 500 quand un abonné lève une erreur. Le handler lit STRIPE_WEBHOOK_SECRET, que le test pose avant l'appel : sans elle, la route répond 404, même avec secret. signature remplace l'en-tête calculé, null envoie la livraison sans en-tête.
Tester un projet
Un site se teste avec les mêmes outils qu'un paquet. Les tests vivent dans test/ à la racine du projet, et npm test les lance depuis cette racine : les modules du projet sont trouvés dans son package.json, comme le fait fraza-db init.
"scripts": {
"test": "node --import @fraza/core/tools/test/register --test test/"
}createTestProject et describeWithProject créent un schéma jetable et l'initialisent comme une vraie base : tables du core, tables des modules, migrations, rôles. Le code testé appelle query() comme en production.
Quels modules. Par défaut, ceux que le projet active : les FRAZA_MODULE_* sont lus dans ses fichiers d'environnement, et un interrupteur déjà posé dans le processus l'emporte. modules: ['@fraza/module-posts'] active exactement les paquets nommés, modules: [] aucun.
Quelle base. node --import @fraza/core/tools/test/register lit FRAZA_TEST_DATABASE_URL dans les mêmes fichiers quand le terminal ne la pose pas : celle que .env.example propose suffit. C'est la seule URL lue dans un fichier. FRAZA_DATABASE_URL, celle de la base de développement, n'atteint jamais les tests.
// test/posts.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { apiCall, describeWithProject, testUser } from '@fraza/core/tools/test';
describeWithProject('posts', (db) => {
test('an admin account exists with its role', async () => {
const admin = await testUser({ roles: ['admin'] });
const { rows } = await db.query('SELECT count(*)::int AS n FROM fraza_auth_user_roles WHERE user_id = $1', [admin.id]);
assert.equal(rows[0].n, 1);
});
});testUser crée le compte directement en base et lui ouvre une session : le formulaire de connexion n'est pas en jeu, sauf si le test passe un password pour l'éprouver lui-même.
Serveur de test
Pour appeler les routes et les pages du site, startTestServer lance next dev sur un port libre, à partir de 3100, contre un schéma de projet jetable. stop() arrête le serveur et supprime le schéma : il n'y a rien à nettoyer, et la base de développement n'est jamais touchée.
Le serveur charge les modules du projet, ceux des manifestes de app/.fraza/ que génèrent npm install et npm run dev, et le schéma reçoit les mêmes. startTestServer ne lance fraza-modules sync que si ces manifestes manquent : les régénérer sous un npm run dev ouvert le dérangerait.
import { after, before, test } from 'node:test';
import assert from 'node:assert/strict';
import { apiCall, startTestServer, testUser } from '@fraza/core/tools/test';
let server;
before(async () => { server = await startTestServer(); });
after(() => server.stop());
test('an anonymous write is refused', async () => {
const { status } = await apiCall(null, 'POST', '/za/api/notifications/read-all', { baseUrl: server.url });
assert.equal(status, 401);
});
test('an admin lists the accounts', async () => {
const admin = await testUser({ roles: ['admin'] });
const { status } = await apiCall(admin, 'GET', '/za/api/users', { baseUrl: server.url });
assert.equal(status, 200);
});Le serveur compile dans .next/test, à part du .next/dev de npm run dev : Next.js refuse deux next dev sur le même dossier. Le next.config du projet doit donc lire FRAZA_NEXT_DIST_DIR. npx fraza-dev init ajoute la ligne, et @fraza/start le lance à la création du projet. fraza-doctor signale un next.config qui ne la lit pas :
const nextConfig = {
distDir: process.env.FRAZA_NEXT_DIST_DIR || '.next',
};Le cookie de session du serveur s'appelle fraza_session_test, posé aussi dans le processus des tests : les cookies de testUser sont ceux que le serveur lit. NEXT_PUBLIC_URL y vaut l'adresse du serveur, pour que la vérification CSRF accepte les écritures de apiCall.
Le serveur est prêt quand /za/api/health répond. Cette première réponse attend la compilation : compter une à deux minutes. Passé timeoutMs, 3 minutes par défaut, startTestServer échoue avec les dernières lignes de la sortie du serveur. log: true affiche la sortie du serveur, utile quand il ne démarre pas.
Au navigateur
Pour un test Playwright, loginAs ouvre la session d'un compte dans le contexte du navigateur :
const admin = await testUser({ roles: ['admin'] });
await loginAs(page.context(), admin, server.url);
await page.goto(`${server.url}/admin`);Les tests au navigateur sont lents et plus fragiles que les autres : les garder pour les parcours critiques, comme la connexion, un paiement ou un envoi de fichier. Le core ne dépend pas de Playwright, que le projet installe lui-même.
Un bucket en mémoire
startFakeS3() sert aux tests de stockage d'un module : téléverser une pièce jointe, vider une file de suppression, lister un dossier. Il démarre sur 127.0.0.1, sur un port libre, un serveur qui répond à ce que le core demande à S3 : PUT d'un objet, DeleteObjects et ListObjectsV2 page par page. Il pose ensuite FRAZA_STORAGE_PROVIDER, FRAZA_STORAGE_ENDPOINT, FRAZA_STORAGE_BUCKET, FRAZA_STORAGE_ACCESS_KEY et FRAZA_STORAGE_SECRET_KEY dans process.env : le code testé appelle @fraza/core/storage comme en production. Les signatures ne sont pas vérifiées, les tests de stockage du core s'en chargent.
import { after, before, test } from 'node:test';
import assert from 'node:assert/strict';
import { startFakeS3 } from '@fraza/core/tools/test';
let s3;
before(async () => { s3 = await startFakeS3(); });
after(() => s3.close());
test('a refused key stays queued', async () => {
s3.put('acme/1/file.png');
s3.refuse.add('acme/1/file.png');
// … delete item 1, drain the queue
assert.equal(s3.objects.has('acme/1/file.png'), true);
});| Champ | Rôle |
|---|---|
objects | Set des clés présentes dans le bucket. |
bodies | Map des octets reçus par clé, Buffer écrit par le dernier PUT : pour vérifier ce que le code testé a envoyé. |
refuse | Set des clés que DeleteObjects refuse, avec le code AccessDenied. |
state.down | true : chaque requête reçoit un 503, comme un stockage en panne. |
state.pageSize | Taille maximale d'une page de listage, 1000 par défaut. La réduire teste la pagination sans créer mille objets. |
state.modified | Map de la date de chaque clé, renvoyée comme LastModified au listage. Posée à chaque PUT. |
put(key, date?) | Ajoute une clé sans requête, datée de date quand elle est donnée. |
close() | Ferme les connexions et arrête le serveur. Les FRAZA_STORAGE_* restent posées. |
Les variables sont posées dans process.env du processus de test : un fichier qui appelle startFakeS3() ne doit pas compter sur un autre stockage.
Variables d'environnement
La variable FRAZA_TEST_DATABASE_URL
Sans elle, les suites qui demandent une base sont annoncées comme sautées, avec leur raison, et npm test reste vert : les tests unitaires tournent partout, y compris sans base.
tools/test/register la lit dans les fichiers d'environnement du projet, .env.local compris, quand le terminal ne la pose pas : celle que propose le .env.example d'un projet créé par @fraza/start suffit. Avec NODE_ENV=test, les fichiers lus sont ceux du mode test, .env.test.local, .env.test et .env, sans .env.local. Seules deux choses viennent de ces fichiers : cette URL et les interrupteurs FRAZA_MODULE_*. FRAZA_DATABASE_URL, celle de la base de développement, n'atteint jamais les tests. Les fonctions de schéma, elles, ne lisent que process.env : sans tools/test/register, la variable se pose dans le terminal.
Pour la poser dans le terminal, qui l'emporte sur les fichiers, 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 testLe 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.
FRAZA_TEST_SCHEMA_TTL_HOURS règle le ménage des schémas laissés, décrit ci-dessous.
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 ce ménage. 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.