Tests
@fougere/testing repose sur deux idées, et aucune n'est une technique de test.
Une partie d'une suite n'a pas de raison d'être écrite. Judge.row a une liste
fermée de refus — champ inconnu, champ manquant, lecture seule, immuable, la forme, un
codec nommé — et les quatre axes disent qui écrit quoi et quand. Les cas se calculent donc
depuis l'entité. Un test tapé à la main devient faux le jour où l'entité bouge et personne
ne l'apprend ; un test fabriqué bouge avec elle.
Le niveau de réalité d'un test ne lui appartient pas. C'est le geste que Fougere
applique déjà à la topologie : remotes: est extérieur au frond, et le même code tourne
en process ou derrière JSON-RPC. Ici, ce qui est réel et l'endroit où ça tourne sont
énoncés hors du fichier de test, qui ne dit que ce qu'il attend.
pnpm add -D @fougere/testing
// vitest.config.ts — le fichier entier
import { fougereTest } from '@fougere/testing/vitest';
export default fougereTest();
La position du fichier dit son sujet
fronds/catalog/tests/pricing.test.ts catalog est réel, ses voisins non
tests/contracts.test.ts tous les fronds sont réels, ensemble
Un dossier est une déclaration — la lecture que le scan fait déjà sur entities/ et
handlers/. Donc testApp() ne prend aucun argument :
await using app = await testApp();
expect(app.fronds.map((frond) => frond.name)).toEqual(['catalog']);
Le projet commence au premier ancêtre portant un fougere.config.ts ou un fronds/, et
le sujet est le segment qui suit fronds/. Un sous-dossier sous tests/ ne porte rien, et
le titre d'un test non plus : un nom est de la prose, et de la prose qui décide du câblage
d'une application est le runtime caché que la doctrine refuse.
Les lignes vivent dans SQLite :memory: — un vrai moteur, donc le DDL tourne, les
contraintes CHECK existent et les transactions sont réelles, et chaque appel a sa propre
base. Énoncer root ou fronds soi-même gagne sur la position, la même précédence qu'une
clé de config a sur une convention partout ailleurs.
Les cas viennent de l'entité
// tests/contracts.test.ts — le fichier entier
import { testApp, checkAll } from '@fougere/testing';
checkAll(await testApp());
Il ne nomme aucune entité : la liste vient du scan. Une liste d'imports est une seconde
copie de ce que le projet déclare déjà, et elle périme le jour où une entité est ajoutée —
en silence, parce qu'un test absent ne peut pas échouer. Une entité à la fois s'écrit
checkContract(app, Product).
✓ Product — le contrat qu'elle déclare
✓ un corps valide ✓ sku plus court que le min ✓ cents sous le minimum
✓ une clé hors contrat ✓ sku plus long que le max ✓ cents au-dessus du maximum
✓ pas un objet du tout ✓ name absent ✓ status hors de l'ensemble
✓ sku absent ✓ name du mauvais type ✓ createdAt fourni sur un update
Aucun cas n'est écrit. Ils sont énumérés depuis cinq lectures qui existent déjà —
Visibility.input pour ce qui a le droit d'entrer, Judge.onAbsent pour ce qui est requis,
Lifecycle.of(field).immutable pour le mode patch, la shape pour les bornes, et la clé
hors contrat. Change un min: dans l'entité, les cas suivent.
Un cas porte le chemin du champ fautif, jamais le message : on sait quel champ doit être refusé parce que c'est nous qui l'avons cassé, et nommer le message ferait de la liste un second juge à tenir à la main.
checkOutput ne coûte rien à énoncer — Visibility.output y répond déjà, donc un champ que la
frontière ferme n'a rien à faire dans une réponse :
password: text({ boundary: 'writeOnly' }) // entre, ne ressort jamais
Ce que ça ne prouve pas
Un test fabriqué depuis Post ne peut jamais dire que Post a tort. Il prouve que les
réalisations sont d'accord. Ce qui doit casser quand la déclaration bouge est ailleurs
et déjà construit : fougere migrate. Et l'intention métier —
publier met le statut à published — s'écrit toujours à la main.
Fabriquer un corps valide
sampleInput(Product) // → { sku: 'gDXv8gBIU', name: '…', cents: 750868 }
sampleInput(Product, { sku: 'ABC-01' }) // ce que tu imposes, tel quel
sampleInput(Product, {}, { seed: 7 }) // figé
La valeur elle-même n'est pas à nous d'inventer : une shape est du JSON Schema, donc
json-schema-faker respecte minLength, enum, format, pattern et items. Ce qui
est à nous, c'est quels champs appartiennent à un corps — Visibility.input, le seul lecteur
des axes boundary et lifecycle sur lequel la façade et le formulaire se tiennent déjà.
La graine vaut par défaut une valeur dérivée du nom de l'entité, donc deux exécutions sont
d'accord et un échec se rejoue. Une ref() est refusée par son nom plutôt que
fabriquée : elle désigne une ligne qui doit exister, et un id inventé ne pointe sur rien.
La même fonction sème une base de dev et pose le décor d'un scénario navigateur.
Les bouchons
On ne peut boucher que ce qui est déjà déclaré remplaçable — un port. « J'ai besoin d'un mock ici » devient un signal de conception plutôt qu'un réflexe.
await using app = await testApp({ stub: [Pricing] });
app.stub(Pricing).total.mockReturnValue(4242);
await run({ entity: 'product', op: 'quote' }, invocation);
expect(app.stub(Pricing).total).toHaveBeenCalledWith(1000);
Le bouchon est proposé, pas écrit : ses méthodes sont lues sur le prototype du port, donc il porte exactement ce que le port porte et gagne une méthode le jour où le port en gagne une. Ce qu'il renvoie est à toi : le type de retour d'un service est un type TypeScript nu, effacé à l'exécution, sans champs déclarés d'où partir. C'est la ligne sur laquelle tout le paquet repose : ce que le vocabulaire de Fougere déclare se dérive, du code arbitraire non.
Un port sous lequel personne ne répond est refusé par son nom, pour la raison qui fait que
ports: refuse une clé qui n'a rien trouvé.
Rien ne peut se substituer à ce qu'un handler va chercher lui-même — un fetch, une lib
importée en haut du fichier, l'horloge. Fougere résout par la signature, donc il ne
remplace que ce que la signature déclare, et la réponse du modèle pour un appel extérieur
est déjà d'en faire un port.
Les portes sont d'accord
checkDoors(app, Product); // locale · REST · GraphQL · RPC
Les cinq opérations CRUD, lectures et écritures. Une écriture crée une ligne différente à chaque porte, donc ce qui est comparé est ce que l'appelant a envoyé — comparer les valeurs comparerait les horloges.
Le dépôt affirme partout que REST, GraphQL et RPC sont trois projections d'un seul contrat. C'est ce qui le vérifie, sur les lignes et sur les refus — un refus est l'endroit où les portes divergent le plus, et où chacune est le plus tentée de répondre dans ses propres mots.
L'enveloppe de chaque porte est retirée avant de comparer : elles emballent différemment
par construction, et comparer les emballages comparerait les protocoles. Le champ GraphQL
n'est pas recalculé mais demandé au schéma — une liste est le champ dont le type est le
type-liste de l'entité, un findById celui du type de l'entité qui prend un id.
Pour un invariant propre à un handler, checkDoorContract(app, Entity, cases) exécute les
mêmes cas écrits à la main sur les quatre portes. Fougere l'utilise pour verrouiller la
sémantique des entrées optionnelles : foo?: T omis arrive en undefined,
foo: T | null conserve un null explicite, et foo?: T | null distingue les deux.
Les faits
await run({ entity: 'post', op: 'publish' }, invocation);
expect(app.announced(PostPublished)).toMatchObject([{ id, title }]);
Observer ne coûte rien : Emissions.announce remet chaque fait au transporteur, donc un
test s'assied à sa place et n'ajoute aucun second dispatcheur — la seule chose que
Emissions refuse d'être. Un fait voyage sous sa clé d'enregistrement (postPublished),
et les deux orthographes sont acceptées.
Son dual n'a rien demandé — app.deliver est déjà la porte du transporteur, elle attend
tous les abonnés et rejette si l'un refuse :
await app.deliver('postPublished', { id: 'p1', title: 'Delivered', at: … });
La dérive entre fronds
Le seul endroit où le gradient ment vraiment : le code est identique en process et coupé
en deux, mais un côté peut avoir vieilli. fougere sync a écrit la copie du consommateur
il y a trois semaines, le producteur a avancé, et ça compile toujours.
const drift = driftOf(mine, theirs, 'blog');
explain(drift);
// blog.post — gone: publish
// blog.post — title: its bounds moved
// postPublished — + authorId (required) → re-sync and deploy the readers, THEN the sender
Les deux cartes viennent de rpc.discover, et diff nomme ce qui les sépare. Lu dans un
seul sens, exprès : un producteur qui sert plus que ce que le consommateur connaît n'est
pas une dérive, c'est un producteur qui a avancé sans casser personne.
Sur les faits, la ligne dit l'ordre à voix haute, parce qu'un fait est jugé strictement : un lecteur qui n'a pas été re-synchronisé refuse ce que l'émetteur annonce maintenant.
Le navigateur
Le cran que les autres ne peuvent pas atteindre : un formulaire qui n'énonce aucune règle de son côté.
formFieldsOf(Product, 'product')
<input id="sku" name="sku" type="text" required minlength="3" maxlength="12">
L'appartenance vient de Visibility.input, l'obligation de l'axe lifecycle, et les bornes de
la forme — sous les noms que le navigateur applique déjà. La page ne contient donc aucun
code de validation : le navigateur refuse un sku de deux caractères, et le même corps
envoyé directement à la porte est refusé par le juge. « Juge local = juge distant » se
regarde ici au lieu de se plaider ; tous les autres crans ne peuvent que le simuler dans
un seul process.
Playwright pilote et démarre le serveur lui-même (webServer dans
playwright.config.ts). Un exemple complet : demos/test-gradient, pnpm e2e.
Une horloge figée
created(), updated() et create: 'now' sont réalisés en un seul endroit —
applyCreate/applyUpdate — et c'est ce qui rend l'instant substituable :
const release = freezeClock('2026-01-02T03:04:05Z');
Une valeur, pas une interception : rien là-dedans n'est un canal, et un instant stable est
un fait sur l'exécution plutôt qu'un crochet sur Date.
La charge
// scripts/load.ts
import { writeFileSync } from 'node:fs';
import { testApp, loadScript } from '@fougere/testing';
const app = await testApp();
writeFileSync('load.js', loadScript(app, { door: 'http://127.0.0.1:3000/_fougere/call' }));
await app.dispose();
k6 run load.js
Le scénario généré couvre toutes les opérations que l'app sert, avec un corps valide
pour chacune, et son enveloppe JSON-RPC vient de frameCall plutôt que d'une copie qui
continuera de se prétendre du JSON-RPC le jour où le format bouge.
Ce qui reste à toi : les poids, les paliers et les seuils. Le scan sait quelles opérations
existent ; il ne sait rien du trafic qu'elles reçoivent. Une opération mise à weight: 0
devient une décision visible dans le fichier au lieu d'un oubli que personne ne voit.
Régénérer plutôt que rapiécer.