@ddd-ts-kit/testing 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.fr.md +969 -0
  2. package/README.md +356 -7
  3. package/package.json +5 -5
package/README.fr.md ADDED
@@ -0,0 +1,969 @@
1
+ [English](README.md) · **Français**
2
+ <!-- translation-of: README.md sha256:b6220dff0e77027d -->
3
+
4
+ # @ddd-ts-kit/testing
5
+
6
+ Des tests lisibles sans *mocks* : des *builders* de données de test, des scénarios étant donné / quand / alors
7
+ pour les agrégats, un harnais en mémoire pour les gestionnaires (handlers), un harnais de bout en bout
8
+ en HTTP et sur le bus d'événements, et des suites de contrat qui tiennent les *fakes* (doubles de test)
9
+ et les vrais adaptateurs au même comportement. La logique métier se teste sans aucune infrastructure,
10
+ et les mêmes *builders* servent jusqu'aux tests qui exécutent toute l'application.
11
+
12
+ ```
13
+ npm i -D @ddd-ts-kit/testing
14
+ ```
15
+
16
+ | Entrée | Contient | Importe |
17
+ |---|---|---|
18
+ | `@ddd-ts-kit/testing` | *builders*, scénarios, `createTestBuses`, `InMemoryRepository` | aucun runner, aucune bibliothèque |
19
+ | `@ddd-ts-kit/testing/faker` | des données générées pour les *builders* | `@faker-js/faker` (*peer* optionnel) |
20
+ | `@ddd-ts-kit/testing/e2e` | client HTTP, `eventually`, `collectEvents` | Node seulement : aucun framework HTTP, aucun conteneur |
21
+ | `@ddd-ts-kit/testing/contracts` | les suites de contrat des ports du noyau, `ContractSuite` pour en écrire une autre | le runner que vous passez |
22
+ | `@ddd-ts-kit/testing/vitest` | `decorators()`, le *plugin* Vite qui permet à Vitest d'exécuter les décorateurs standard, et `decoratorCoverage()` | Node seulement ; exécute le `tsc` du projet |
23
+ | `@ddd-ts-kit/testing/coverage` | `governedCoverage()`, `governedJestCoverage()`, `unaffectedContexts()` : la couverture que mesure le runner et son plancher, lus dans `ddd-ts-kit.config.json` | Node seulement |
24
+
25
+ L'entrée principale, `/contracts` et `/e2e` suivent les
26
+ [niveaux de test](../../docs/ARCHITECTURE.fr.md#7-niveaux-de-test) ; les *builders* servent à chaque niveau, et les
27
+ autres entrées se configurent une fois dans la configuration du runner :
28
+
29
+ ![Les quatre niveaux de test et ce que le package donne à chacun : given / when / then, givenEvents, givenProcess et expectRefusal pour une spec du domaine ; createTestBuses, InMemoryRepository et les builders du contexte d'exécution pour une spec d'application ; les suites de contrat pour une spec d'adaptateur, exécutées sur le fake et sur le vrai adaptateur ; startTestServer, le client HTTP, expectProblem, collectEvents et eventually pour une spec de bout en bout. Les mêmes builders alimentent chaque niveau, et les entrées vitest, coverage et faker se configurent une fois dans la configuration du runner](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/testing-package-overview.fr.svg)
30
+
31
+ ## Un premier test
32
+
33
+ Un agrégat, son *builder* et deux scénarios, dans un seul fichier pour la lecture (un projet donne à
34
+ l'agrégat son fichier sous `domain/` et au *builder* le sien sous `test/builders/`) :
35
+
36
+ <!-- checked sample -->
37
+ ```ts
38
+ import { AggregateRoot, ConceptError, type DomainEvent } from '@ddd-ts-kit/kernel';
39
+ import { Builder, given, resetSequences, sequence, type Defaults } from '@ddd-ts-kit/testing';
40
+ import { beforeEach, expect, it } from 'vitest';
41
+
42
+ // Le modèle sous test.
43
+ class OrderError extends ConceptError {
44
+ static alreadyPaid(orderId: string) {
45
+ return OrderError.refusal('ORDER_ALREADY_PAID', `Order ${orderId} is already paid.`, { orderId });
46
+ }
47
+ }
48
+
49
+ class OrderPaid implements DomainEvent {
50
+ readonly name = 'ordering.order.paid';
51
+
52
+ constructor(
53
+ readonly aggregateId: string,
54
+ readonly occurredAt: Date,
55
+ readonly amount: number,
56
+ ) {}
57
+ }
58
+
59
+ class Order extends AggregateRoot {
60
+ private paid = false;
61
+
62
+ private constructor(
63
+ id: string,
64
+ readonly amount: number,
65
+ ) {
66
+ super(id);
67
+ }
68
+
69
+ static place(id: string, amount: number): Order {
70
+ return new Order(id, amount);
71
+ }
72
+
73
+ get isPaid(): boolean {
74
+ return this.paid;
75
+ }
76
+
77
+ pay(at: Date): void {
78
+ if (this.paid) {
79
+ throw OrderError.alreadyPaid(this.id);
80
+ }
81
+
82
+ this.paid = true;
83
+ this.record(new OrderPaid(this.id, at, this.amount));
84
+ }
85
+ }
86
+
87
+ // Le builder : des valeurs par défaut valides une fois, puis des méthodes nommées dans le langage omniprésent.
88
+ const at = new Date('2026-03-01T10:00:00.000Z');
89
+
90
+ interface OrderProps {
91
+ readonly id: string;
92
+ readonly amount: number;
93
+ readonly paid: boolean;
94
+ }
95
+
96
+ class OrderBuilder extends Builder<OrderProps, Order> {
97
+ protected defaults(): Defaults<OrderProps> {
98
+ return { id: sequence('order'), amount: 30, paid: false };
99
+ }
100
+
101
+ protected construct(props: OrderProps): Order {
102
+ const order = Order.place(props.id, props.amount);
103
+
104
+ if (props.paid) {
105
+ order.pay(at);
106
+ }
107
+
108
+ return order;
109
+ }
110
+
111
+ paid(): this {
112
+ return this.with('paid', true);
113
+ }
114
+ }
115
+
116
+ const anOrder = (): OrderBuilder => new OrderBuilder();
117
+
118
+ // Les specs : une intention, puis les faits qu'elle a enregistrés ou le refus.
119
+ beforeEach(() => {
120
+ resetSequences();
121
+ });
122
+
123
+ it('records the payment of a placed order', () => {
124
+ given(anOrder())
125
+ .when((order) => order.pay(at))
126
+ .thenEvents([{ name: 'ordering.order.paid', amount: 30 }]);
127
+ });
128
+
129
+ it('refuses to pay an order twice', () => {
130
+ given(anOrder().paid())
131
+ .when((order) => order.pay(at))
132
+ .thenError('ORDER_ALREADY_PAID', { orderId: 'order-1' })
133
+ .thenState((order) => expect(order.isPaid).toBe(true));
134
+ });
135
+ ```
136
+
137
+ Aucune base de données, aucun conteneur, aucun *mock* : le second test se lit « étant donné une commande
138
+ payée, quand elle est payée, alors elle est refusée avec `ORDER_ALREADY_PAID`, et elle est toujours payée ».
139
+ Les sections qui suivent reprennent chaque pièce tour à tour.
140
+
141
+ ## Builders
142
+
143
+ Un *builder* déclare des valeurs par défaut valides une fois ; un test n'énonce que ce qui compte pour la
144
+ règle qu'il vérifie. Chaque étape rend un nouveau *builder*, immuable, si bien qu'un *builder* de base se
145
+ partage entre les tests.
146
+
147
+ Pour un agrégat, écrivez une classe : ses méthodes sont nommées dans le langage omniprésent (ubiquitous
148
+ language), et `construct` passe par l'API publique, si bien qu'un test ne peut jamais partir d'un état
149
+ invalide.
150
+
151
+ ```ts
152
+ // contexts/ordering/test/builders/order.builder.ts
153
+ import { Builder, sequence, type Buildable, type Defaults } from '@ddd-ts-kit/testing';
154
+
155
+ interface OrderProps {
156
+ id: string;
157
+ customer: Customer;
158
+ lines: readonly Line[];
159
+ paid: boolean;
160
+ }
161
+
162
+ class OrderBuilder extends Builder<OrderProps, Order> {
163
+ protected defaults(): Defaults<OrderProps> {
164
+ return { id: sequence('order'), customer: aCustomer(), lines: [aLine()], paid: false };
165
+ }
166
+
167
+ protected construct(props: OrderProps): Order {
168
+ const order = Order.place(props.id, props.customer, props.lines);
169
+ if (props.paid) {
170
+ order.pay();
171
+ }
172
+ return order;
173
+ }
174
+
175
+ placedBy(customer: Buildable<Customer>): this {
176
+ return this.with('customer', customer);
177
+ }
178
+
179
+ withLine(line: Buildable<Line>): this {
180
+ return this.adding('lines', line);
181
+ }
182
+
183
+ paid(): this {
184
+ return this.with('paid', true);
185
+ }
186
+ }
187
+
188
+ export const anOrder = (): OrderBuilder => new OrderBuilder();
189
+ ```
190
+
191
+ ```ts
192
+ const order = anOrder().paid().withLine(aLine().with('sku', 'sku-1')).build();
193
+ const orders = anOrder().placedBy(aCustomer().named('Ada')).buildMany(3);
194
+ ```
195
+
196
+ ![Comment un builder fabrique sa valeur : les valeurs par défaut déclarées une fois et ce que le test a énoncé sont fusionnés par props(), qui tire chaque valeur sequence, lazy et generated et construit chaque builder imbriqué ; build() remet les propriétés à construct, qui passe par l'API publique et donne un agrégat valide par construction, et buildRequest(), une méthode que le projet écrit sur son propre builder, donne, à partir des mêmes propriétés, le contenu de la requête HTTP d'une spec de bout en bout](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/testing-package-builder.fr.svg)
197
+
198
+ Pour des données simples (contenus de requête, DTO, objets-valeurs simples), la forme fonctionnelle suffit.
199
+ Elle donne les méthodes génériques `with(key, value)` et `withMany(partial)`, toutes deux vérifiées contre
200
+ les propriétés :
201
+
202
+ ```ts
203
+ import { defineBuilder, lazy, sequence } from '@ddd-ts-kit/testing';
204
+
205
+ export const aLine = defineBuilder<Line>({
206
+ defaults: { sku: sequence('sku'), quantity: 1 },
207
+ });
208
+
209
+ export const anEmail = defineBuilder<{ value: string }, Email>({
210
+ defaults: { value: sequence('email', (n) => `user${n}@example.com`) },
211
+ build: ({ value }) => Email.create(value),
212
+ });
213
+
214
+ aLine().with('quantity', 3).build(); // { sku: 'sku-1', quantity: 3 }
215
+ ```
216
+
217
+ - Un *builder* est accepté partout où une valeur l'est : `.with('customer', aCustomer().named('Ada'))`,
218
+ et dans les tableaux : `.with('lines', [aLine(), { sku: 'pen', quantity: 2 }])`.
219
+ - `sequence('order')` donne `order-1`, `order-2`... à chaque construction ; `lazy(() => new Date())`
220
+ calcule une valeur par défaut à chaque construction. Une valeur donnée par `with` ne consomme rien.
221
+ - Appelez `resetSequences()` dans `beforeEach`, pour qu'un test ne dépende pas de ceux qui le précèdent.
222
+ - La forme en classe garde `with`, `withMany` et `adding` protégées à dessein : la surface publique
223
+ d'un *builder* d'agrégat est le langage du domaine, pas les noms de ses propriétés.
224
+
225
+ Au lieu de créer l'objet entier dans chaque test, où la seule valeur dont dépend la règle se perd
226
+ parmi les autres, et où un nouvel argument de `Order.place` casse tous les tests :
227
+
228
+ ```ts
229
+ const order = Order.place('order-1', { id: 'customer-1', name: 'Grace Hopper' }, [{ sku: 'sku-1', quantity: 1 }]);
230
+ order.pay();
231
+ ```
232
+
233
+ écrivez ce dont parle le test, et laissez le *builder* tenir le reste en un seul endroit :
234
+
235
+ ```ts
236
+ const order = anOrder().paid().build();
237
+ ```
238
+
239
+ ## Données générées
240
+
241
+ ```
242
+ npm i -D @faker-js/faker
243
+ ```
244
+
245
+ <!-- checked sample -->
246
+ ```ts
247
+ import { defineBuilder, sequence } from '@ddd-ts-kit/testing';
248
+ import { generated } from '@ddd-ts-kit/testing/faker';
249
+
250
+ // Issu du domaine du contexte.
251
+ interface CustomerProps {
252
+ readonly id: string;
253
+ readonly name: string;
254
+ readonly email: string;
255
+ readonly birthDate: Date;
256
+ }
257
+
258
+ declare class Customer {
259
+ static register(props: CustomerProps): Customer;
260
+ }
261
+
262
+ export const aCustomer = defineBuilder<CustomerProps, Customer>({
263
+ defaults: {
264
+ id: sequence('customer'),
265
+ name: generated((faker) => faker.person.fullName()),
266
+ email: generated((faker) => faker.internet.email().toLowerCase()),
267
+ birthDate: generated((faker) => faker.date.birthdate({ mode: 'age', min: 18, max: 90 })),
268
+ },
269
+ build: (props) => Customer.register(props),
270
+ });
271
+
272
+ aCustomer().build(); // un client différent, valide, à chaque construction
273
+ aCustomer().with('email', 'ada@example.com'); // énoncé : la règle sous test en dépend
274
+ ```
275
+
276
+ Les valeurs par défaut sont générées, et pourtant valides : chaque générateur est contraint à ce que le
277
+ domaine accepte (un adulte, une adresse en minuscules), et `build` passe toujours par `Customer.register`.
278
+
279
+ **La règle : des données générées pour les champs dont un test ne se soucie pas, des valeurs explicites
280
+ pour ce dont dépend la règle sous test.** Un test n'affirme jamais une valeur générée qu'il n'a pas
281
+ énoncée, et ne compte jamais sur une valeur générée qui tomberait du bon côté d'une règle.
282
+
283
+ Au lieu de laisser au générateur la valeur dont parle la règle, si bien que le test passe grâce à une
284
+ valeur par défaut que personne ne lit :
285
+
286
+ ```ts
287
+ it('lets an adult buy wine', () => {
288
+ const customer = aCustomer().build(); // adulte parce que la valeur par défaut le dit aujourd'hui
289
+ expect(customer.mayBuy(wine, today)).toBe(true);
290
+ });
291
+ ```
292
+
293
+ énoncez-la, avec une date que le lecteur peut vérifier :
294
+
295
+ ```ts
296
+ it('lets an adult buy wine', () => {
297
+ const customer = aCustomer().with('birthDate', new Date('2008-05-01')).build();
298
+ expect(customer.mayBuy(wine, new Date('2026-05-01'))).toBe(true); // 18 ans ce jour-là
299
+ });
300
+ ```
301
+
302
+ Chaque valeur `generated` est tirée d'un seul générateur par fichier de test, `dataGenerator()`. Sa graine
303
+ vient de `TEST_SEED`, sinon elle est aléatoire, et elle est toujours connue (`dataGenerator().seed`) :
304
+
305
+ <!-- checked sample -->
306
+ ```ts
307
+ // fichier de configuration initiale (setup) de vitest
308
+ import { beforeEach, onTestFailed } from 'vitest';
309
+ import { reportSeedOnFailure } from '@ddd-ts-kit/testing/faker';
310
+
311
+ reportSeedOnFailure({ beforeEach, onTestFailed });
312
+ // sous un test en échec : Generated data seed: 1837264. Replay this file with TEST_SEED=1837264.
313
+ ```
314
+
315
+ | Appel | Fait |
316
+ |---|---|
317
+ | `generated((faker) => value)` | une valeur par défaut ou une valeur de `with` tirée à chaque construction |
318
+ | `dataGenerator()` | le générateur courant : `seed`, `faker`, `restart()` |
319
+ | `createDataGenerator({ seed?, locales?, referenceDate? })` | un générateur à part ; les dates relatives sont calculées à partir d'une `referenceDate` fixe, si bien qu'une graine les rejoue aussi |
320
+ | `useDataGenerator(generator)` | fait tirer `generated` de ce générateur, par exemple une graine fixe dans une suite |
321
+ | `reportSeedOnFailure(runner, log?)` | affiche la graine sous chaque test en échec |
322
+
323
+ ## Scénarios
324
+
325
+ ```ts
326
+ import { given, scenario } from '@ddd-ts-kit/testing';
327
+
328
+ it('records the payment of a placed order', () => {
329
+ given(anOrder().withLine(aLine().with('quantity', 2)))
330
+ .when((order) => order.pay())
331
+ .thenEvents([{ name: 'ordering.order.paid', amount: 30 }]);
332
+ });
333
+
334
+ it('refuses to pay an order twice', () => {
335
+ given(anOrder().paid())
336
+ .when((order) => order.pay())
337
+ .thenError('ORDER_ALREADY_PAID')
338
+ .thenState((order) => expect(order.status).toBe('paid'));
339
+ });
340
+ ```
341
+
342
+ `given` prend un agrégat ou son *builder* et écarte les événements de la mise en place
343
+ (`pullEvents()`), si bien que seuls les événements de `when` sont vérifiés. `scenario(() => aggregate)` fait
344
+ de même avec la mise en place sous forme de fonction, rejouée à partir d'un agrégat neuf à chaque `when`.
345
+
346
+ | Assertion | Passe quand |
347
+ |---|---|
348
+ | `thenEvents([...])` | exactement ces événements, dans l'ordre ; chacun décrit par son nom, sa classe, ou les propriétés qui comptent |
349
+ | `thenEventNames([...])` | exactement ces noms, dans l'ordre |
350
+ | `thenNoEvent()` | rien n'a été enregistré |
351
+ | `thenError(code)`, `thenError(code, facts)` | l'action a refusé avec ce code, quelle que soit la classe de l'erreur, en portant ces faits s'ils sont donnés (elle peut en porter d'autres) |
352
+ | `thenError(ErrorClass, code?)` | l'action a levé une instance de cette classe : pour une erreur qui est une classe à elle seule, comme les écrivait la 0.2 |
353
+ | `thenState((aggregate) => ...)` | vos propres assertions sur l'agrégat passent |
354
+
355
+ Les échecs sont des `AssertionError` de `node:assert`, avec les événements attendus et enregistrés côte à
356
+ côte, si bien qu'ils se lisent bien sous n'importe quel runner. Une erreur levée par `when` n'est jamais
357
+ avalée : toute assertion sauf `thenError` échoue sur elle. Quand `when` (ou la mise en place) est
358
+ asynchrone, la chaîne est une promesse : `await given(order).when(async (o) => ...).thenEventNames([...])`.
359
+
360
+ ### Un agrégat en event sourcing
361
+
362
+ `givenEvents(rehydrate, events)` reconstruit l'agrégat en rejouant son historique, sans base de données,
363
+ et le scénario continue comme plus haut : une commande, puis les événements qu'elle a enregistrés ou le
364
+ refus.
365
+
366
+ ```ts
367
+ givenEvents((id, history) => AttendanceRecord.rehydrate(id, history), [opened, noShowNoted, noShowNoted])
368
+ .when((record) => record.noteNoShow('appointment-9', now))
369
+ .thenEvents([NoShowNoted, AttendanceRecordBarred]);
370
+ ```
371
+
372
+ `expectSnapshotEquivalence({ events, rehydrate, policy, state })` prouve les *snapshots* d'un agrégat :
373
+ pour chaque version de l'historique, l'agrégat reconstruit à partir du *snapshot* que sa politique
374
+ stockerait et des événements qui le suivent a l'état et la version du rejeu complet.
375
+
376
+ ## Étapes de processus
377
+
378
+ ```ts
379
+ import { givenProcess } from '@ddd-ts-kit/testing';
380
+
381
+ givenProcess<DeadlineState>(undefined)
382
+ .when((current) => deadlines.requested(booked, current))
383
+ .thenState({ appointmentId: 'appointment-1', confirmBefore: '2026-03-01T12:00:00.000Z' })
384
+ .thenTimeouts([{ name: 'deadline', at: deadline }])
385
+ .thenCommands([]);
386
+ ```
387
+
388
+ Une étape d'un gestionnaire de processus est une fonction de son état et de ce qui s'est passé :
389
+ `givenProcess(state)` (ou `undefined` pour une instance qui n'existe pas) l'exécute et vérifie sa
390
+ décision sans stockage, sans file d'attente ni bus. `thenIgnored()`, `thenState(state)`,
391
+ `thenCommands([...])` (chacune par sa classe ou par les propriétés qui comptent), `thenTimeouts([...])`,
392
+ `thenCancelled([...])` et `thenEnded()` s'enchaînent ; un échec est une `AssertionError` qui dit ce qui
393
+ diffère.
394
+
395
+ ## Refus
396
+
397
+ Un refus métier se reconnaît à son code, et porte ses faits sous forme de données : un test vérifie les
398
+ deux, jamais un message et jamais la classe d'un refus.
399
+
400
+ ```ts
401
+ import { expectRefusal } from '@ddd-ts-kit/testing';
402
+
403
+ expectRefusal(() => calendar.slotFor('consultation', startsAt, now), 'BOOKING_TOO_LATE', { notice: 120 });
404
+ expectRefusal(Email.create('ada@'), 'INVALID_EMAIL');
405
+ await expectRefusal(() => commands.execute(command), 'SLOT_TAKEN', { calendarId: 'calendar-1' });
406
+ ```
407
+
408
+ `expectRefusal(subject, code, facts?)` lit une action, qu'elle exécute, un `Result`, ou une erreur déjà
409
+ attrapée. Une action qui rend une promesse donne une promesse à attendre. Elle rend l'erreur, et échoue
410
+ avec une `AssertionError` qui nomme ce qui a été refusé à la place, ou les faits qui diffèrent, un par
411
+ ligne. Les faits donnés doivent être portés avec ces valeurs ; le refus peut en porter d'autres.
412
+
413
+ Au lieu de vérifier la formulation d'un message, qui casse quand on la reformule, ou la classe de
414
+ l'erreur, qui ne dit rien du refus dont il s'agissait :
415
+
416
+ ```ts
417
+ expect(() => order.pay(at)).toThrow('Order order-1 is already paid.');
418
+ expect(() => order.pay(at)).toThrow(OrderError);
419
+ ```
420
+
421
+ vérifiez le code, stable pour chaque client, et les faits que porte le refus :
422
+
423
+ <!-- checked sample -->
424
+ ```ts
425
+ import { ConceptError } from '@ddd-ts-kit/kernel';
426
+ import { expectRefusal } from '@ddd-ts-kit/testing';
427
+
428
+ class OrderError extends ConceptError {
429
+ static alreadyPaid(orderId: string) {
430
+ return OrderError.refusal('ORDER_ALREADY_PAID', `Order ${orderId} is already paid.`, { orderId });
431
+ }
432
+ }
433
+
434
+ const order = {
435
+ id: 'order-1',
436
+ pay(): void {
437
+ throw OrderError.alreadyPaid(this.id);
438
+ },
439
+ };
440
+
441
+ expectRefusal(() => order.pay(), 'ORDER_ALREADY_PAID', { orderId: 'order-1' });
442
+ ```
443
+
444
+ ## Gestionnaires
445
+
446
+ ```ts
447
+ import { createTestBuses, InMemoryRepository, snapshots } from '@ddd-ts-kit/testing';
448
+
449
+ // domain/repositories/order.repository.ts : le port est une classe abstraite
450
+ export abstract class OrderRepository extends Repository<Order> {}
451
+
452
+ // test/fakes/in-memory-order.repository.ts : le fake implémente le port, il ne l'étend jamais
453
+ class InMemoryOrderRepository extends InMemoryRepository<Order> implements OrderRepository {
454
+ constructor() {
455
+ super(snapshots({ toSnapshot: (order) => order.toSnapshot(), fromSnapshot: Order.rehydrate }));
456
+ }
457
+ }
458
+
459
+ it('places an order and announces it', async () => {
460
+ const { commandBus, domainEvents, dispatching, eventBus, clock, ids, publishedEvents } = createTestBuses();
461
+ const orders = dispatching(new InMemoryOrderRepository());
462
+ const publisher = new OrderPlacedPublisher(eventBus, ids);
463
+
464
+ domainEvents.on('ordering.order.placed', (event) => publisher.handle(event));
465
+ commandBus.register(PlaceOrder, new PlaceOrderHandler(orders, ids, clock));
466
+
467
+ const id = await commandBus.execute(new PlaceOrder('sku-1'));
468
+
469
+ expect(orders.count()).toBe(1);
470
+ expect(publishedEvents('ordering.order.placed')).toMatchObject([{ payload: { orderId: id } }]);
471
+ });
472
+ ```
473
+
474
+ Le gestionnaire ne fait que sauvegarder : `await this.orders.save(order); return order.id;`.
475
+ `dispatching(fake)` donne au *fake* ce que l'application donne à chaque *repository* : une fois `save` (ou
476
+ toute méthode à qui l'on remet l'agrégat) résolu, les événements que l'agrégat a enregistrés sont remis
477
+ à `domainEvents`.
478
+
479
+ ![Une spec de gestionnaire sur createTestBuses : la spec exécute une commande sur le bus de commandes, qui l'exécute dans l'unité de travail ; le vrai gestionnaire sauvegarde l'agrégat dans le fake du repository enveloppé par dispatching, qui garde une copie pendant que les événements enregistrés attendent ; une fois que le gestionnaire a rendu la main, domainEvents remet les événements à un gestionnaire before-commit, qui publie un événement d'intégration sur le bus d'événements en mémoire ; quand execute est résolu, la spec lit l'état du fake et les événements publiés](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/testing-package-handler-spec.fr.svg)
480
+
481
+ Une spec de gestionnaire a le minutage de la production. `commandBus` exécute chaque commande dans
482
+ `unitOfWork` (une `InMemoryUnitOfWork`), comme le fait le bus de l'application : les événements de chaque
483
+ sauvegarde attendent que le gestionnaire de la commande ait rendu la main, puis sont distribués dans
484
+ l'ordre où l'agrégat les a enregistrés, et `execute` se résout une fois que les gestionnaires des deux phases ont été
485
+ exécutés. Un gestionnaire qui échoue dans la phase `before-commit` fait rejeter `execute` ; rien n'est
486
+ défait, puisqu'il n'y a pas de transaction en mémoire. Appelé hors d'une commande, `orders.save(order)`
487
+ distribue avant de se résoudre.
488
+
489
+ ```ts
490
+ domainEvents.on('ordering.order.placed', (event) => board.refresh(event.aggregateId), { phase: 'after-commit' });
491
+
492
+ await commandBus.execute(new PlaceOrder('sku-1')); // se résout même quand le gestionnaire ci-dessus a échoué
493
+
494
+ expect(domainEvents.afterCommitFailures).toEqual([]); // { error, event } de chaque échec, les cent derniers
495
+ ```
496
+
497
+ Avec `createTestApplication` de `@ddd-ts-kit/di`, il n'y a rien à appeler : le *fake* donné pour un port
498
+ *repository* annonce à `app.domainEvents` avec le même minutage, et les gestionnaires d'événements du
499
+ domaine déclarés réagissent, chacun dans sa phase.
500
+
501
+ `InMemoryRepository` est un *fake*, pas un *mock* : un *repository* qui fonctionne, sur une Map, qui garde
502
+ sa propre copie de ce qui est sauvegardé (par `snapshots({ toSnapshot, fromSnapshot })` ou `{ clone }`) et
503
+ applique la concurrence optimiste sur `version`, en levant `ConcurrencyError` (code
504
+ `CONCURRENCY_CONFLICT`). Vérifiez son état avec `all()` et `count()`.
505
+
506
+ ### Un fake, pas un mock
507
+
508
+ Au lieu d'un *mock*, qui vérifie les appels qu'a faits le gestionnaire et rien de leur effet :
509
+
510
+ ```ts
511
+ const orders = { findById: vi.fn(), save: vi.fn() };
512
+ await new PlaceOrderHandler(orders, ids, clock).execute(new PlaceOrder('sku-1'));
513
+ expect(orders.save).toHaveBeenCalledTimes(1); // toujours vert si la commande ne pouvait jamais être relue
514
+ ```
515
+
516
+ exécutez le vrai gestionnaire par le bus sur un *fake*, et vérifiez ce qui est vrai ensuite :
517
+
518
+ <!-- checked sample -->
519
+ ```ts
520
+ import { AggregateRoot, Command, Repository, type Clock, type CommandHandler, type IdGenerator } from '@ddd-ts-kit/kernel';
521
+ import { createTestBuses, InMemoryRepository, snapshots } from '@ddd-ts-kit/testing';
522
+ import { expect, it } from 'vitest';
523
+
524
+ // Issu du domaine du contexte.
525
+ interface OrderSnapshot {
526
+ readonly id: string;
527
+ readonly sku: string;
528
+ }
529
+
530
+ declare class Order extends AggregateRoot {
531
+ static place(id: string, sku: string, at: Date): Order;
532
+ static rehydrate(snapshot: OrderSnapshot): Order;
533
+ toSnapshot(): OrderSnapshot;
534
+ }
535
+
536
+ abstract class OrderRepository extends Repository<Order> {}
537
+
538
+ // Issu de la couche application : le cas d'usage sous test.
539
+ class PlaceOrder extends Command<string> {
540
+ constructor(readonly sku: string) {
541
+ super();
542
+ }
543
+ }
544
+
545
+ class PlaceOrderHandler implements CommandHandler<PlaceOrder> {
546
+ constructor(
547
+ private readonly orders: OrderRepository,
548
+ private readonly ids: IdGenerator,
549
+ private readonly clock: Clock,
550
+ ) {}
551
+
552
+ async execute(command: PlaceOrder): Promise<string> {
553
+ const order = Order.place(this.ids.next(), command.sku, this.clock.now());
554
+
555
+ await this.orders.save(order);
556
+
557
+ return order.id;
558
+ }
559
+ }
560
+
561
+ // Issu de test/fakes/ : un repository qui fonctionne, tenu à la même suite de contrat que l'adaptateur.
562
+ class InMemoryOrderRepository extends InMemoryRepository<Order> implements OrderRepository {
563
+ constructor() {
564
+ super(snapshots({ toSnapshot: (order) => order.toSnapshot(), fromSnapshot: Order.rehydrate }));
565
+ }
566
+ }
567
+
568
+ it('places an order', async () => {
569
+ const { commandBus, dispatching, domainEvents, ids, clock } = createTestBuses();
570
+ const orders = dispatching(new InMemoryOrderRepository());
571
+ const heard: string[] = [];
572
+
573
+ domainEvents.on('ordering.order.placed', async (event) => void heard.push(event.aggregateId));
574
+ commandBus.register(PlaceOrder, new PlaceOrderHandler(orders, ids, clock));
575
+
576
+ const id = await commandBus.execute(new PlaceOrder('sku-1'));
577
+
578
+ expect((await orders.findById(id))?.toSnapshot()).toEqual({ id, sku: 'sku-1' });
579
+ expect(heard).toEqual([id]);
580
+ });
581
+ ```
582
+
583
+ Le *mock* casse quand le gestionnaire appelle une autre méthode du port pour le même effet, et reste vert
584
+ quand la commande sauvegardée ne peut pas être retrouvée ; le *fake* ne fait ni l'un ni l'autre. La règle
585
+ [`test/no-mocks`](../eslint-plugin/docs/rules/test-no-mocks.fr.md) signale `vi.fn`, `vi.mock` et leurs
586
+ semblables dans les tests d'un contexte.
587
+
588
+ ### Qui agit
589
+
590
+ `createTestBuses()` donne aussi `executionScope` et `actorContext`, les deux doubles du noyau tenus
591
+ ensemble, pour les *fakes* et les adaptateurs qui renseignent les colonnes d'audit ou restreignent au
592
+ *tenant*. Par défaut : l'utilisateur `test-user`, la corrélation `test-correlation`, l'origine `test`.
593
+
594
+ ```ts
595
+ import { aSession, anExecutionContext, createTestBuses, scoped } from '@ddd-ts-kit/testing';
596
+
597
+ const { executionScope } = createTestBuses({ executionContext: anExecutionContext().as(ada).build() });
598
+ const orders = new DrizzleOrderRepository(database, executionScope, clock);
599
+
600
+ await orders.save(order); // renseigné au nom d'ada, dans son tenant
601
+ await scoped(executionScope, anExecutionContext().as(bob).ofTenant('globex'), () => orders.findById(order.id));
602
+ ```
603
+
604
+ | Builder | Par défaut | Dit |
605
+ |---|---|---|
606
+ | `aSession()` | la session d'un utilisateur à part, ouverte par mot de passe à l'instant de référence fixe des tests (*test epoch*), sans expiration | `of(actor)`, `identifiedBy`, `issuedAt`, `expiringAt`, `authenticatedBy`, `withSecondFactor`, `impersonatedBy`, `inLocale`, `inTimeZone`, `withExtensions` |
607
+ | `anExecutionContext()` | le travail d'un utilisateur à part, origine `test`, une corrélation à part | `as(actor)`, `inSession(session)`, `ofTenant`, `elevatedOver(tenants, reason?)`, `correlatedBy`, `causedBy`, `from(origin)`, `dueBy`, `inLocale`, `withExtensions` |
608
+
609
+ Ce sont deux classes sur `Builder`, immuables comme tout *builder*, et elles acceptent un *builder* partout
610
+ où l'on attend un acteur ou une session (`aSession().of(anActor().withRole('accountant'))` avec le
611
+ *builder* d'acteur de `@ddd-ts-kit/auth/testing`). `scoped(scope, context, work)` exécute un bloc dans un
612
+ contexte donné comme valeur ou comme *builder*, avec n'importe quel `ExecutionScope` ; `asTenant(scope,
613
+ 'acme', work)` l'exécute comme membre d'un *tenant*, et `elevatedOver('all')` construit le contexte du
614
+ personnel de la plateforme sous une élévation, sans l'entrée d'audit qu'écrit `acrossTenants`.
615
+
616
+ ## De bout en bout
617
+
618
+ Toute l'application, vrais adaptateurs compris, pilotée de l'extérieur : des requêtes HTTP en entrée, des
619
+ réponses et des événements d'intégration en sortie. Rien ici ne connaît le framework HTTP ni la façon dont
620
+ l'application est composée ; `start` le dit.
621
+
622
+ <!-- checked sample -->
623
+ ```ts
624
+ import { createServer, type RequestListener } from 'node:http';
625
+ import { listenOnRandomPort, startTestServer } from '@ddd-ts-kit/testing/e2e';
626
+ import { afterAll } from 'vitest';
627
+
628
+ // Issu du projet : sa racine de composition, son framework HTTP, un jeton du fournisseur d'identité de test.
629
+ declare function composeApplication(): object;
630
+ declare function expressApp(app: object): RequestListener;
631
+ declare const testToken: string;
632
+
633
+ const app = composeApplication(); // la racine de composition de main.ts
634
+ const server = await startTestServer({
635
+ start: () => listenOnRandomPort(createServer(expressApp(app))),
636
+ // Fastify : async () => ({ url: await fastify.listen({ port: 0 }), close: () => fastify.close() })
637
+ headers: { authorization: `Bearer ${testToken}` },
638
+ });
639
+ afterAll(() => server.close());
640
+ ```
641
+
642
+ | Appel | Fait |
643
+ |---|---|
644
+ | `createHttpClient(baseUrl)` | le client seul, pour un serveur que le projet démarre lui-même : ce qu'utilise le `test/e2e/support/start-application.ts` d'un projet généré, sur son propre `composeApplication(persistence, options)` |
645
+ | `client.get / post / put / patch / delete` | du JSON en entrée, `{ status, headers, body }` en sortie ; `body` est analysé quand le type de contenu est JSON, du texte sinon ; un 4xx ou un 5xx est une réponse, pas une exception |
646
+ | `client.post(path, body, { headers })`, `client.withHeaders(headers)` | des en-têtes pour un appel, ou un client pour un autre appelant |
647
+ | `expectStatus(response, 201)` | rend la réponse, ou échoue en montrant le corps, où l'API dit pourquoi |
648
+ | `expectProblem(response, { status, code, facts })` | vérifie une réponse RFC 9457 `application/problem+json` et rend le document ; `code` et `facts` sont optionnels, et `facts` nomme les faits que le document doit exposer, tels que JSON les écrit (une date est son texte ISO) |
649
+ | `eventually(assertion, { timeoutMs = 2000, intervalMs = 20 })` | réessaie jusqu'à ce que l'assertion passe ; quand le temps est écoulé, échoue en citant la dernière erreur |
650
+ | `collectEvents(subscriber, types)` | enregistre les événements d'intégration publiés : `received(type?)`, `waitFor(type, predicate?, options?)`, `stop()` |
651
+
652
+ Les assertions sont celles de `node:assert` : elles se lisent de la même façon sous n'importe quel runner.
653
+
654
+ **Préparer par l'API.** Un test de bout en bout crée son état comme le ferait un client, jamais en
655
+ écrivant des lignes. Le *builder* qui donne l'agrégat aux tests unitaires donne ici le contenu de la
656
+ requête HTTP, à partir des mêmes propriétés et du même vocabulaire :
657
+
658
+ ```ts
659
+ class OrderBuilder extends Builder<OrderProps, Order> {
660
+ // defaults(), construct() et les méthodes du langage omniprésent, comme plus haut
661
+
662
+ buildRequest(): PlaceOrderRequest {
663
+ const { customer, lines } = this.props(); // les valeurs par défaut et ce que le test a énoncé, résolus
664
+ return { customerId: customer.id, lines };
665
+ }
666
+ }
667
+
668
+ const { body: order } = expectStatus(await client.post<OrderView>('/orders', anOrder().withLine(aLine()).buildRequest()), 201);
669
+ ```
670
+
671
+ **Remettre à zéro entre les tests.** Le harnais nomme le contrat et laisse l'implémentation au projet et à
672
+ son pilote :
673
+
674
+ <!-- checked sample -->
675
+ ```ts
676
+ import type { DatabaseReset } from '@ddd-ts-kit/testing/e2e';
677
+ import { beforeEach } from 'vitest';
678
+
679
+ // PostgreSQL, avec n'importe quel client qui expose `query` : chaque table des schémas de l'application.
680
+ export function truncateAllTables(db: { query(sql: string): Promise<{ rows: { name: string }[] }> }, schemas: string[]): DatabaseReset {
681
+ return {
682
+ async reset() {
683
+ const { rows } = await db.query(
684
+ `select quote_ident(schemaname) || '.' || quote_ident(tablename) as name
685
+ from pg_tables where schemaname in (${schemas.map((schema) => `'${schema}'`).join(', ')})`,
686
+ );
687
+ if (rows.length > 0) {
688
+ await db.query(`truncate ${rows.map((row) => row.name).join(', ')} restart identity cascade`);
689
+ }
690
+ },
691
+ };
692
+ }
693
+
694
+ declare const database: DatabaseReset; // truncateAllTables(pool, ['ordering', 'billing']), créé une fois
695
+
696
+ beforeEach(() => database.reset());
697
+ ```
698
+
699
+ ## Trois niveaux, le même vocabulaire
700
+
701
+ Une règle, « une commande déjà payée ne peut pas être payée de nouveau », à trois des quatre
702
+ [niveaux de test](../../docs/ARCHITECTURE.fr.md#7-niveaux-de-test) (le niveau de l'adaptateur, ce sont les
703
+ suites de contrat, plus bas). Le *builder* est le même ; seul change ce qui s'exécute en dessous.
704
+
705
+ ```ts
706
+ // 1. Domaine : l'agrégat seul. Quelques millisecondes, aucun double.
707
+ it('refuses to pay an order twice', () => {
708
+ given(anOrder().paid())
709
+ .when((order) => order.pay(at))
710
+ .thenError('ORDER_ALREADY_PAID');
711
+ });
712
+
713
+ // 2. Application : le vrai gestionnaire par le bus, des fakes pour les ports.
714
+ it('refuses to pay an order twice', async () => {
715
+ const orders = new InMemoryOrderRepository();
716
+ const app = createTestApplication({ modules: [orderingModule], fakes: [fake(OrderRepository, orders)] });
717
+ const order = anOrder().paid().build();
718
+ await orders.save(order);
719
+
720
+ await expect(app.commandBus.execute(new PayOrder(order.id))).rejects.toMatchObject({ code: 'ORDER_ALREADY_PAID' });
721
+ expect(app.eventBus.published).toEqual([]);
722
+ });
723
+
724
+ // 3. De bout en bout : HTTP, la vraie base de données et le vrai broker, des données générées, un état créé par l'API.
725
+ it('refuses to pay an order twice', async () => {
726
+ const events = collectEvents(broker, [OrderPaid]);
727
+ const { body: order } = expectStatus(await client.post<OrderView>('/orders', anOrder().buildRequest()), 201);
728
+ expectStatus(await client.post(`/orders/${order.id}/payment`), 204);
729
+
730
+ expectProblem(await client.post(`/orders/${order.id}/payment`), { status: 409, code: 'ORDER_ALREADY_PAID' });
731
+
732
+ await events.waitFor(OrderPaid, (event) => event.payload.orderId === order.id);
733
+ await eventually(async () => expect((await client.get<OrderView>(`/orders/${order.id}`)).body.status).toBe('paid'));
734
+ expect(events.received(OrderPaid)).toHaveLength(1);
735
+ });
736
+ ```
737
+
738
+ Le niveau 2 utilise `createTestApplication` et `fake` de `@ddd-ts-kit/di` : le port, une classe abstraite,
739
+ est ce sous quoi le *fake* est lié. Avec un autre conteneur, `createTestBuses()` ci-dessus donne les mêmes
740
+ bus sur lesquels enregistrer le gestionnaire. Au niveau 3, le client de `anOrder()` est généré, la ligne
741
+ de la commande est celle que donne la valeur par défaut, et les seules valeurs que le test énonce sont celles
742
+ dont dépend la règle : la commande est payée, puis payée de nouveau.
743
+
744
+ ## Suites de contrat
745
+
746
+ Un *fake* ne mérite la confiance que s'il se comporte comme l'adaptateur qu'il remplace. Chaque suite
747
+ prend les fonctions du runner et une fabrique (factory), et s'exécute sur les deux.
748
+
749
+ ![Une suite de contrat, deux sujets : repositoryContract exécute les mêmes tests sur le fake en mémoire, qu'utilisent les specs de gestionnaire, et sur l'adaptateur Drizzle sur une vraie base de données, qu'utilise la production ; les deux implémentent le port repository du domaine, si bien que ce qu'une spec de gestionnaire prouve sur le fake vaut pour la production](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/testing-package-contract-suite.fr.svg)
750
+
751
+ <!-- checked sample -->
752
+ ```ts
753
+ // contexts/ordering/test/integration/order-repository.contract.spec.ts
754
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
755
+ import type { AggregateRoot, Repository } from '@ddd-ts-kit/kernel';
756
+ import type { InMemoryRepository } from '@ddd-ts-kit/testing';
757
+ import { repositoryContract } from '@ddd-ts-kit/testing/contracts';
758
+
759
+ // Issu du contexte et de son dossier de test.
760
+ interface Line {
761
+ readonly sku: string;
762
+ readonly quantity: number;
763
+ }
764
+
765
+ declare class Order extends AggregateRoot {
766
+ addLine(line: Line): void;
767
+ toSnapshot(): object;
768
+ }
769
+
770
+ declare const anOrder: () => { build(): Order };
771
+ declare const aLine: () => { build(): Line };
772
+ // Le fake de la section Gestionnaires : il étend InMemoryRepository, il n'étend jamais le port.
773
+ declare class InMemoryOrderRepository extends InMemoryRepository<Order> {
774
+ constructor();
775
+ }
776
+ declare function freshDrizzleRepository(): Promise<Repository<Order>>;
777
+
778
+ const runner = { describe, it, expect, beforeEach, afterEach };
779
+ const options = {
780
+ createAggregate: () => anOrder().build(),
781
+ mutate: (order: Order) => order.addLine(aLine().build()),
782
+ idOf: (order: Order) => order.id,
783
+ stateOf: (order: Order) => order.toSnapshot(),
784
+ };
785
+
786
+ repositoryContract(runner, { ...options, name: 'InMemoryOrderRepository', createRepository: () => new InMemoryOrderRepository() });
787
+ repositoryContract(runner, { ...options, name: 'DrizzleOrderRepository', createRepository: () => freshDrizzleRepository() });
788
+ ```
789
+
790
+ | Suite | Tient |
791
+ |---|---|
792
+ | `repositoryContract` | enregistrer puis retrouver, id inconnu, mise à jour, isolement de la copie stockée, concurrence optimiste (toute erreur de code `CONCURRENCY_CONFLICT`) |
793
+ | `tenantIsolationContract` | un *repository* sous une stratégie d'isolement des *tenants* : ce qu'un *tenant* a enregistré est caché à un autre ; un autre *tenant* qui enregistre sous le même id le laisse intact, par une mise à jour ou (si `recreate(aggregate)` est donné, un nouvel agrégat sous le même id) par une insertion ; le personnel de la plateforme, avec une élévation sur tous les *tenants*, n'écrit rien, et lit l'agrégat de l'autre *tenant* quand `elevatedReads` est vrai (tables partagées, sécurité au niveau des lignes) ; une écriture d'un travail qui ne nomme aucun *tenant* est refusée (`NoCurrentTenantError`) et une lecture est refusée ou ne trouve rien. `createRepository(scope)` lit le *tenant* dans la portée qu'on lui donne, sur un stockage où existent les *tenants* `acme` et `globex`. Pour prouver la sécurité au niveau des lignes elle-même, exécutez-la sur un *repository* qui n'écrit aucune condition de *tenant* |
794
+ | `eventBusContract` | tout fournisseur d'`EventBus` : routage par type, plusieurs abonnés, désabonnement, aller-retour exact de l'enveloppe (`traceparent`, `correlationId`, `tenantId`), remontée de l'échec d'un gestionnaire. Les relivraisons sont acceptées ; rendez `settle()` depuis `createBus` pour les fournisseurs qui livrent de façon asynchrone |
795
+ | `unitOfWorkContract` | toute `UnitOfWork`, appelée par `run` sans bus de commandes : les événements du domaine attendent la fin et sont distribués avant la validation (commit), si bien que les gestionnaires voient chaque sauvegarde et que ce qu'ils écrivent, l'outbox comprise, est validé avec ; un échec dans une sauvegarde, dans le travail, dans un gestionnaire ou dans la validation (une contrainte différée) ne laisse rien derrière lui ; les gestionnaires `after-commit` s'exécutent une fois les lignes validées, jamais autrement. Un adaptateur qui n'est pas construit sur `withinUnitOfWork` échoue ici |
796
+ | `outboxStoreContract` | tout stockage d'outbox : ordre, limite, bail, prises concurrentes, publié, échoué, mort, `retryDead`, purge ; avec `carriesTenant: true`, le *tenant* de chaque événement conservé |
797
+ | `processedEventStoreContract` | tout `ProcessedEventStore` : une fois par consommateur et par événement, aussi sous des livraisons concurrentes |
798
+ | `eventStoreContract` | tout `EventStore` : un flux créé à la version 0 et relu dans l'ordre, numéroté à partir de 1, avec son type, sa version de forme, ses données, ses métadonnées et son instant ; un ajout à une version que le flux n'a plus (en retard ou en avance) refusé avec `CONCURRENCY_CONFLICT` et rien n'en est gardé ; un seul de deux ajouts à la même version passe ; des flux numérotés séparément ; tout le stockage lu dans l'ordre des ajouts, positions croissantes à partir de 1, après une position et dans une limite ; `head()` |
799
+ | `eventStoreUnitOfWorkContract` | un `EventStore` transactionnel avec l'outbox de sa base de données sous une même `UnitOfWork` : le flux et les enregistrements de l'outbox publiés pour ses événements sont validés ensemble ; rien ne reste dans l'outbox pour un ajout défait par un retour arrière (rollback), ni pour un ajout refusé par un conflit de concurrence. `createSubject()` rend `{ unitOfWork, store, outbox }` |
800
+ | `processStoreContract` | tout `ProcessStore` de gestionnaires de processus : une instance telle qu'enregistrée (état JSON, délais armés, fin), une version qui n'est pas la suivante refusée avec `CONCURRENCY_CONFLICT`, un seul de deux premiers enregistrements qui l'emporte, et avec `unitOfWork` rien de laissé par une sauvegarde défaite par un retour arrière (rollback) |
801
+ | `jobStoreContract` | tout `JobStore` de *jobs* en arrière-plan : les *jobs* dus, le plus ancien d'abord et chacun à une seule prise, les *jobs* différés, les clés d'idempotence restreintes au *tenant* (deux *tenants* n'en partagent jamais une), le *tenant* et le sujet que montre `find`, le bail et ses battements de cœur, les comptes rendus entendus seulement de la tentative qui tient le *job* (progression, point de reprise remis à la tentative suivante, succès, nouvelle tentative, mort et ranimé, libération), l'annulation d'un *job* en attente et d'un *job* en cours, la purge. Avec `unitOfWork` (sur le même contexte que le stockage qui vient d'être créé), un *job* ajouté, annulé ou ranimé dans une unité de travail qui revient en arrière (rollback) reste tel qu'il était, et un *job* dont l'unité de travail est validée est ajouté, annulé ou ranimé. Les prises demandent un bail de 30 s |
802
+ | `cacheContract` | tout `Cache` : aller-retour de chaque forme de valeur, isolement des entrées, suppression, durée de vie |
803
+ | `cachePurgerContract` | tout `CachePurger` avec son cache partagé (`store`, `holds`) : un tag purge ce qu'il nomme et rien d'autre, une sorte atteint chaque élément, les *tenants* restent séparés, la purge est idempotente |
804
+ | `lockContract` | tout `Lock` : un seul détenteur à la fois, aussi sous des demandes concurrentes, libération, expiration, une libération tardive ignorée, jeton de clôture (*fencing token*) croissant, `waitMs` |
805
+ | `jobSchedulerContract` | tout `JobScheduler` : enregistrement et description, un nom en double et une planification illisible refusés, `runNow` (aussi pour un *job* désactivé, un nom inconnu rejeté), rien par le temps avant `start` ni après `stop`, aucun chevauchement par défaut, un *job* en échec, signalé `failed` avec son code, pendant que les autres continuent, le contexte d'exécution d'une exécution |
806
+ | `loggerContract` | tout `Logger` : chaque niveau avec son message et des champs de toute forme, un niveau et un message qu'aucun champ ne contrefait, des enfants qui ajoutent leurs champs et s'imbriquent, des champs lus à chaque entrée, une entrée sous le niveau écartée sans lire aucun champ, `isLevelEnabled` qui répond ce qui est écrit (pour un enfant aussi), aucune exception sur un champ circulaire ou un *getter* qui lève, y compris au premier niveau des champs ; une `Error` passée comme champ écrite sous forme de sa description (`type`, `code` d'une `DomainError`, `message`, `stack` propre, `cause`, huit erreurs de profondeur au plus), son message remplacé quand le *logger* nettoie, sauf s'il s'agit d'une `DomainError` ; chaque entrée dans la destination une fois `flush()` résolu, puis aucune exception d'un appel de journalisation ni d'un second *flush*. `createLogger({ level, fields, scrubMessages })` rend le *logger* et `lines()`, ce que sa destination a reçu sous la forme `{ level, message, fields }`. Aucun indicateur de capacité : `InMemoryLogger` prend les trois mêmes options et passe toute la suite |
807
+ | `criteriaContract` | tout traducteur de critères, l'évaluateur en mémoire compris : une table de critères et les ids auxquels chacun répond, dans l'ordre, sur huit lignes fixes. Chaque opérateur sur chaque type auquel il s'applique, les décimaux comme des nombres, les instants à la milliseconde et à travers les fuseaux, la casse et les caractères sur lesquels un `LIKE` trébucherait, les nulls sous chaque opérateur, `and`, `or`, `not` et ce qu'une négation fait à un null, des tris avec égalités et nulls dans les deux sens, des pages par décalage avec leur total, chaque tri parcouru par curseur une et deux lignes à la fois, les quatre sortes de périmètre d'accès, et des valeurs hostiles. `createReadModel(rows)` rend `{ search(scopedCriteria) }` sur exactement ces lignes, lues avec `criteriaContractDefinition` |
808
+ | `readModelStoreContract` | toute `ReadModelTable` sous `TableReadModelStore` : des lignes de tout type en aller-retour, la ligne courante remise à un événement ultérieur, un événement appliqué une fois, jamais un événement plus ancien que la ligne, ordre par id d'événement au même instant et par version quand elle est donnée, des écritures concurrentes qui finissent sur la plus récente, des suppressions qui durent, la liste par critères et par périmètre, et `ProjectionRebuild` : reconstruit à partir de la source seule, aucun événement perdu pendant l'export (sur une ligne déjà copiée et sur une ligne pas encore copiée), des événements périmés et nouveaux après la bascule, des suppressions pendant et avant, un export en échec qui laisse la table telle qu'elle était, la table toujours elle-même après deux reconstructions. `createTable()` rend `{ table, unitOfWork }` ; `SourceOfTruth` est le *fake* de la source |
809
+ | `fileStorageContract` | tout `FileStorage` : aller-retour du contenu avec taille, type de média et somme de contrôle, plusieurs mébioctets en flux, une écriture au-delà de `maxBytes` refusée en laissant le contenu précédent, des clés qui pourraient sortir de leur place refusées, suppression, copie, déplacement, liste par préfixe, des liens signés de téléchargement et d'envoi (*upload*) qui expirent, refusent une signature modifiée ou un autre type de média, et ne sont jamais signés pour plus d'une heure, un téléchargement servi en pièce jointe même sans nom. `follow` envoie une requête HTTP à un lien : `fetch` par défaut, `followServed(storage)` pour un stockage que l'application sert |
810
+ | `rateLimiterContract` | tout `RateLimiter` : limite, `remaining` et `retryAfterMs`, appels refusés non comptés, fenêtre glissante, isolement des clés, appels concurrents |
811
+
812
+ Les suites du cache, du verrou, du limiteur de débit et du stockage de fichiers prennent aussi
813
+ `elapse(ms)`, qui fait passer le temps pour le sujet : avancez l'horloge qu'il lit, ou attendez vraiment
814
+ quand le temps est celui d'un serveur. La suite du planificateur de *jobs* le prend de `createScheduler`,
815
+ avec le planificateur et la portée dans laquelle il exécute ses *jobs* : `{ scheduler, scope, elapse }`. Elle
816
+ demande quelques secondes au plus, avec des *jobs* toutes les secondes.
817
+
818
+ La suite de l'unité de travail prend `createUnitOfWork`, appelée avant chaque test, qui rend une
819
+ `UnitOfWorkUnderContract` sur une table vide et une outbox vide :
820
+ `{ unitOfWork, outbox, insert(id), insertRefusedAtCommit(id), ids(), committedIds?(), close?() }`.
821
+ La table tient un identifiant sous une clé primaire et une référence à une ligne de la même table que la
822
+ base de données ne vérifie qu'à la validation (une contrainte différée) : `insertRefusedAtCommit` écrit
823
+ une ligne que la validation refuse. Donnez `committedIds`, ce que voit une autre connexion, là où la base
824
+ de données sert plusieurs connexions : la suite prouve alors aussi que les lignes d'un gestionnaire
825
+ `before-commit` sont invisibles de l'extérieur de la transaction et visibles des gestionnaires
826
+ `after-commit`.
827
+
828
+ Les suites n'utilisent que `describe`, `it`, `beforeEach`, `expect(...).toBe` et `toEqual`
829
+ (`afterEach` est optionnel), que fournissent les globales de vitest comme de jest : cette partie d'un
830
+ runner est le type `ContractRunner`.
831
+
832
+ Un *package* qui tient un port à lui publie son contrat sur la même base, `ContractSuite` : un sujet créé à
833
+ neuf avant chaque test, et `it`, `expect`, `afterEach` liés au runner.
834
+
835
+ <!-- checked sample -->
836
+ ```ts
837
+ import { ContractSuite, type Awaitable, type ContractRunner } from '@ddd-ts-kit/testing/contracts';
838
+
839
+ // Le port que tient le package.
840
+ export interface Geocoder {
841
+ locate(address: string): Promise<{ readonly latitude: number; readonly longitude: number } | undefined>;
842
+ }
843
+
844
+ export function geocoderContract(runner: ContractRunner, options: { name: string; create(): Awaitable<Geocoder> }): void {
845
+ new GeocoderSuite(runner, () => options.create()).describe(`${options.name} (geocoder contract)`);
846
+ }
847
+
848
+ class GeocoderSuite extends ContractSuite<Geocoder> {
849
+ protected tests(): void {
850
+ this.it('answers undefined for an address it does not know', async () => {
851
+ this.expect(await this.subject.locate('nowhere')).toBe(undefined);
852
+ });
853
+ }
854
+ }
855
+ ```
856
+
857
+ ## Les secrets qu'un adaptateur ne doit pas montrer
858
+
859
+ Un adaptateur est journalisé, étalé dans un diagnostic et sérialisé par du code qui ne sait rien de ce
860
+ qu'il contient. `expectNoSecret(adapter, [password, url])` vérifie qu'aucune des valeurs données ne
861
+ transparaît par `JSON.stringify`, un étalement d'objet, les valeurs auxquelles mène `Object.keys`, ou
862
+ `util.inspect` à toute profondeur, propriétés cachées comprises. L'échec nomme la vue et le rang du
863
+ secret, jamais sa valeur.
864
+
865
+ <!-- checked sample -->
866
+ ```ts
867
+ import { KafkaEventBus } from '@ddd-ts-kit/kafka';
868
+ import { expectNoSecret } from '@ddd-ts-kit/testing';
869
+ import { it } from 'vitest';
870
+
871
+ declare const brokers: string;
872
+ declare const password: string;
873
+
874
+ it('shows no credential', () => {
875
+ const bus = new KafkaEventBus({ brokers, client: { 'sasl.password': password } });
876
+
877
+ expectNoSecret(bus, [password]);
878
+ });
879
+ ```
880
+
881
+ ## Décorateurs standard sous Vitest
882
+
883
+ Vitest 5 (Vite 8, Oxc) transforme les décorateurs historiques (legacy) et ne transforme pas les
884
+ décorateurs standard (TC39) : une *spec* qui importe un fichier `.ts` contenant `@CommandHandler(...)` ou
885
+ `@Retry(...)` s'arrête sur une erreur de syntaxe. `decorators()` comble cet écart, et seulement celui-là.
886
+
887
+ <!-- checked sample -->
888
+ ```ts
889
+ // vitest.config.ts
890
+ import { defineConfig } from 'vitest/config';
891
+ import { decorators } from '@ddd-ts-kit/testing/vitest';
892
+
893
+ export default defineConfig({ plugins: [decorators()] });
894
+ ```
895
+
896
+ - **Ce qu'il fait.** Un fichier `.ts`, `.mts` ou `.cts` dont une ligne commence par un décorateur est
897
+ compilé par le compilateur TypeScript du projet avant que le runner le lise ; tout autre fichier est
898
+ laissé au runner. Les imports sont gardés tels qu'écrits, pour que le runner les résolve.
899
+ - **Quelles options il suit.** `target`, `useDefineForClassFields`, `verbatimModuleSyntax` et
900
+ `experimentalDecorators`, lues dans le `tsconfig.json` le plus proche de chaque fichier (celui que suit
901
+ aussi le runner), ou dans l'option `tsconfig`. Une cible `ESNext` laisserait les décorateurs en place,
902
+ si bien que `ES2024` la remplace.
903
+ - **Il s'efface en mode historique.** Sous `experimentalDecorators: true`, il ne répond rien et le runner
904
+ transforme lui-même les décorateurs. Un projet sur class-validator ou NestJS peut garder le *plugin* dans
905
+ sa configuration ou l'enlever : le résultat est le même.
906
+ - **Source maps.** La *map* nomme le vrai fichier et contient son texte : les traces de pile, les points
907
+ d'arrêt et la couverture pointent sur la source.
908
+ - **Cache.** Une entrée par contenu de fichier, compilateur et options, sous le répertoire de cache de
909
+ Vite (option `cacheDir` pour le déplacer). Un fichier est compilé une fois, puis relu : environ 60 ms
910
+ avec le TypeScript 7 natif et 220 ms avec TypeScript 6 la première fois, rien ensuite.
911
+ - **Quel compilateur.** Le `tsc` du *package* `typescript` qu'installe le projet, à partir de la 5.6, le
912
+ TypeScript 7 natif compris : il exécute le binaire du compilateur, pas son API JavaScript, que le
913
+ compilateur natif n'a pas. `compiler` désigne un autre *package* ou un chemin.
914
+
915
+ | Option | Par défaut |
916
+ |---|---|
917
+ | `tsconfig` | le `tsconfig.json` le plus proche au-dessus de chaque fichier |
918
+ | `compiler` | `typescript` |
919
+ | `cacheDir` | `ddd-ts-kit-decorators` dans le répertoire de cache de Vite |
920
+
921
+ Il ne vérifie pas les types (le build s'en charge), et il ne touche pas aux fichiers `.tsx`.
922
+
923
+ ## Seuils de couverture lus dans la configuration
924
+
925
+ `@ddd-ts-kit/testing/coverage` donne au runner de tests ce qu'il mesure et le plancher auquel il est tenu,
926
+ lus dans la clé `coverage` de `ddd-ts-kit.config.json` (la référence quand la clé est absente), si bien
927
+ qu'aucun seuil ne s'écrit dans une configuration de runner :
928
+
929
+ <!-- checked sample -->
930
+ ```ts
931
+ // vitest.config.ts
932
+ import { governedCoverage } from '@ddd-ts-kit/testing/coverage';
933
+ import { defineConfig } from 'vitest/config';
934
+
935
+ export default defineConfig(({ mode }) => ({
936
+ test: { coverage: { provider: 'v8', reporter: ['text', 'lcov'], ...governedCoverage(mode) } },
937
+ }));
938
+ ```
939
+
940
+ - `include` : les couches de chaque contexte qu'exécute le lancement, y compris les fichiers qu'aucune
941
+ *spec* ne charge. `--mode unit` mesure `domain`, `application` et `presentation`, `--mode integration`
942
+ mesure `infrastructure`, tout autre mode les quatre.
943
+ - `thresholds` : une entrée par contexte et par couche qui contient un fichier source, si bien qu'un
944
+ contexte ne peut pas se cacher derrière un autre et qu'une couche vide n'est tenue à rien jusqu'à son
945
+ premier fichier.
946
+ - Un lancement partiel ne mesure et ne tient que les contextes nommés dans `DDD_TS_KIT_AFFECTED` (séparés
947
+ par des virgules, tels que [`ddd-ts-kit affected`](../cli/README.fr.md#affected) les calcule pour une
948
+ *pull request*) ; sans elle, le lancement couvre tous les contextes. `unaffectedContexts()` rend les
949
+ répertoires des autres contextes, terminés par `/`, pour que le runner exclue leurs *specs* :
950
+ ``exclude: unaffectedContexts().map((directory) => `${directory}**`)``.
951
+ - `governedJestCoverage()` donne la même chose sous la forme de `collectCoverageFrom` et `coverageThreshold`.
952
+ - `REFERENCE_COVERAGE` est la référence : domaine 90/85/90/90, application 85/80/85/85,
953
+ présentation 80/70/80/80, infrastructure 70/60/70/70 (instructions, branches, fonctions, lignes).
954
+
955
+ En mode de décorateurs historique avec `emitDecoratorMetadata`, ajoutez `decoratorCoverage()` de
956
+ `@ddd-ts-kit/testing/vitest` à `plugins` : les instructions qu'écrit la transformation pour appliquer les
957
+ décorateurs contiennent, pour chaque type que nomme une classe décorée, une branche qu'aucun test ne peut
958
+ prendre ; le *plugin* les marque avec l'indication que lit le fournisseur `v8`, comme le fournisseur le
959
+ fait de lui-même pour SWC.
960
+
961
+ ## Voir aussi
962
+
963
+ - [Stratégie de test](../../docs/concepts/testing-strategy.fr.md) : pourquoi des *fakes* et pas des *mocks*,
964
+ les quatre niveaux, quoi tester où
965
+ - [Runners de test](../../docs/tools/test-runners.fr.md) : Vitest ou Jest, et ce que chacun donne avec le kit
966
+ - [`@ddd-ts-kit/di`, tests](../di/README.fr.md#tests) : `createTestApplication` et `fake`, la spec
967
+ d'application avec le conteneur
968
+ - [`@ddd-ts-kit/kernel`](../kernel/README.fr.md) : les agrégats, les ports et les adaptateurs en mémoire
969
+ avec lesquels travaillent les *builders* et les *fakes*