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.

Construit avec Fougere — ce site tourne sur le framework qu'il documente.