@codefast/di-testing 0.1.1 → 0.1.3
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/CHANGELOG.md +27 -0
- package/LICENSE +1 -1
- package/README.md +143 -66
- package/dist/discovery/dependency-scanner.d.ts +1 -2
- package/dist/discovery/dependency-scanner.js +1 -2
- package/dist/discovery/mock-binder.d.ts +1 -2
- package/dist/discovery/mock-binder.js +1 -2
- package/dist/errors/errors.d.ts +1 -2
- package/dist/errors/errors.js +1 -2
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1 -2
- package/dist/mocking/auto-mock.d.ts +1 -2
- package/dist/mocking/auto-mock.js +1 -2
- package/dist/mocking/mock-factory.d.ts +1 -2
- package/dist/mocking/mock-factory.js +1 -2
- package/dist/mocking/spy.d.ts +1 -2
- package/dist/mocking/spy.js +1 -2
- package/dist/test-bed/bed-builder.d.ts +1 -2
- package/dist/test-bed/bed-builder.js +1 -2
- package/dist/test-bed/sociable-builder.d.ts +1 -2
- package/dist/test-bed/sociable-builder.js +1 -2
- package/dist/test-bed/solitary-builder.d.ts +1 -2
- package/dist/test-bed/solitary-builder.js +1 -2
- package/dist/test-bed/test-bed.d.ts +1 -2
- package/dist/test-bed/test-bed.js +1 -2
- package/dist/test-bed/unit-test-bed.d.ts +1 -2
- package/dist/test-bed/unit-test-bed.js +1 -2
- package/dist/types.d.ts +1 -2
- package/dist/types.js +1 -2
- package/package.json +7 -55
- package/dist/discovery/dependency-scanner.d.ts.map +0 -1
- package/dist/discovery/dependency-scanner.js.map +0 -1
- package/dist/discovery/mock-binder.d.ts.map +0 -1
- package/dist/discovery/mock-binder.js.map +0 -1
- package/dist/errors/errors.d.ts.map +0 -1
- package/dist/errors/errors.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/mocking/auto-mock.d.ts.map +0 -1
- package/dist/mocking/auto-mock.js.map +0 -1
- package/dist/mocking/mock-factory.d.ts.map +0 -1
- package/dist/mocking/mock-factory.js.map +0 -1
- package/dist/mocking/spy.d.ts.map +0 -1
- package/dist/mocking/spy.js.map +0 -1
- package/dist/test-bed/bed-builder.d.ts.map +0 -1
- package/dist/test-bed/bed-builder.js.map +0 -1
- package/dist/test-bed/sociable-builder.d.ts.map +0 -1
- package/dist/test-bed/sociable-builder.js.map +0 -1
- package/dist/test-bed/solitary-builder.d.ts.map +0 -1
- package/dist/test-bed/solitary-builder.js.map +0 -1
- package/dist/test-bed/test-bed.d.ts.map +0 -1
- package/dist/test-bed/test-bed.js.map +0 -1
- package/dist/test-bed/unit-test-bed.d.ts.map +0 -1
- package/dist/test-bed/unit-test-bed.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
- package/src/discovery/dependency-scanner.ts +0 -80
- package/src/discovery/mock-binder.ts +0 -334
- package/src/errors/errors.ts +0 -119
- package/src/index.ts +0 -26
- package/src/mocking/auto-mock.ts +0 -243
- package/src/mocking/mock-factory.ts +0 -35
- package/src/mocking/spy.ts +0 -92
- package/src/test-bed/bed-builder.ts +0 -172
- package/src/test-bed/sociable-builder.ts +0 -148
- package/src/test-bed/solitary-builder.ts +0 -70
- package/src/test-bed/test-bed.ts +0 -64
- package/src/test-bed/unit-test-bed.ts +0 -161
- package/src/types.ts +0 -15
package/src/mocking/auto-mock.ts
DELETED
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
/** The lazy `Proxy` that mocks an erased interface one accessed property at a time. */
|
|
2
|
-
|
|
3
|
-
import type { MockFactory, MockFunction } from "#/mocking/mock-factory";
|
|
4
|
-
import type { Spy } from "#/mocking/spy";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* A mocked view of `Dependency`: every member becomes a spy, nested objects are mocked in turn.
|
|
8
|
-
*
|
|
9
|
-
* @remarks With the default backend a method is a precisely-typed {@link Spy}; a custom backend
|
|
10
|
-
* (`vi.fn`, `jest.fn`, a Sinon stub) intersects its own native surface with the method's signature,
|
|
11
|
-
* so backend-specific APIs like `mockReturnValueOnce` or `returns` type-check without adapters. The
|
|
12
|
-
* check is mutual, so a backend that merely resembles {@link Spy} still keeps its own surface.
|
|
13
|
-
*
|
|
14
|
-
* @typeParam Dependency - The dependency type being mocked.
|
|
15
|
-
* @typeParam Backend - The spy type the active mock factory produces.
|
|
16
|
-
*
|
|
17
|
-
* @since 0.1.0
|
|
18
|
-
*/
|
|
19
|
-
export type Mocked<Dependency, Backend extends MockFunction = Spy> = Dependency extends (
|
|
20
|
-
...args: infer Args
|
|
21
|
-
) => infer Return
|
|
22
|
-
? [Backend] extends [Spy]
|
|
23
|
-
? [Spy] extends [Backend]
|
|
24
|
-
? Spy<Args, Return>
|
|
25
|
-
: Backend & ((...args: Args) => Return)
|
|
26
|
-
: Backend & ((...args: Args) => Return)
|
|
27
|
-
: Dependency extends object
|
|
28
|
-
? { [Key in keyof Dependency]: Mocked<Dependency[Key], Backend> }
|
|
29
|
-
: Dependency;
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* A recursively optional view of `Dependency` — the shape a hand-written `.stub` seed may supply.
|
|
33
|
-
*
|
|
34
|
-
* @remarks Functions are kept whole (a stub replaces a whole method), everything else is made
|
|
35
|
-
* optional so only the members a test cares about need spelling out.
|
|
36
|
-
*
|
|
37
|
-
* @typeParam Dependency - The dependency type being partially stubbed.
|
|
38
|
-
*
|
|
39
|
-
* @since 0.1.0
|
|
40
|
-
*/
|
|
41
|
-
export type DeepPartial<Dependency> = Dependency extends (...args: Array<never>) => unknown
|
|
42
|
-
? Dependency
|
|
43
|
-
: Dependency extends object
|
|
44
|
-
? { [Key in keyof Dependency]?: DeepPartial<Dependency[Key]> }
|
|
45
|
-
: Dependency;
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* The key an auto-mock answers with its reset routine, clearing the root spy, every materialized
|
|
49
|
-
* member, and the spies of its seed.
|
|
50
|
-
*
|
|
51
|
-
* @since 0.1.0
|
|
52
|
-
*/
|
|
53
|
-
export const MOCK_RESET: unique symbol = Symbol("di-testing:mock-reset");
|
|
54
|
-
|
|
55
|
-
// `then` would make every mock thenable and stall `await`; `toJSON` and `asymmetricMatch` are probed
|
|
56
|
-
// by serializers and expect() and must not answer as callables.
|
|
57
|
-
const UNMOCKED_KEYS: ReadonlySet<string> = new Set(["then", "toJSON", "asymmetricMatch"]);
|
|
58
|
-
|
|
59
|
-
// Inherited members that inspection and printing rely on; every other inherited key (`apply`,
|
|
60
|
-
// `call`, `bind`, the throwing `caller`/`arguments`) is fair game for a domain interface.
|
|
61
|
-
const INHERITED_PASSTHROUGH: ReadonlySet<string> = new Set([
|
|
62
|
-
"constructor",
|
|
63
|
-
"toString",
|
|
64
|
-
"toLocaleString",
|
|
65
|
-
"valueOf",
|
|
66
|
-
"hasOwnProperty",
|
|
67
|
-
"isPrototypeOf",
|
|
68
|
-
"propertyIsEnumerable",
|
|
69
|
-
]);
|
|
70
|
-
|
|
71
|
-
/** Clears one spy through whichever reset method its backend spells, recursing into auto-mocks. */
|
|
72
|
-
function resetSpy(spy: unknown): void {
|
|
73
|
-
if (spy === null || (typeof spy !== "object" && typeof spy !== "function")) {
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
const candidate = spy as {
|
|
77
|
-
[MOCK_RESET]?: () => void;
|
|
78
|
-
mockReset?: () => void;
|
|
79
|
-
reset?: () => void;
|
|
80
|
-
resetHistory?: () => void;
|
|
81
|
-
};
|
|
82
|
-
if (typeof candidate[MOCK_RESET] === "function") {
|
|
83
|
-
candidate[MOCK_RESET]();
|
|
84
|
-
} else if (typeof candidate.mockReset === "function") {
|
|
85
|
-
candidate.mockReset();
|
|
86
|
-
} else if (typeof candidate.reset === "function") {
|
|
87
|
-
candidate.reset();
|
|
88
|
-
} else if (typeof candidate.resetHistory === "function") {
|
|
89
|
-
candidate.resetHistory();
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Whether the seed supplies `key` itself — its prototype chain counts, the global prototypes don't. */
|
|
94
|
-
function seedProvides(seed: object, key: PropertyKey): boolean {
|
|
95
|
-
let current: object | null = seed;
|
|
96
|
-
while (current !== null && current !== Object.prototype && current !== Function.prototype) {
|
|
97
|
-
if (Object.hasOwn(current, key)) {
|
|
98
|
-
return true;
|
|
99
|
-
}
|
|
100
|
-
current = Object.getPrototypeOf(current);
|
|
101
|
-
}
|
|
102
|
-
return false;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Builds a lazy auto-mock for an erased interface: each accessed member becomes a cached child mock.
|
|
107
|
-
*
|
|
108
|
-
* @remarks The proxy target is itself a spy, so a function-typed dependency records calls, and every
|
|
109
|
-
* materialized member is another auto-mock, so nested access (`repo.user.create`) works to any depth.
|
|
110
|
-
* A `seed` wins for the members it supplies (inherited ones included; a primitive seed is returned
|
|
111
|
-
* as the value itself); keys the root spy owns pass through, so its backend API stays real — which
|
|
112
|
-
* also means a member sharing a name with a function's own `name`/`length` cannot be auto-mocked.
|
|
113
|
-
*
|
|
114
|
-
* @typeParam Dependency - The dependency type being mocked.
|
|
115
|
-
* @typeParam Backend - The spy type the factory produces, threaded into the returned `Mocked` view.
|
|
116
|
-
*
|
|
117
|
-
* @since 0.1.0
|
|
118
|
-
*/
|
|
119
|
-
export function createAutoMock<Dependency, Backend extends MockFunction = Spy>(
|
|
120
|
-
mockFactory: MockFactory<Backend>,
|
|
121
|
-
seed?: DeepPartial<Dependency>,
|
|
122
|
-
): Mocked<Dependency, Backend> {
|
|
123
|
-
// A primitive stub has no members to mock — the seed is the dependency's whole value.
|
|
124
|
-
if (seed !== undefined && (typeof seed !== "object" || seed === null) && typeof seed !== "function") {
|
|
125
|
-
return seed as Mocked<Dependency, Backend>;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const cache = new Map<string, unknown>();
|
|
129
|
-
const methodWrappers = new Map<string, unknown>();
|
|
130
|
-
const seedRecord = seed as Record<PropertyKey, unknown> | undefined;
|
|
131
|
-
const target = mockFactory();
|
|
132
|
-
let self: unknown;
|
|
133
|
-
|
|
134
|
-
const reset = (): void => {
|
|
135
|
-
resetSpy(target);
|
|
136
|
-
for (const child of cache.values()) {
|
|
137
|
-
resetSpy(child);
|
|
138
|
-
}
|
|
139
|
-
if (seedRecord !== undefined) {
|
|
140
|
-
for (const value of Object.values(seedRecord)) {
|
|
141
|
-
resetSpy(value);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
const proxy = new Proxy(target, {
|
|
147
|
-
get(fnTarget, key, receiver): unknown {
|
|
148
|
-
if (key === MOCK_RESET) {
|
|
149
|
-
return reset;
|
|
150
|
-
}
|
|
151
|
-
if (seedRecord !== undefined && seedProvides(seedRecord, key)) {
|
|
152
|
-
// A non-configurable, non-writable target property must report its own value (proxy invariant).
|
|
153
|
-
const own = Reflect.getOwnPropertyDescriptor(fnTarget, key);
|
|
154
|
-
if (own !== undefined && own.configurable === false && own.writable === false) {
|
|
155
|
-
return own.value;
|
|
156
|
-
}
|
|
157
|
-
return Reflect.get(seedRecord, key);
|
|
158
|
-
}
|
|
159
|
-
if (typeof key === "symbol" || UNMOCKED_KEYS.has(key)) {
|
|
160
|
-
return Reflect.get(fnTarget, key, receiver);
|
|
161
|
-
}
|
|
162
|
-
if (Object.hasOwn(fnTarget, key)) {
|
|
163
|
-
const value = Reflect.get(fnTarget, key);
|
|
164
|
-
if (typeof value !== "function") {
|
|
165
|
-
return value;
|
|
166
|
-
}
|
|
167
|
-
// The backend's own methods run against the target, and a chainable return re-enters the mock.
|
|
168
|
-
let wrapper = methodWrappers.get(key);
|
|
169
|
-
if (wrapper === undefined) {
|
|
170
|
-
wrapper = (...args: ReadonlyArray<unknown>): unknown => {
|
|
171
|
-
const result = (value as (...call: ReadonlyArray<unknown>) => unknown).call(fnTarget, ...args);
|
|
172
|
-
return result === fnTarget ? self : result;
|
|
173
|
-
};
|
|
174
|
-
methodWrappers.set(key, wrapper);
|
|
175
|
-
}
|
|
176
|
-
return wrapper;
|
|
177
|
-
}
|
|
178
|
-
if (INHERITED_PASSTHROUGH.has(key)) {
|
|
179
|
-
return Reflect.get(fnTarget, key);
|
|
180
|
-
}
|
|
181
|
-
let child = cache.get(key);
|
|
182
|
-
if (child === undefined) {
|
|
183
|
-
child = createAutoMock(mockFactory);
|
|
184
|
-
cache.set(key, child);
|
|
185
|
-
}
|
|
186
|
-
return child;
|
|
187
|
-
},
|
|
188
|
-
// `in` agrees with `get`: any mockable string key answers true, everything else asks the target.
|
|
189
|
-
has(fnTarget, key): boolean {
|
|
190
|
-
if (key === MOCK_RESET) {
|
|
191
|
-
return true;
|
|
192
|
-
}
|
|
193
|
-
if (seedRecord !== undefined && seedProvides(seedRecord, key)) {
|
|
194
|
-
return true;
|
|
195
|
-
}
|
|
196
|
-
if (typeof key === "symbol" || UNMOCKED_KEYS.has(key)) {
|
|
197
|
-
return Reflect.has(fnTarget, key);
|
|
198
|
-
}
|
|
199
|
-
return true;
|
|
200
|
-
},
|
|
201
|
-
// Enumeration shows the interface's materialized members, not the spy backend's internals.
|
|
202
|
-
ownKeys(fnTarget): Array<string | symbol> {
|
|
203
|
-
const keys = new Set<string | symbol>();
|
|
204
|
-
if (seedRecord !== undefined) {
|
|
205
|
-
for (const key of Reflect.ownKeys(seedRecord)) {
|
|
206
|
-
keys.add(key);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
for (const key of cache.keys()) {
|
|
210
|
-
keys.add(key);
|
|
211
|
-
}
|
|
212
|
-
// Non-configurable target keys must be reported (proxy invariant); the rest stay hidden.
|
|
213
|
-
for (const key of Reflect.ownKeys(fnTarget)) {
|
|
214
|
-
const descriptor = Reflect.getOwnPropertyDescriptor(fnTarget, key);
|
|
215
|
-
if (descriptor !== undefined && descriptor.configurable === false) {
|
|
216
|
-
keys.add(key);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
return [...keys];
|
|
220
|
-
},
|
|
221
|
-
getOwnPropertyDescriptor(fnTarget, key): PropertyDescriptor | undefined {
|
|
222
|
-
if (typeof key === "string" && (cache.has(key) || (seedRecord !== undefined && seedProvides(seedRecord, key)))) {
|
|
223
|
-
return {
|
|
224
|
-
configurable: true,
|
|
225
|
-
enumerable: true,
|
|
226
|
-
writable: true,
|
|
227
|
-
value: (proxy as Record<string, unknown>)[key],
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
return Reflect.getOwnPropertyDescriptor(fnTarget, key);
|
|
231
|
-
},
|
|
232
|
-
deleteProperty(fnTarget, key): boolean {
|
|
233
|
-
if (typeof key === "string" && cache.delete(key)) {
|
|
234
|
-
return true;
|
|
235
|
-
}
|
|
236
|
-
return Reflect.deleteProperty(fnTarget, key);
|
|
237
|
-
},
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
self = proxy;
|
|
241
|
-
|
|
242
|
-
return proxy as unknown as Mocked<Dependency, Backend>;
|
|
243
|
-
}
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
/** The pluggable seam that decides which spy backend the auto-mocks are built from. */
|
|
2
|
-
|
|
3
|
-
import type { Spy } from "#/mocking/spy";
|
|
4
|
-
import { createSpy } from "#/mocking/spy";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* The loose callable an auto-mock materializes for each accessed property.
|
|
8
|
-
*
|
|
9
|
-
* @remarks Deliberately structural so any backend qualifies: the built-in {@link Spy}, a Vitest
|
|
10
|
-
* `vi.fn()`, a `jest.fn()`, or a Sinon stub all satisfy it.
|
|
11
|
-
*
|
|
12
|
-
* @since 0.1.0
|
|
13
|
-
*/
|
|
14
|
-
export type MockFunction = (...args: ReadonlyArray<unknown>) => unknown;
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* The factory each auto-mocked member is created by.
|
|
18
|
-
*
|
|
19
|
-
* @remarks `Backend` is the spy type the factory returns, and it flows through the whole test bed:
|
|
20
|
-
* `Mocked` members, `mocks.get`, and the `.stub` callback are all typed against it, so
|
|
21
|
-
* `() => vi.fn()` gives every mock Vitest's own surface and `() => sinon.stub()` gives Sinon's —
|
|
22
|
-
* with no adapter package and no module augmentation.
|
|
23
|
-
*
|
|
24
|
-
* @typeParam Backend - The spy type one factory call produces; defaults to the loose callable.
|
|
25
|
-
*
|
|
26
|
-
* @since 0.1.0
|
|
27
|
-
*/
|
|
28
|
-
export type MockFactory<Backend extends MockFunction = MockFunction> = () => Backend;
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* The default `MockFactory` — one built-in {@link Spy} per call, with no test-framework dependency.
|
|
32
|
-
*
|
|
33
|
-
* @since 0.1.0
|
|
34
|
-
*/
|
|
35
|
-
export const defaultMockFactory: MockFactory<Spy> = () => createSpy();
|
package/src/mocking/spy.ts
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
/** The built-in zero-dependency spy the default mock factory hands out. */
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* One recorded invocation's outcome — a returned value or a thrown error.
|
|
5
|
-
*
|
|
6
|
-
* @since 0.1.0
|
|
7
|
-
*/
|
|
8
|
-
export interface SpyResult {
|
|
9
|
-
readonly type: "return" | "throw";
|
|
10
|
-
readonly value: unknown;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* The call log a spy exposes for assertions.
|
|
15
|
-
*
|
|
16
|
-
* @typeParam Args - The spy's argument tuple.
|
|
17
|
-
*
|
|
18
|
-
* @since 0.1.0
|
|
19
|
-
*/
|
|
20
|
-
export interface SpyState<Args extends ReadonlyArray<unknown>> {
|
|
21
|
-
readonly calls: ReadonlyArray<Args>;
|
|
22
|
-
readonly results: ReadonlyArray<SpyResult>;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/* oxlint-disable typescript/no-explicit-any -- a spy must be assignable to any return type */
|
|
26
|
-
/**
|
|
27
|
-
* The built-in zero-dependency spy: callable, records calls, and takes a return value or implementation.
|
|
28
|
-
*
|
|
29
|
-
* @remarks `Return` defaults to `any` so a spy drops into any typed method slot, exactly as Vitest's
|
|
30
|
-
* own mock type does — the package's one deliberate `any`.
|
|
31
|
-
*
|
|
32
|
-
* @since 0.1.0
|
|
33
|
-
*/
|
|
34
|
-
export interface Spy<Args extends ReadonlyArray<unknown> = ReadonlyArray<unknown>, Return = any> {
|
|
35
|
-
(...args: Args): Return;
|
|
36
|
-
/** The recorded calls and their outcomes. */
|
|
37
|
-
readonly mock: SpyState<Args>;
|
|
38
|
-
/** Sets a fixed value returned by every subsequent call. */
|
|
39
|
-
mockReturnValue(value: Return): this;
|
|
40
|
-
/** Replaces the spy's behaviour with `fn`, still recording each call. */
|
|
41
|
-
mockImplementation(fn: (...args: Args) => Return): this;
|
|
42
|
-
/** Clears the recorded calls and any configured return value or implementation. */
|
|
43
|
-
mockReset(): void;
|
|
44
|
-
}
|
|
45
|
-
/* oxlint-enable typescript/no-explicit-any */
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Creates a fresh zero-dependency spy that records its calls and returns `undefined` until configured.
|
|
49
|
-
*
|
|
50
|
-
* @since 0.1.0
|
|
51
|
-
*/
|
|
52
|
-
export function createSpy(): Spy {
|
|
53
|
-
const calls: Array<ReadonlyArray<unknown>> = [];
|
|
54
|
-
const results: Array<SpyResult> = [];
|
|
55
|
-
let implementation: ((...args: ReadonlyArray<unknown>) => unknown) | undefined;
|
|
56
|
-
let returnValue: unknown;
|
|
57
|
-
|
|
58
|
-
const spy = Object.assign(
|
|
59
|
-
(...args: ReadonlyArray<unknown>): unknown => {
|
|
60
|
-
calls.push(args);
|
|
61
|
-
try {
|
|
62
|
-
const value = implementation === undefined ? returnValue : implementation(...args);
|
|
63
|
-
results.push({ type: "return", value });
|
|
64
|
-
return value;
|
|
65
|
-
} catch (error) {
|
|
66
|
-
results.push({ type: "throw", value: error });
|
|
67
|
-
throw error;
|
|
68
|
-
}
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
mock: { calls, results },
|
|
72
|
-
mockReturnValue(value: unknown): Spy {
|
|
73
|
-
returnValue = value;
|
|
74
|
-
implementation = undefined;
|
|
75
|
-
return spy;
|
|
76
|
-
},
|
|
77
|
-
mockImplementation(fn: (...args: ReadonlyArray<unknown>) => unknown): Spy {
|
|
78
|
-
implementation = fn;
|
|
79
|
-
return spy;
|
|
80
|
-
},
|
|
81
|
-
// Cleared in place so `mock` stays one stable object across resets.
|
|
82
|
-
mockReset(): void {
|
|
83
|
-
calls.length = 0;
|
|
84
|
-
results.length = 0;
|
|
85
|
-
implementation = undefined;
|
|
86
|
-
returnValue = undefined;
|
|
87
|
-
},
|
|
88
|
-
},
|
|
89
|
-
) as Spy;
|
|
90
|
-
|
|
91
|
-
return spy;
|
|
92
|
-
}
|
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
/** The override-recording core both test-bed builders extend. */
|
|
2
|
-
|
|
3
|
-
import type { Constructor, Container, DependencyKey, InjectOptions, MetadataReader } from "@codefast/di";
|
|
4
|
-
import { defaultMetadataReader } from "@codefast/di";
|
|
5
|
-
import { verifyingMetadataReader } from "@codefast/di/metadata/verifying-metadata-reader";
|
|
6
|
-
|
|
7
|
-
import type { BoundMock, SlotCriteria, SlottedOverride } from "#/discovery/mock-binder";
|
|
8
|
-
import { criteriaEquals, normalizeCriteria } from "#/discovery/mock-binder";
|
|
9
|
-
import type { DeepPartial } from "#/mocking/auto-mock";
|
|
10
|
-
import type { MockFactory, MockFunction } from "#/mocking/mock-factory";
|
|
11
|
-
import { defaultMockFactory } from "#/mocking/mock-factory";
|
|
12
|
-
import type { Spy } from "#/mocking/spy";
|
|
13
|
-
import type { InjectionIdentifier } from "#/types";
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Options that configure a whole test-bed compile.
|
|
17
|
-
*
|
|
18
|
-
* @typeParam Backend - The spy type the mock factory produces; it flows into every `Mocked` member,
|
|
19
|
-
* `mocks.get`, and the `.stub` callback, so `() => vi.fn()` yields Vitest's own mock typing.
|
|
20
|
-
*
|
|
21
|
-
* @since 0.1.0
|
|
22
|
-
*/
|
|
23
|
-
export interface TestBedOptions<Backend extends MockFunction = Spy> {
|
|
24
|
-
/** Spy factory each auto-mock property is materialized with; defaults to the built-in spy. */
|
|
25
|
-
readonly mockFactory?: MockFactory<Backend> | undefined;
|
|
26
|
-
/** Reader the dependency scan and the compile container both consult; defaults to di's reader. */
|
|
27
|
-
readonly metadataReader?: MetadataReader | undefined;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* The override step: choose how one named dependency is supplied instead of a plain auto-mock.
|
|
32
|
-
*
|
|
33
|
-
* @typeParam Dependency - The dependency's value type.
|
|
34
|
-
* @typeParam Owner - The builder the chain returns to.
|
|
35
|
-
* @typeParam Backend - The spy type the bed's mock factory produces.
|
|
36
|
-
*
|
|
37
|
-
* @since 0.1.0
|
|
38
|
-
*/
|
|
39
|
-
export interface MockOverrideBuilder<Dependency, Owner, Backend extends MockFunction = Spy> {
|
|
40
|
-
/**
|
|
41
|
-
* Supplies a fixed value for this dependency.
|
|
42
|
-
*
|
|
43
|
-
* @remarks The value is bound as-is and sealed: it has no mock surface, so `mocks.get` refuses
|
|
44
|
-
* it rather than hand it back mistyped — the test already holds the reference it passed in.
|
|
45
|
-
*/
|
|
46
|
-
using(value: Dependency): Owner;
|
|
47
|
-
/**
|
|
48
|
-
* Supplies a partial stub, built from the active spy factory; unlisted members stay auto-mocked.
|
|
49
|
-
*
|
|
50
|
-
* @remarks The callback runs once per compile, so beds built from one builder never share spies.
|
|
51
|
-
*/
|
|
52
|
-
stub(setup: (fn: MockFactory<Backend>) => DeepPartial<Dependency>): Owner;
|
|
53
|
-
/** Leaves the dependency unbound: an `optional()` slot resolves `undefined`, an `injectAll()` slot `[]`. */
|
|
54
|
-
absent(): Owner;
|
|
55
|
-
/** Supplies every element of an unconstrained `injectAll()` slot, in order. Sealed like `.using`. */
|
|
56
|
-
usingAll(values: ReadonlyArray<Dependency>): Owner;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* What a builder's prepare step hands the compile template: the container and the bound mock entries.
|
|
61
|
-
*
|
|
62
|
-
* @since 0.1.0
|
|
63
|
-
*/
|
|
64
|
-
export interface PreparedBed {
|
|
65
|
-
readonly container: Container;
|
|
66
|
-
readonly mocks: ReadonlyMap<DependencyKey, ReadonlyArray<BoundMock>>;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* The shared builder core: records overrides, resolves the reader and mock factory, and owns the
|
|
71
|
-
* compile template that disposes the container when a build fails.
|
|
72
|
-
*
|
|
73
|
-
* @remarks Fields are `protected` rather than `#` so the two concrete builders stay thin; nothing
|
|
74
|
-
* outside `test-bed/` extends this class.
|
|
75
|
-
*
|
|
76
|
-
* @typeParam Class - The class under test.
|
|
77
|
-
* @typeParam Backend - The spy type the bed's mock factory produces.
|
|
78
|
-
*
|
|
79
|
-
* @since 0.1.0
|
|
80
|
-
*/
|
|
81
|
-
export abstract class BedBuilder<Class, Backend extends MockFunction = Spy> {
|
|
82
|
-
protected readonly target: Constructor<Class>;
|
|
83
|
-
protected readonly reader: MetadataReader;
|
|
84
|
-
protected readonly mockFactory: MockFactory<Backend>;
|
|
85
|
-
protected readonly overrides: ReadonlyMap<DependencyKey, ReadonlyArray<SlottedOverride>> = new Map();
|
|
86
|
-
|
|
87
|
-
constructor(target: Constructor<Class>, options?: TestBedOptions<Backend>) {
|
|
88
|
-
this.target = target;
|
|
89
|
-
// A supplied reader is a claim — verify it the way the container itself does.
|
|
90
|
-
this.reader = verifyingMetadataReader(options?.metadataReader ?? defaultMetadataReader);
|
|
91
|
-
// With no factory the caller's Backend defaulted to Spy, which is what the default produces.
|
|
92
|
-
this.mockFactory = options?.mockFactory ?? (defaultMockFactory as unknown as MockFactory<Backend>);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Replaces the auto-mock for one dependency with a hand-written stub or a concrete value.
|
|
97
|
-
*
|
|
98
|
-
* @remarks Pass `options` (a name or tags) to target one slot of a token bound several ways;
|
|
99
|
-
* without them the override covers every slot of the token that has no more specific override.
|
|
100
|
-
* Registering the same target twice replaces the earlier override wholesale.
|
|
101
|
-
*/
|
|
102
|
-
mock<Dependency>(
|
|
103
|
-
identifier: InjectionIdentifier<Dependency>,
|
|
104
|
-
options?: InjectOptions,
|
|
105
|
-
): MockOverrideBuilder<Dependency, this, Backend> {
|
|
106
|
-
const key = identifier as DependencyKey;
|
|
107
|
-
const criteria = normalizeCriteria(options);
|
|
108
|
-
const set = (override: SlottedOverride["override"]): this => {
|
|
109
|
-
this.#register(key, criteria, override);
|
|
110
|
-
return this;
|
|
111
|
-
};
|
|
112
|
-
return {
|
|
113
|
-
using: (value) => set({ kind: "value", value }),
|
|
114
|
-
stub: (setup) => set({ kind: "stub", setup: setup as (fn: MockFactory) => unknown }),
|
|
115
|
-
absent: () => set({ kind: "absent" }),
|
|
116
|
-
usingAll: (values) => set({ kind: "all", values }),
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** Resolves the unit inside a guard that disposes the container when any compile step throws. */
|
|
121
|
-
protected compileWith<Prepared extends PreparedBed, Bed>(
|
|
122
|
-
prepare: () => Prepared,
|
|
123
|
-
build: (unit: Class, prepared: Prepared) => Bed,
|
|
124
|
-
): Bed {
|
|
125
|
-
let prepared: Prepared | undefined;
|
|
126
|
-
try {
|
|
127
|
-
prepared = prepare();
|
|
128
|
-
const unit = prepared.container.resolve(this.target);
|
|
129
|
-
return build(unit, prepared);
|
|
130
|
-
} catch (error) {
|
|
131
|
-
// A failed compile still owns the container — dispose it so no lifecycle state leaks.
|
|
132
|
-
void prepared?.container.dispose().catch(() => undefined);
|
|
133
|
-
throw error;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** The async twin of {@link compileWith}, for units whose activation is asynchronous. */
|
|
138
|
-
protected async compileWithAsync<Prepared extends PreparedBed, Bed>(
|
|
139
|
-
prepare: () => Prepared,
|
|
140
|
-
build: (unit: Class, prepared: Prepared) => Bed,
|
|
141
|
-
): Promise<Bed> {
|
|
142
|
-
let prepared: Prepared | undefined;
|
|
143
|
-
try {
|
|
144
|
-
prepared = prepare();
|
|
145
|
-
const unit = await prepared.container.resolveAsync(this.target);
|
|
146
|
-
return build(unit, prepared);
|
|
147
|
-
} catch (error) {
|
|
148
|
-
await prepared?.container.dispose().catch(() => undefined);
|
|
149
|
-
throw error;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/** Records an override, replacing any earlier one that targets the same token and slot. */
|
|
154
|
-
#register(key: DependencyKey, criteria: SlotCriteria | undefined, override: SlottedOverride["override"]): void {
|
|
155
|
-
const overrides = this.overrides as Map<DependencyKey, Array<SlottedOverride>>;
|
|
156
|
-
let list = overrides.get(key);
|
|
157
|
-
if (list === undefined) {
|
|
158
|
-
list = [];
|
|
159
|
-
overrides.set(key, list);
|
|
160
|
-
}
|
|
161
|
-
const existing = list.findIndex((candidate) =>
|
|
162
|
-
candidate.criteria === undefined || criteria === undefined
|
|
163
|
-
? candidate.criteria === criteria
|
|
164
|
-
: criteriaEquals(candidate.criteria, criteria),
|
|
165
|
-
);
|
|
166
|
-
if (existing === -1) {
|
|
167
|
-
list.push({ criteria, override });
|
|
168
|
-
} else {
|
|
169
|
-
list[existing] = { criteria, override };
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
/** The fluent builder that compiles a unit together with chosen real collaborators. */
|
|
2
|
-
|
|
3
|
-
import type { Constructor, DependencySlot, InjectOptions } from "@codefast/di";
|
|
4
|
-
import { Container, token } from "@codefast/di";
|
|
5
|
-
|
|
6
|
-
import { scanSociableDependencies } from "#/discovery/dependency-scanner";
|
|
7
|
-
import { bindMocks } from "#/discovery/mock-binder";
|
|
8
|
-
import { ExposureError } from "#/errors/errors";
|
|
9
|
-
import type { MockFunction } from "#/mocking/mock-factory";
|
|
10
|
-
import type { Spy } from "#/mocking/spy";
|
|
11
|
-
import type { MockOverrideBuilder, PreparedBed } from "#/test-bed/bed-builder";
|
|
12
|
-
import { BedBuilder } from "#/test-bed/bed-builder";
|
|
13
|
-
import type { SociableUnitTestBed } from "#/test-bed/unit-test-bed";
|
|
14
|
-
import { createSociableUnitTestBed, createUnitTestBed } from "#/test-bed/unit-test-bed";
|
|
15
|
-
import type { InjectionIdentifier } from "#/types";
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* A sociable build in progress: expose real collaborators, override the rest, then compile.
|
|
19
|
-
*
|
|
20
|
-
* @remarks Exposure follows class identity: a class-keyed dependency of the unit — or of another
|
|
21
|
-
* exposed class — stays real when exposed. A `Token`-keyed dependency is always mocked, treating
|
|
22
|
-
* tokens as the declared boundary between the logic under test and the outside world.
|
|
23
|
-
*
|
|
24
|
-
* @typeParam Class - The class under test.
|
|
25
|
-
* @typeParam Backend - The spy type the bed's mock factory produces.
|
|
26
|
-
*
|
|
27
|
-
* @since 0.1.0
|
|
28
|
-
*/
|
|
29
|
-
export interface SociableTestBedBuilder<Class, Backend extends MockFunction = Spy> {
|
|
30
|
-
/** Keeps a class-keyed collaborator real; its own dependencies follow the same exposure rules. */
|
|
31
|
-
expose(target: Constructor): SociableTestBedBuilder<Class, Backend>;
|
|
32
|
-
/** Replaces the auto-mock for one dependency with a hand-written stub or a concrete value. */
|
|
33
|
-
mock<Dependency>(
|
|
34
|
-
identifier: InjectionIdentifier<Dependency>,
|
|
35
|
-
options?: InjectOptions,
|
|
36
|
-
): MockOverrideBuilder<Dependency, SociableTestBedBuilder<Class, Backend>, Backend>;
|
|
37
|
-
/** Instantiates the unit and its exposed subtree, mocking everything else. */
|
|
38
|
-
compile(): SociableUnitTestBed<Class, Backend>;
|
|
39
|
-
/** Async variant for a subtree whose `@postConstruct` is async; otherwise identical to {@link compile}. */
|
|
40
|
-
compileAsync(): Promise<SociableUnitTestBed<Class, Backend>>;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** What the sociable prepare step adds to the shared shape: the reachable exposed classes. */
|
|
44
|
-
type PreparedSociableBed = PreparedBed & { readonly realClasses: ReadonlySet<Constructor> };
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* The default {@link SociableTestBedBuilder}, backed by a fresh container per compile.
|
|
48
|
-
*
|
|
49
|
-
* @typeParam Class - The class under test.
|
|
50
|
-
* @typeParam Backend - The spy type the bed's mock factory produces.
|
|
51
|
-
*
|
|
52
|
-
* @since 0.1.0
|
|
53
|
-
*/
|
|
54
|
-
export class SociableBuilder<Class, Backend extends MockFunction = Spy>
|
|
55
|
-
extends BedBuilder<Class, Backend>
|
|
56
|
-
implements SociableTestBedBuilder<Class, Backend>
|
|
57
|
-
{
|
|
58
|
-
readonly #exposed = new Set<Constructor>();
|
|
59
|
-
|
|
60
|
-
expose(target: Constructor): this {
|
|
61
|
-
this.#exposed.add(target);
|
|
62
|
-
return this;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
compile(): SociableUnitTestBed<Class, Backend> {
|
|
66
|
-
return this.compileWith(
|
|
67
|
-
() => this.#prepare(),
|
|
68
|
-
(unit, prepared) =>
|
|
69
|
-
createSociableUnitTestBed(
|
|
70
|
-
createUnitTestBed(unit, prepared.mocks, prepared.container),
|
|
71
|
-
prepared.container,
|
|
72
|
-
prepared.realClasses,
|
|
73
|
-
),
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
async compileAsync(): Promise<SociableUnitTestBed<Class, Backend>> {
|
|
78
|
-
return this.compileWithAsync(
|
|
79
|
-
() => this.#prepare(),
|
|
80
|
-
(unit, prepared) =>
|
|
81
|
-
createSociableUnitTestBed(
|
|
82
|
-
createUnitTestBed(unit, prepared.mocks, prepared.container),
|
|
83
|
-
prepared.container,
|
|
84
|
-
prepared.realClasses,
|
|
85
|
-
),
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Scans across the exposed set, binds mocks for the boundary, and binds every real class. */
|
|
90
|
-
#prepare(): PreparedSociableBed {
|
|
91
|
-
if (this.#exposed.has(this.target)) {
|
|
92
|
-
throw new ExposureError(this.target.name, "the unit under test is already real — expose only its collaborators.");
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const container = Container.create({ metadataReader: this.reader });
|
|
96
|
-
const { mockSlots, realClasses, realSlots } = scanSociableDependencies(this.target, this.#exposed, this.reader);
|
|
97
|
-
|
|
98
|
-
// An exposed class the unit never reaches is a stale exposure — likely a typo or a refactor.
|
|
99
|
-
for (const exposedClass of this.#exposed) {
|
|
100
|
-
if (!realClasses.has(exposedClass)) {
|
|
101
|
-
throw new ExposureError(
|
|
102
|
-
exposedClass.name,
|
|
103
|
-
"the unit never reaches this class through exposed collaborators. Expose the intermediate classes on the path to it, or remove the exposure.",
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const mocks = bindMocks(container, mockSlots, this.overrides, this.mockFactory, this.#exposed);
|
|
109
|
-
|
|
110
|
-
const constrainedSlots = new Map<Constructor, Array<DependencySlot>>();
|
|
111
|
-
for (const slot of realSlots) {
|
|
112
|
-
const list = constrainedSlots.get(slot.token as Constructor) ?? [];
|
|
113
|
-
list.push(slot);
|
|
114
|
-
constrainedSlots.set(slot.token as Constructor, list);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Real collaborators are singletons, so the unit and bed.exposed() see one instance each,
|
|
118
|
-
// and their @preDestroy hooks run on dispose.
|
|
119
|
-
const merged = new Map(mocks);
|
|
120
|
-
for (const realClass of realClasses) {
|
|
121
|
-
const slots = constrainedSlots.get(realClass);
|
|
122
|
-
if (slots === undefined) {
|
|
123
|
-
container.bind(realClass).toSelf().singleton();
|
|
124
|
-
} else {
|
|
125
|
-
// A name/tag-constrained slot needs its own binding, and di rejects a self-alias — so the
|
|
126
|
-
// one real binding lives on a private token, and every slot of the class resolves it
|
|
127
|
-
// through a factory (an alias would forward the slot's criteria to the private token).
|
|
128
|
-
const realToken = token<unknown>(`di-testing:real:${realClass.name}`);
|
|
129
|
-
container.bind(realToken).to(realClass).singleton();
|
|
130
|
-
container.bind(realClass).toDynamic((context) => context.resolve(realToken));
|
|
131
|
-
for (const slot of slots) {
|
|
132
|
-
const builder = container.bind(realClass).toDynamic((context) => context.resolve(realToken));
|
|
133
|
-
if (slot.name !== undefined) {
|
|
134
|
-
builder.whenNamed(slot.name);
|
|
135
|
-
}
|
|
136
|
-
for (const tag of slot.tags ?? []) {
|
|
137
|
-
builder.whenTagged(tag);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
// Sealed entries so mocks.get points at bed.exposed() instead of handing back a fake Mocked.
|
|
142
|
-
merged.set(realClass, [{ criteria: undefined, value: undefined, kind: "exposed" }]);
|
|
143
|
-
}
|
|
144
|
-
container.bind(this.target).toSelf().singleton();
|
|
145
|
-
|
|
146
|
-
return { container, mocks: merged, realClasses };
|
|
147
|
-
}
|
|
148
|
-
}
|