@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.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
**English** · [Français](README.fr.md)
|
|
2
|
+
|
|
1
3
|
# @ddd-ts-kit/testing
|
|
2
4
|
|
|
3
5
|
Readable tests without mocks: test data builders, given / when / then scenarios for
|
|
@@ -16,7 +18,124 @@ npm i -D @ddd-ts-kit/testing
|
|
|
16
18
|
| `@ddd-ts-kit/testing/faker` | generated data for builders | `@faker-js/faker` (optional peer) |
|
|
17
19
|
| `@ddd-ts-kit/testing/e2e` | HTTP client, `eventually`, `collectEvents` | Node only: no HTTP framework, no container |
|
|
18
20
|
| `@ddd-ts-kit/testing/contracts` | contract suites of the kernel ports, `ContractSuite` to write another | the runner you pass |
|
|
19
|
-
| `@ddd-ts-kit/testing/vitest` | `decorators()`, the Vite plugin that lets Vitest run standard decorators | Node only; runs the `tsc` of the project |
|
|
21
|
+
| `@ddd-ts-kit/testing/vitest` | `decorators()`, the Vite plugin that lets Vitest run standard decorators, and `decoratorCoverage()` | Node only; runs the `tsc` of the project |
|
|
22
|
+
| `@ddd-ts-kit/testing/coverage` | `governedCoverage()`, `governedJestCoverage()`, `unaffectedContexts()`: the coverage the runner measures and its floor, read from `ddd-ts-kit.config.json` | Node only |
|
|
23
|
+
|
|
24
|
+
The main entry, `/contracts` and `/e2e` follow the
|
|
25
|
+
[test levels](../../docs/ARCHITECTURE.md#7-test-levels); the builders serve every level, and the
|
|
26
|
+
other entries are set up once in the runner config:
|
|
27
|
+
|
|
28
|
+

|
|
29
|
+
|
|
30
|
+
## A first test
|
|
31
|
+
|
|
32
|
+
One aggregate, its builder and two scenarios, in one file for the reading (a project gives the
|
|
33
|
+
aggregate its file under `domain/` and the builder its file under `test/builders/`):
|
|
34
|
+
|
|
35
|
+
<!-- checked sample -->
|
|
36
|
+
```ts
|
|
37
|
+
import { AggregateRoot, ConceptError, type DomainEvent } from '@ddd-ts-kit/kernel';
|
|
38
|
+
import { Builder, given, resetSequences, sequence, type Defaults } from '@ddd-ts-kit/testing';
|
|
39
|
+
import { beforeEach, expect, it } from 'vitest';
|
|
40
|
+
|
|
41
|
+
// The model under test.
|
|
42
|
+
class OrderError extends ConceptError {
|
|
43
|
+
static alreadyPaid(orderId: string) {
|
|
44
|
+
return OrderError.refusal('ORDER_ALREADY_PAID', `Order ${orderId} is already paid.`, { orderId });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
class OrderPaid implements DomainEvent {
|
|
49
|
+
readonly name = 'ordering.order.paid';
|
|
50
|
+
|
|
51
|
+
constructor(
|
|
52
|
+
readonly aggregateId: string,
|
|
53
|
+
readonly occurredAt: Date,
|
|
54
|
+
readonly amount: number,
|
|
55
|
+
) {}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
class Order extends AggregateRoot {
|
|
59
|
+
private paid = false;
|
|
60
|
+
|
|
61
|
+
private constructor(
|
|
62
|
+
id: string,
|
|
63
|
+
readonly amount: number,
|
|
64
|
+
) {
|
|
65
|
+
super(id);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static place(id: string, amount: number): Order {
|
|
69
|
+
return new Order(id, amount);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get isPaid(): boolean {
|
|
73
|
+
return this.paid;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
pay(at: Date): void {
|
|
77
|
+
if (this.paid) {
|
|
78
|
+
throw OrderError.alreadyPaid(this.id);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
this.paid = true;
|
|
82
|
+
this.record(new OrderPaid(this.id, at, this.amount));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The builder: valid defaults once, then methods named in the ubiquitous language.
|
|
87
|
+
const at = new Date('2026-03-01T10:00:00.000Z');
|
|
88
|
+
|
|
89
|
+
interface OrderProps {
|
|
90
|
+
readonly id: string;
|
|
91
|
+
readonly amount: number;
|
|
92
|
+
readonly paid: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
class OrderBuilder extends Builder<OrderProps, Order> {
|
|
96
|
+
protected defaults(): Defaults<OrderProps> {
|
|
97
|
+
return { id: sequence('order'), amount: 30, paid: false };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
protected construct(props: OrderProps): Order {
|
|
101
|
+
const order = Order.place(props.id, props.amount);
|
|
102
|
+
|
|
103
|
+
if (props.paid) {
|
|
104
|
+
order.pay(at);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return order;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
paid(): this {
|
|
111
|
+
return this.with('paid', true);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const anOrder = (): OrderBuilder => new OrderBuilder();
|
|
116
|
+
|
|
117
|
+
// The specs: one intention, then the facts it recorded or the refusal.
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
resetSequences();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('records the payment of a placed order', () => {
|
|
123
|
+
given(anOrder())
|
|
124
|
+
.when((order) => order.pay(at))
|
|
125
|
+
.thenEvents([{ name: 'ordering.order.paid', amount: 30 }]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('refuses to pay an order twice', () => {
|
|
129
|
+
given(anOrder().paid())
|
|
130
|
+
.when((order) => order.pay(at))
|
|
131
|
+
.thenError('ORDER_ALREADY_PAID', { orderId: 'order-1' })
|
|
132
|
+
.thenState((order) => expect(order.isPaid).toBe(true));
|
|
133
|
+
});
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
No database, no container, no mock: the second test reads "given a paid order, when it is paid,
|
|
137
|
+
then it is refused with `ORDER_ALREADY_PAID`, and it is still paid". The sections below take
|
|
138
|
+
each piece in turn.
|
|
20
139
|
|
|
21
140
|
## Builders
|
|
22
141
|
|
|
@@ -71,6 +190,8 @@ const order = anOrder().paid().withLine(aLine().with('sku', 'sku-1')).build();
|
|
|
71
190
|
const orders = anOrder().placedBy(aCustomer().named('Ada')).buildMany(3);
|
|
72
191
|
```
|
|
73
192
|
|
|
193
|
+

|
|
194
|
+
|
|
74
195
|
For plain data (payloads, DTOs, simple value objects) the functional form is enough. It gives
|
|
75
196
|
the generic `with(key, value)` and `withMany(partial)`, both checked against the properties:
|
|
76
197
|
|
|
@@ -97,15 +218,43 @@ aLine().with('quantity', 3).build(); // { sku: 'sku-1', quantity: 3 }
|
|
|
97
218
|
- The class form keeps `with`, `withMany` and `adding` protected on purpose: the public
|
|
98
219
|
surface of an aggregate builder is the language of the domain, not its property names.
|
|
99
220
|
|
|
221
|
+
Instead of creating the whole object in every test, where the one value the rule depends on is
|
|
222
|
+
lost among the others, and where a new argument of `Order.place` breaks every test:
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
const order = Order.place('order-1', { id: 'customer-1', name: 'Grace Hopper' }, [{ sku: 'sku-1', quantity: 1 }]);
|
|
226
|
+
order.pay();
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
write what the test is about, and let the builder hold the rest in one place:
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
const order = anOrder().paid().build();
|
|
233
|
+
```
|
|
234
|
+
|
|
100
235
|
## Generated data
|
|
101
236
|
|
|
102
237
|
```
|
|
103
238
|
npm i -D @faker-js/faker
|
|
104
239
|
```
|
|
105
240
|
|
|
241
|
+
<!-- checked sample -->
|
|
106
242
|
```ts
|
|
243
|
+
import { defineBuilder, sequence } from '@ddd-ts-kit/testing';
|
|
107
244
|
import { generated } from '@ddd-ts-kit/testing/faker';
|
|
108
245
|
|
|
246
|
+
// From the domain of the context.
|
|
247
|
+
interface CustomerProps {
|
|
248
|
+
readonly id: string;
|
|
249
|
+
readonly name: string;
|
|
250
|
+
readonly email: string;
|
|
251
|
+
readonly birthDate: Date;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
declare class Customer {
|
|
255
|
+
static register(props: CustomerProps): Customer;
|
|
256
|
+
}
|
|
257
|
+
|
|
109
258
|
export const aCustomer = defineBuilder<CustomerProps, Customer>({
|
|
110
259
|
defaults: {
|
|
111
260
|
id: sequence('customer'),
|
|
@@ -127,9 +276,29 @@ The defaults are generated yet valid: each generator is constrained to what the
|
|
|
127
276
|
what the rule under test depends on.** A test never asserts a generated value it did not
|
|
128
277
|
state, and never relies on a generated value falling on the right side of a rule.
|
|
129
278
|
|
|
279
|
+
Instead of leaving the value the rule is about to the generator, so that the test passes
|
|
280
|
+
because of a default nobody reads:
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
it('lets an adult buy wine', () => {
|
|
284
|
+
const customer = aCustomer().build(); // adult because the default says so today
|
|
285
|
+
expect(customer.mayBuy(wine, today)).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
state it, with a date the reader can check:
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
it('lets an adult buy wine', () => {
|
|
293
|
+
const customer = aCustomer().with('birthDate', new Date('2008-05-01')).build();
|
|
294
|
+
expect(customer.mayBuy(wine, new Date('2026-05-01'))).toBe(true); // 18 on that day
|
|
295
|
+
});
|
|
296
|
+
```
|
|
297
|
+
|
|
130
298
|
Every `generated` value is drawn from one generator per test file, `dataGenerator()`. Its seed
|
|
131
299
|
comes from `TEST_SEED`, else it is random, and it is always known (`dataGenerator().seed`):
|
|
132
300
|
|
|
301
|
+
<!-- checked sample -->
|
|
133
302
|
```ts
|
|
134
303
|
// vitest setup file
|
|
135
304
|
import { beforeEach, onTestFailed } from 'vitest';
|
|
@@ -237,6 +406,37 @@ error, and fails with an `AssertionError` that names what was refused instead, o
|
|
|
237
406
|
that differ, one line each. The facts given must be carried with those values; the refusal may
|
|
238
407
|
carry others.
|
|
239
408
|
|
|
409
|
+
Instead of asserting the wording of a message, which breaks when it is rephrased, or the class
|
|
410
|
+
of the error, which says nothing about which refusal it was:
|
|
411
|
+
|
|
412
|
+
```ts
|
|
413
|
+
expect(() => order.pay(at)).toThrow('Order order-1 is already paid.');
|
|
414
|
+
expect(() => order.pay(at)).toThrow(OrderError);
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
assert the code, stable for every client, and the facts the refusal carries:
|
|
418
|
+
|
|
419
|
+
<!-- checked sample -->
|
|
420
|
+
```ts
|
|
421
|
+
import { ConceptError } from '@ddd-ts-kit/kernel';
|
|
422
|
+
import { expectRefusal } from '@ddd-ts-kit/testing';
|
|
423
|
+
|
|
424
|
+
class OrderError extends ConceptError {
|
|
425
|
+
static alreadyPaid(orderId: string) {
|
|
426
|
+
return OrderError.refusal('ORDER_ALREADY_PAID', `Order ${orderId} is already paid.`, { orderId });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const order = {
|
|
431
|
+
id: 'order-1',
|
|
432
|
+
pay(): void {
|
|
433
|
+
throw OrderError.alreadyPaid(this.id);
|
|
434
|
+
},
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
expectRefusal(() => order.pay(), 'ORDER_ALREADY_PAID', { orderId: 'order-1' });
|
|
438
|
+
```
|
|
439
|
+
|
|
240
440
|
## Handlers
|
|
241
441
|
|
|
242
442
|
```ts
|
|
@@ -271,6 +471,8 @@ The handler only saves: `await this.orders.save(order); return order.id;`. `disp
|
|
|
271
471
|
gives the fake what the application gives every repository: once `save` (or any method handed
|
|
272
472
|
the aggregate) resolved, the events the aggregate recorded are handed over to `domainEvents`.
|
|
273
473
|
|
|
474
|
+

|
|
475
|
+
|
|
274
476
|
A handler spec has the production timing. `commandBus` runs every command in `unitOfWork`
|
|
275
477
|
(an `InMemoryUnitOfWork`), as the bus of the application does: the events of every save wait
|
|
276
478
|
until the command handler has returned, then are dispatched in recording order, and
|
|
@@ -295,6 +497,88 @@ copy of what is saved (through `snapshots({ toSnapshot, fromSnapshot })` or `{ c
|
|
|
295
497
|
enforces optimistic concurrency on `version`, raising `ConcurrencyError` (code
|
|
296
498
|
`CONCURRENCY_CONFLICT`). Assert its state with `all()` and `count()`.
|
|
297
499
|
|
|
500
|
+
### A fake, not a mock
|
|
501
|
+
|
|
502
|
+
Instead of a mock, which checks the calls the handler made and nothing of their effect:
|
|
503
|
+
|
|
504
|
+
```ts
|
|
505
|
+
const orders = { findById: vi.fn(), save: vi.fn() };
|
|
506
|
+
await new PlaceOrderHandler(orders, ids, clock).execute(new PlaceOrder('sku-1'));
|
|
507
|
+
expect(orders.save).toHaveBeenCalledTimes(1); // still green if the order could never be read back
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
run the real handler through the bus on a fake, and assert what is true afterwards:
|
|
511
|
+
|
|
512
|
+
<!-- checked sample -->
|
|
513
|
+
```ts
|
|
514
|
+
import { AggregateRoot, Command, Repository, type Clock, type CommandHandler, type IdGenerator } from '@ddd-ts-kit/kernel';
|
|
515
|
+
import { createTestBuses, InMemoryRepository, snapshots } from '@ddd-ts-kit/testing';
|
|
516
|
+
import { expect, it } from 'vitest';
|
|
517
|
+
|
|
518
|
+
// From the domain of the context.
|
|
519
|
+
interface OrderSnapshot {
|
|
520
|
+
readonly id: string;
|
|
521
|
+
readonly sku: string;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
declare class Order extends AggregateRoot {
|
|
525
|
+
static place(id: string, sku: string, at: Date): Order;
|
|
526
|
+
static rehydrate(snapshot: OrderSnapshot): Order;
|
|
527
|
+
toSnapshot(): OrderSnapshot;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
abstract class OrderRepository extends Repository<Order> {}
|
|
531
|
+
|
|
532
|
+
// From the application layer: the use case under test.
|
|
533
|
+
class PlaceOrder extends Command<string> {
|
|
534
|
+
constructor(readonly sku: string) {
|
|
535
|
+
super();
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
class PlaceOrderHandler implements CommandHandler<PlaceOrder> {
|
|
540
|
+
constructor(
|
|
541
|
+
private readonly orders: OrderRepository,
|
|
542
|
+
private readonly ids: IdGenerator,
|
|
543
|
+
private readonly clock: Clock,
|
|
544
|
+
) {}
|
|
545
|
+
|
|
546
|
+
async execute(command: PlaceOrder): Promise<string> {
|
|
547
|
+
const order = Order.place(this.ids.next(), command.sku, this.clock.now());
|
|
548
|
+
|
|
549
|
+
await this.orders.save(order);
|
|
550
|
+
|
|
551
|
+
return order.id;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// From test/fakes/: a working repository, held to the same contract suite as the adapter.
|
|
556
|
+
class InMemoryOrderRepository extends InMemoryRepository<Order> implements OrderRepository {
|
|
557
|
+
constructor() {
|
|
558
|
+
super(snapshots({ toSnapshot: (order) => order.toSnapshot(), fromSnapshot: Order.rehydrate }));
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
it('places an order', async () => {
|
|
563
|
+
const { commandBus, dispatching, domainEvents, ids, clock } = createTestBuses();
|
|
564
|
+
const orders = dispatching(new InMemoryOrderRepository());
|
|
565
|
+
const heard: string[] = [];
|
|
566
|
+
|
|
567
|
+
domainEvents.on('ordering.order.placed', async (event) => void heard.push(event.aggregateId));
|
|
568
|
+
commandBus.register(PlaceOrder, new PlaceOrderHandler(orders, ids, clock));
|
|
569
|
+
|
|
570
|
+
const id = await commandBus.execute(new PlaceOrder('sku-1'));
|
|
571
|
+
|
|
572
|
+
expect((await orders.findById(id))?.toSnapshot()).toEqual({ id, sku: 'sku-1' });
|
|
573
|
+
expect(heard).toEqual([id]);
|
|
574
|
+
});
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
The mock breaks when the handler calls another method of the port for the same effect, and stays
|
|
578
|
+
green when the saved order cannot be found again; the fake does neither. The
|
|
579
|
+
[`test/no-mocks`](../eslint-plugin/docs/rules/test-no-mocks.md) rule reports `vi.fn`,
|
|
580
|
+
`vi.mock` and their kin in the tests of a context.
|
|
581
|
+
|
|
298
582
|
### Who is acting
|
|
299
583
|
|
|
300
584
|
`createTestBuses()` also gives `executionScope` and `actorContext`, the two doubles of the
|
|
@@ -329,9 +613,16 @@ The whole application, real adapters included, driven from the outside: requests
|
|
|
329
613
|
and integration events out. Nothing here knows the HTTP framework or how the application is
|
|
330
614
|
composed; `start` says it.
|
|
331
615
|
|
|
616
|
+
<!-- checked sample -->
|
|
332
617
|
```ts
|
|
333
|
-
import { createServer } from 'node:http';
|
|
334
|
-
import {
|
|
618
|
+
import { createServer, type RequestListener } from 'node:http';
|
|
619
|
+
import { listenOnRandomPort, startTestServer } from '@ddd-ts-kit/testing/e2e';
|
|
620
|
+
import { afterAll } from 'vitest';
|
|
621
|
+
|
|
622
|
+
// From the project: its composition root, its HTTP framework, a token of the test identity provider.
|
|
623
|
+
declare function composeApplication(): object;
|
|
624
|
+
declare function expressApp(app: object): RequestListener;
|
|
625
|
+
declare const testToken: string;
|
|
335
626
|
|
|
336
627
|
const app = composeApplication(); // the composition root of main.ts
|
|
337
628
|
const server = await startTestServer({
|
|
@@ -374,8 +665,10 @@ const { body: order } = expectStatus(await client.post<OrderView>('/orders', anO
|
|
|
374
665
|
**Reset between tests.** The harness names the contract and leaves the implementation to the
|
|
375
666
|
project and its driver:
|
|
376
667
|
|
|
668
|
+
<!-- checked sample -->
|
|
377
669
|
```ts
|
|
378
670
|
import type { DatabaseReset } from '@ddd-ts-kit/testing/e2e';
|
|
671
|
+
import { beforeEach } from 'vitest';
|
|
379
672
|
|
|
380
673
|
// PostgreSQL, with any client exposing `query`: every table of the application schemas.
|
|
381
674
|
export function truncateAllTables(db: { query(sql: string): Promise<{ rows: { name: string }[] }> }, schemas: string[]): DatabaseReset {
|
|
@@ -392,13 +685,16 @@ export function truncateAllTables(db: { query(sql: string): Promise<{ rows: { na
|
|
|
392
685
|
};
|
|
393
686
|
}
|
|
394
687
|
|
|
688
|
+
declare const database: DatabaseReset; // truncateAllTables(pool, ['ordering', 'billing']), made once
|
|
689
|
+
|
|
395
690
|
beforeEach(() => database.reset());
|
|
396
691
|
```
|
|
397
692
|
|
|
398
693
|
## Three levels with the same vocabulary
|
|
399
694
|
|
|
400
|
-
One rule, "an order that is already paid cannot be paid again", at three of the four
|
|
401
|
-
levels
|
|
695
|
+
One rule, "an order that is already paid cannot be paid again", at three of the four
|
|
696
|
+
[test levels](../../docs/ARCHITECTURE.md#7-test-levels) (the adapter level is the contract
|
|
697
|
+
suites, below). The builder is the same; only what runs underneath changes.
|
|
402
698
|
|
|
403
699
|
```ts
|
|
404
700
|
// 1. Domain: the aggregate alone. Milliseconds, no double.
|
|
@@ -445,11 +741,35 @@ again.
|
|
|
445
741
|
A fake is only worth trusting if it behaves like the adapter it stands for. Each suite takes
|
|
446
742
|
the runner's functions and a factory, and is run against both.
|
|
447
743
|
|
|
744
|
+

|
|
745
|
+
|
|
746
|
+
<!-- checked sample -->
|
|
448
747
|
```ts
|
|
449
748
|
// contexts/ordering/test/integration/order-repository.contract.spec.ts
|
|
450
749
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
750
|
+
import type { AggregateRoot, Repository } from '@ddd-ts-kit/kernel';
|
|
751
|
+
import type { InMemoryRepository } from '@ddd-ts-kit/testing';
|
|
451
752
|
import { repositoryContract } from '@ddd-ts-kit/testing/contracts';
|
|
452
753
|
|
|
754
|
+
// From the context and its test folder.
|
|
755
|
+
interface Line {
|
|
756
|
+
readonly sku: string;
|
|
757
|
+
readonly quantity: number;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
declare class Order extends AggregateRoot {
|
|
761
|
+
addLine(line: Line): void;
|
|
762
|
+
toSnapshot(): object;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
declare const anOrder: () => { build(): Order };
|
|
766
|
+
declare const aLine: () => { build(): Line };
|
|
767
|
+
// The fake of the Handlers section: it extends InMemoryRepository, it never extends the port.
|
|
768
|
+
declare class InMemoryOrderRepository extends InMemoryRepository<Order> {
|
|
769
|
+
constructor();
|
|
770
|
+
}
|
|
771
|
+
declare function freshDrizzleRepository(): Promise<Repository<Order>>;
|
|
772
|
+
|
|
453
773
|
const runner = { describe, it, expect, beforeEach, afterEach };
|
|
454
774
|
const options = {
|
|
455
775
|
createAggregate: () => anOrder().build(),
|
|
@@ -484,8 +804,8 @@ repositoryContract(runner, { ...options, name: 'DrizzleOrderRepository', createR
|
|
|
484
804
|
| `fileStorageContract` | any `FileStorage`: content round trip with size, media type and checksum, several mebibytes streamed, a write beyond `maxBytes` refused leaving the previous content, keys that could leave their place refused, deletion, copy, move, listing by prefix, signed download and upload links that expire, refuse a changed signature or another media type, and are never signed for more than an hour, a download served as an attachment even without a name. `follow` sends a request to a link: `fetch` by default, `followServed(storage)` for a storage the application serves |
|
|
485
805
|
| `rateLimiterContract` | any `RateLimiter`: limit, `remaining` and `retryAfterMs`, refused calls not counted, sliding window, isolation of keys, concurrent calls |
|
|
486
806
|
|
|
487
|
-
The cache, lock
|
|
488
|
-
subject: advance the clock it reads, or really wait when the time is a server's. The job
|
|
807
|
+
The cache, lock, rate limiter and file storage suites also take `elapse(ms)`, which makes time
|
|
808
|
+
pass for the subject: advance the clock it reads, or really wait when the time is a server's. The job
|
|
489
809
|
scheduler suite takes it from `createScheduler`, with the scheduler and the scope it runs its
|
|
490
810
|
jobs in: `{ scheduler, scope, elapse }`. It asks for a few seconds at most, with jobs of every
|
|
491
811
|
second.
|
|
@@ -507,9 +827,15 @@ runner is the `ContractRunner` type.
|
|
|
507
827
|
A package that holds a port of its own publishes its contract on the same base, `ContractSuite`:
|
|
508
828
|
a subject created anew before each test, and `it`, `expect`, `afterEach` bound to the runner.
|
|
509
829
|
|
|
830
|
+
<!-- checked sample -->
|
|
510
831
|
```ts
|
|
511
832
|
import { ContractSuite, type Awaitable, type ContractRunner } from '@ddd-ts-kit/testing/contracts';
|
|
512
833
|
|
|
834
|
+
// The port the package holds.
|
|
835
|
+
export interface Geocoder {
|
|
836
|
+
locate(address: string): Promise<{ readonly latitude: number; readonly longitude: number } | undefined>;
|
|
837
|
+
}
|
|
838
|
+
|
|
513
839
|
export function geocoderContract(runner: ContractRunner, options: { name: string; create(): Awaitable<Geocoder> }): void {
|
|
514
840
|
new GeocoderSuite(runner, () => options.create()).describe(`${options.name} (geocoder contract)`);
|
|
515
841
|
}
|
|
@@ -531,8 +857,14 @@ shows through `JSON.stringify`, an object spread, the values `Object.keys` leads
|
|
|
531
857
|
`util.inspect` at any depth, hidden properties included. The failure names the view and the
|
|
532
858
|
rank of the secret, never its value.
|
|
533
859
|
|
|
860
|
+
<!-- checked sample -->
|
|
534
861
|
```ts
|
|
862
|
+
import { KafkaEventBus } from '@ddd-ts-kit/kafka';
|
|
535
863
|
import { expectNoSecret } from '@ddd-ts-kit/testing';
|
|
864
|
+
import { it } from 'vitest';
|
|
865
|
+
|
|
866
|
+
declare const brokers: string;
|
|
867
|
+
declare const password: string;
|
|
536
868
|
|
|
537
869
|
it('shows no credential', () => {
|
|
538
870
|
const bus = new KafkaEventBus({ brokers, client: { 'sasl.password': password } });
|
|
@@ -547,6 +879,7 @@ Vitest 5 (Vite 8, Oxc) lowers legacy decorators and does not lower standard (TC3
|
|
|
547
879
|
spec that imports a `.ts` file holding `@CommandHandler(...)` or `@Retry(...)` stops on a
|
|
548
880
|
syntax error. `decorators()` closes that gap, and only that one.
|
|
549
881
|
|
|
882
|
+
<!-- checked sample -->
|
|
550
883
|
```ts
|
|
551
884
|
// vitest.config.ts
|
|
552
885
|
import { defineConfig } from 'vitest/config';
|
|
@@ -588,6 +921,7 @@ It does not type-check (the build does), and it does not touch `.tsx`.
|
|
|
588
921
|
to, read from the `coverage` key of `ddd-ts-kit.config.json` (the reference when the key is
|
|
589
922
|
absent), so that no threshold is written in a runner config:
|
|
590
923
|
|
|
924
|
+
<!-- checked sample -->
|
|
591
925
|
```ts
|
|
592
926
|
// vitest.config.ts
|
|
593
927
|
import { governedCoverage } from '@ddd-ts-kit/testing/coverage';
|
|
@@ -603,6 +937,11 @@ export default defineConfig(({ mode }) => ({
|
|
|
603
937
|
measures `infrastructure`, any other mode the four.
|
|
604
938
|
- `thresholds`: one entry per context and layer that holds a source file, so a context cannot
|
|
605
939
|
hide behind another and an empty layer is held to nothing until its first file.
|
|
940
|
+
- A partial run measures and holds only the contexts named in `DDD_TS_KIT_AFFECTED` (comma
|
|
941
|
+
separated, as [`ddd-ts-kit affected`](../cli/README.md#affected) computes them for a pull
|
|
942
|
+
request); unset, the run covers every context. `unaffectedContexts()` answers the directories
|
|
943
|
+
of the other contexts, ending in `/`, for the runner to exclude their specs:
|
|
944
|
+
``exclude: unaffectedContexts().map((directory) => `${directory}**`)``.
|
|
606
945
|
- `governedJestCoverage()` gives the same as `collectCoverageFrom` and `coverageThreshold`.
|
|
607
946
|
- `REFERENCE_COVERAGE` is the reference: domain 90/85/90/90, application 85/80/85/85,
|
|
608
947
|
presentation 80/70/80/80, infrastructure 70/60/70/70 (statements, branches, functions, lines).
|
|
@@ -611,3 +950,13 @@ Under the legacy decorator mode with `emitDecoratorMetadata`, add `decoratorCove
|
|
|
611
950
|
`@ddd-ts-kit/testing/vitest` to `plugins`: the statements the transform writes to apply
|
|
612
951
|
decorators hold, for every type a decorated class names, a branch no test can take; the plugin
|
|
613
952
|
marks them with the hint the `v8` provider reads, as the provider does by itself for SWC.
|
|
953
|
+
|
|
954
|
+
## See also
|
|
955
|
+
|
|
956
|
+
- [Testing strategy](../../docs/concepts/testing-strategy.md): why fakes and not mocks, the four
|
|
957
|
+
levels, what to test where
|
|
958
|
+
- [Test runners](../../docs/tools/test-runners.md): Vitest or Jest, and what each gives with the kit
|
|
959
|
+
- [`@ddd-ts-kit/di`, tests](../di/README.md#tests): `createTestApplication` and `fake`, the
|
|
960
|
+
application spec with the container
|
|
961
|
+
- [`@ddd-ts-kit/kernel`](../kernel/README.md): the aggregates, ports and in-memory adapters the
|
|
962
|
+
builders and fakes work with
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ddd-ts-kit/testing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "Readable tests without mocks: test data builders, given/when/then scenarios and contract suites shared by fakes and real adapters.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -46,13 +46,13 @@
|
|
|
46
46
|
"dist"
|
|
47
47
|
],
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@ddd-ts-kit/kernel": "0.
|
|
49
|
+
"@ddd-ts-kit/kernel": "0.16.1"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
|
-
"@ddd-ts-kit/aspects": "0.
|
|
53
|
-
"@ddd-ts-kit/events": "0.
|
|
52
|
+
"@ddd-ts-kit/aspects": "0.16.1",
|
|
53
|
+
"@ddd-ts-kit/events": "0.16.1",
|
|
54
54
|
"@faker-js/faker": "^10.6.0",
|
|
55
|
-
"@ddd-ts-kit/scheduling": "0.
|
|
55
|
+
"@ddd-ts-kit/scheduling": "0.16.1"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"@faker-js/faker": ">=9.0.0"
|