@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.
- package/README.fr.md +969 -0
- package/README.md +356 -7
- 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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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*
|