@znight/testing 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +441 -2
- package/dist/index.d.mts +697 -0
- package/dist/index.mjs +1561 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +49 -4
- package/src/config.ts +214 -0
- package/src/define-metadata.ts +255 -0
- package/src/extensions.ts +109 -0
- package/src/index.ts +58 -0
- package/src/metadata-index.ts +161 -0
- package/src/mock-fn.ts +83 -0
- package/src/mock-registry.ts +118 -0
- package/src/mock.ts +270 -0
- package/src/overrides.ts +54 -0
- package/src/spy.ts +246 -0
- package/src/test-app.ts +551 -0
- package/src/testbed-builder.ts +366 -0
- package/src/testbed.ts +97 -0
- package/src/types.ts +107 -0
- package/src/utils.ts +37 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import type { DependencyRef, ProviderDefinition, ProviderValues, Token, ZnightMetadata } from '@znight/core'
|
|
2
|
+
import type { ConfigClassInfo } from './extensions.ts'
|
|
3
|
+
import type { AopMode, TestBedOptions, TestBedResult } from './testbed.ts'
|
|
4
|
+
import type { MockFnFactory } from './types.ts'
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
applyInterceptors,
|
|
8
|
+
Container,
|
|
9
|
+
describeToken,
|
|
10
|
+
displayName,
|
|
11
|
+
ErrorCode,
|
|
12
|
+
formatPath,
|
|
13
|
+
InstantiationError,
|
|
14
|
+
isProviderDefinition,
|
|
15
|
+
METADATA_VERSION,
|
|
16
|
+
ZnightError,
|
|
17
|
+
} from '@znight/core'
|
|
18
|
+
|
|
19
|
+
import { buildConfigInstance, normalizeConfigValues } from './config.ts'
|
|
20
|
+
import { readConfigClasses } from './extensions.ts'
|
|
21
|
+
import { MetadataIndex } from './metadata-index.ts'
|
|
22
|
+
import { mockObject } from './mock.ts'
|
|
23
|
+
import { resolveMockFn } from './mock-fn.ts'
|
|
24
|
+
import { type InjectionKind, TestMocks } from './mock-registry.ts'
|
|
25
|
+
import { describeKey, entriesOf, isThenable } from './utils.ts'
|
|
26
|
+
|
|
27
|
+
const AOP_MODES: ReadonlySet<AopMode | undefined> = new Set([undefined, 'none', 'real'])
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Builds the target of `TestBed.create()`: resolves keys, injects auto-mocks / provided values /
|
|
31
|
+
* configuration instances, runs the factory and assembles the result container. Internal.
|
|
32
|
+
*/
|
|
33
|
+
export class Builder {
|
|
34
|
+
readonly #index: MetadataIndex
|
|
35
|
+
readonly #options: TestBedOptions
|
|
36
|
+
readonly #mockFn: MockFnFactory
|
|
37
|
+
readonly #configs: ReadonlyMap<Token, ConfigClassInfo>
|
|
38
|
+
readonly #configValues: ReadonlyMap<Token, Record<string, unknown>>
|
|
39
|
+
readonly #provided: ReadonlyMap<Token, unknown>
|
|
40
|
+
readonly #overrides: ReadonlyMap<Token, unknown>
|
|
41
|
+
/** Values injected into the target (and into built dependencies), by canonical token. */
|
|
42
|
+
readonly #values = new Map<Token, unknown>()
|
|
43
|
+
/** Real interceptors (aop: 'real'), by canonical token. */
|
|
44
|
+
readonly #interceptors = new Map<Token, unknown>()
|
|
45
|
+
readonly #kinds = new Map<Token, InjectionKind>()
|
|
46
|
+
readonly #mocks = new Map<Token, object>()
|
|
47
|
+
/** Providers being built (cycle guard). */
|
|
48
|
+
readonly #building = new Set<Token>()
|
|
49
|
+
#known: Set<Token> | undefined
|
|
50
|
+
|
|
51
|
+
constructor(options: TestBedOptions) {
|
|
52
|
+
if (options === null || typeof options !== 'object') {
|
|
53
|
+
throw new TypeError('[znight] TestBed.create(target, { metadata }) requires options with the generated metadata')
|
|
54
|
+
}
|
|
55
|
+
if (!AOP_MODES.has(options.aop)) {
|
|
56
|
+
throw new TypeError(`[znight] TestBed.create(): aop must be 'none' or 'real', got ${String(options.aop)}`)
|
|
57
|
+
}
|
|
58
|
+
this.#options = options
|
|
59
|
+
this.#index = new MetadataIndex(options.metadata)
|
|
60
|
+
this.#mockFn = resolveMockFn(options.mockFn).create
|
|
61
|
+
this.#configs = readConfigClasses(options.metadata)
|
|
62
|
+
this.#configValues = normalizeConfigValues(options.config, this.#index, this.#configs)
|
|
63
|
+
this.#provided = this.#normalize(options.provide, 'provide')
|
|
64
|
+
this.#overrides = this.#normalize(options.overrides, 'overrides')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async build(target: unknown): Promise<TestBedResult<unknown>> {
|
|
68
|
+
const token = this.#resolveTarget(target)
|
|
69
|
+
let definition = this.#index.definition(token) as ProviderDefinition
|
|
70
|
+
if (this.#overrides.has(token)) {
|
|
71
|
+
const replacement = this.#overrides.get(token)
|
|
72
|
+
if (!isProviderDefinition(replacement)) {
|
|
73
|
+
throw new ZnightError(
|
|
74
|
+
ErrorCode.InvalidDecorator,
|
|
75
|
+
`overrides: the target ${displayName(token)} can only be replaced by a provider definition { scope, deps, factory }`,
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
definition = replacement
|
|
79
|
+
}
|
|
80
|
+
const instance = await this.#construct(token, definition, [token])
|
|
81
|
+
const container = await this.#createContainer(token, definition, instance)
|
|
82
|
+
const others = new Map(this.#kinds)
|
|
83
|
+
return {
|
|
84
|
+
instance,
|
|
85
|
+
mocks: new TestMocks(this.#mocks, others, this.#index, token),
|
|
86
|
+
container,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// -----------------------------------------------------------------------------------------------
|
|
91
|
+
// Keys
|
|
92
|
+
// -----------------------------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
#resolveTarget(target: unknown): Token {
|
|
95
|
+
const token = this.#index.find(target, new Set(this.#index.providerTokens()))
|
|
96
|
+
if (token !== undefined) return token
|
|
97
|
+
throw new ZnightError(
|
|
98
|
+
ErrorCode.MissingProvider,
|
|
99
|
+
`TestBed: ${describeKey(target)} is not a provider of the metadata`,
|
|
100
|
+
{
|
|
101
|
+
hints: [
|
|
102
|
+
'Check that the class is decorated (@Injectable(), @Controller()...) and exported',
|
|
103
|
+
'Regenerate the metadata before running tests: `znight generate`, the Bun preload `@znight/compiler/bun` or the Vite plugin',
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Every token a `provide`/`overrides` key may designate (canonical). */
|
|
110
|
+
#knownTokens(): Set<Token> {
|
|
111
|
+
if (this.#known !== undefined) return this.#known
|
|
112
|
+
const index = this.#index
|
|
113
|
+
const known = new Set<Token>()
|
|
114
|
+
const add = (token: unknown): void => {
|
|
115
|
+
if (typeof token === 'string') known.add(index.canonical(token))
|
|
116
|
+
}
|
|
117
|
+
for (const token of index.providerTokens()) {
|
|
118
|
+
known.add(token)
|
|
119
|
+
for (const ref of (index.definition(token) as ProviderDefinition).deps) {
|
|
120
|
+
for (const dep of tokensOfRef(ref)) add(dep)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const external of index.metadata.externals ?? []) add(external?.token)
|
|
124
|
+
for (const [alias] of index.aliases()) add(alias)
|
|
125
|
+
this.#known = known
|
|
126
|
+
return known
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
#normalize(values: ProviderValues | undefined, option: 'provide' | 'overrides'): Map<Token, unknown> {
|
|
130
|
+
const result = new Map<Token, unknown>()
|
|
131
|
+
if (values === undefined) return result
|
|
132
|
+
const known = this.#knownTokens()
|
|
133
|
+
for (const [key, value] of entriesOf<unknown, unknown>(values)) {
|
|
134
|
+
const token = this.#index.find(key, known)
|
|
135
|
+
if (token === undefined) {
|
|
136
|
+
throw new ZnightError(
|
|
137
|
+
ErrorCode.MissingProvider,
|
|
138
|
+
`${option}: ${describeKey(key)} matches no provider, external or dependency of the metadata`,
|
|
139
|
+
{ hints: ['Use a class, a token, an alias or a unique display name (e.g. "UserRepository")'] },
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
if (result.has(token)) {
|
|
143
|
+
throw new ZnightError(ErrorCode.DuplicateProvider, `${option}: several values for ${describeToken(token)}`)
|
|
144
|
+
}
|
|
145
|
+
result.set(token, value)
|
|
146
|
+
}
|
|
147
|
+
return result
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#isExplicit(token: Token): boolean {
|
|
151
|
+
const canonical = this.#index.canonical(token)
|
|
152
|
+
return this.#overrides.has(canonical) || this.#provided.has(canonical)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// -----------------------------------------------------------------------------------------------
|
|
156
|
+
// Construction
|
|
157
|
+
// -----------------------------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
async #construct(token: Token, definition: ProviderDefinition, path: readonly Token[]): Promise<unknown> {
|
|
160
|
+
if (this.#building.has(token)) {
|
|
161
|
+
throw new ZnightError(ErrorCode.CircularDependency, `Circular dependency: ${formatPath([...path])}`)
|
|
162
|
+
}
|
|
163
|
+
this.#building.add(token)
|
|
164
|
+
try {
|
|
165
|
+
const interceptorDeps = interceptorIndices(definition)
|
|
166
|
+
const deps: unknown[] = new Array(definition.deps.length)
|
|
167
|
+
for (let index = 0; index < definition.deps.length; index++) {
|
|
168
|
+
const ref = definition.deps[index] as DependencyRef
|
|
169
|
+
if (!interceptorDeps.has(index)) deps[index] = await this.#resolve(ref, path)
|
|
170
|
+
else if (this.#options.aop === 'real') deps[index] = await this.#interceptor(ref, path)
|
|
171
|
+
else for (const dep of tokensOfRef(ref)) this.#record(this.#index.canonical(dep), 'interceptor')
|
|
172
|
+
}
|
|
173
|
+
let instance: unknown
|
|
174
|
+
try {
|
|
175
|
+
instance = definition.factory(deps)
|
|
176
|
+
if (isThenable(instance)) instance = await instance
|
|
177
|
+
} catch (error) {
|
|
178
|
+
throw new InstantiationError(token, path, error, definition.source)
|
|
179
|
+
}
|
|
180
|
+
if (this.#options.aop === 'real' && definition.interceptors !== undefined) {
|
|
181
|
+
try {
|
|
182
|
+
applyInterceptors(instance as object, definition.interceptors, deps, displayName(token))
|
|
183
|
+
} catch (error) {
|
|
184
|
+
throw new InstantiationError(token, path, error, definition.source)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return instance
|
|
188
|
+
} finally {
|
|
189
|
+
this.#building.delete(token)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async #resolve(ref: DependencyRef, path: readonly Token[]): Promise<unknown> {
|
|
194
|
+
if (typeof ref === 'string') return this.#value(ref, path)
|
|
195
|
+
if (ref !== null && typeof ref === 'object') {
|
|
196
|
+
if ('multi' in ref && Array.isArray(ref.multi)) {
|
|
197
|
+
const values: unknown[] = []
|
|
198
|
+
for (const token of ref.multi as readonly Token[]) {
|
|
199
|
+
if (this.#isExplicit(token)) values.push(await this.#value(token, path))
|
|
200
|
+
}
|
|
201
|
+
return values
|
|
202
|
+
}
|
|
203
|
+
if ('lazy' in ref && typeof ref.lazy === 'string') {
|
|
204
|
+
const value = await this.#value(ref.lazy, path)
|
|
205
|
+
return { get: (): unknown => value }
|
|
206
|
+
}
|
|
207
|
+
if ('token' in ref && typeof ref.token === 'string') {
|
|
208
|
+
if (this.#isExplicit(ref.token)) return this.#value(ref.token, path)
|
|
209
|
+
this.#record(this.#index.canonical(ref.token), 'optional')
|
|
210
|
+
return undefined
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
throw new ZnightError(
|
|
214
|
+
ErrorCode.InvalidDecorator,
|
|
215
|
+
`Invalid dependency of ${displayName(path[path.length - 1] as Token)}: ${safeJson(ref)}`,
|
|
216
|
+
{ hints: ['Expected a token, { token, optional: true }, { multi: [...] } or { lazy: token }'] },
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The value injected for `token` (cached: the same dependency always gets the same value). */
|
|
221
|
+
async #value(token: Token, path: readonly Token[]): Promise<unknown> {
|
|
222
|
+
const canonical = this.#index.canonical(token)
|
|
223
|
+
if (this.#values.has(canonical)) return this.#values.get(canonical)
|
|
224
|
+
let value: unknown
|
|
225
|
+
if (this.#overrides.has(canonical)) {
|
|
226
|
+
value = await this.#explicit(canonical, this.#overrides.get(canonical), path)
|
|
227
|
+
this.#record(canonical, 'override')
|
|
228
|
+
} else if (this.#provided.has(canonical)) {
|
|
229
|
+
value = this.#provided.get(canonical)
|
|
230
|
+
this.#record(canonical, 'provided')
|
|
231
|
+
} else if (this.#configs.has(canonical)) {
|
|
232
|
+
const info = this.#configs.get(canonical) as ConfigClassInfo
|
|
233
|
+
value = buildConfigInstance(info, this.#index.classOf(canonical), this.#configValues.get(canonical))
|
|
234
|
+
this.#record(canonical, 'config')
|
|
235
|
+
} else {
|
|
236
|
+
value = this.#autoMock(canonical, path)
|
|
237
|
+
}
|
|
238
|
+
this.#values.set(canonical, value)
|
|
239
|
+
return value
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** An override (a value, or a definition built with its dependencies resolved like the target's). */
|
|
243
|
+
#explicit(token: Token, value: unknown, path: readonly Token[]): Promise<unknown> | unknown {
|
|
244
|
+
return isProviderDefinition(value) ? this.#construct(token, value, [...path, token]) : value
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#autoMock(token: Token, path: readonly Token[]): object {
|
|
248
|
+
const definition = this.#index.definition(token)
|
|
249
|
+
if (definition !== undefined && definition.class === undefined && definition.name !== undefined) {
|
|
250
|
+
throw qualifiedValueError(token, definition.name, path)
|
|
251
|
+
}
|
|
252
|
+
const mock = mockObject(definition?.class, undefined, {
|
|
253
|
+
name: displayName(token),
|
|
254
|
+
deep: this.#options.deep === true,
|
|
255
|
+
mockFn: this.#mockFn,
|
|
256
|
+
})
|
|
257
|
+
this.#mocks.set(token, mock)
|
|
258
|
+
this.#kinds.delete(token)
|
|
259
|
+
return mock
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** A real interceptor (aop: 'real'): explicit value, or built from its compiled definition. */
|
|
263
|
+
async #interceptor(ref: DependencyRef, path: readonly Token[]): Promise<unknown> {
|
|
264
|
+
if (typeof ref !== 'string') return this.#resolve(ref, path)
|
|
265
|
+
const token = this.#index.canonical(ref)
|
|
266
|
+
if (this.#interceptors.has(token)) return this.#interceptors.get(token)
|
|
267
|
+
let value: unknown
|
|
268
|
+
if (this.#overrides.has(token)) {
|
|
269
|
+
value = await this.#explicit(token, this.#overrides.get(token), path)
|
|
270
|
+
this.#record(token, 'override')
|
|
271
|
+
} else if (this.#provided.has(token)) {
|
|
272
|
+
value = this.#provided.get(token)
|
|
273
|
+
this.#record(token, 'provided')
|
|
274
|
+
} else {
|
|
275
|
+
const definition = this.#index.definition(token)
|
|
276
|
+
if (definition === undefined) {
|
|
277
|
+
const providedBy = this.#index.external(token)?.providedBy
|
|
278
|
+
throw new ZnightError(
|
|
279
|
+
ErrorCode.MissingProvider,
|
|
280
|
+
`aop: 'real' needs the interceptor ${describeToken(token)}, supplied at runtime by ${providedBy ?? 'a plugin'}`,
|
|
281
|
+
{ hints: [`Pass an instance: provide: [[${JSON.stringify(token)}, interceptor]]`] },
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
value = await this.#construct(token, definition, [...path, token])
|
|
285
|
+
this.#record(token, 'real')
|
|
286
|
+
}
|
|
287
|
+
this.#interceptors.set(token, value)
|
|
288
|
+
return value
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
#record(token: Token, kind: InjectionKind): void {
|
|
292
|
+
if (!this.#mocks.has(token)) this.#kinds.set(token, kind)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// -----------------------------------------------------------------------------------------------
|
|
296
|
+
// Result container
|
|
297
|
+
// -----------------------------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
async #createContainer(target: Token, definition: ProviderDefinition, instance: unknown): Promise<Container> {
|
|
300
|
+
const bound = new Map<Token, unknown>([[target, instance]])
|
|
301
|
+
for (const [token, value] of this.#values) if (value !== undefined && !bound.has(token)) bound.set(token, value)
|
|
302
|
+
for (const [token, value] of this.#interceptors) {
|
|
303
|
+
if (value !== undefined && !bound.has(token)) bound.set(token, value)
|
|
304
|
+
}
|
|
305
|
+
const providers = [...bound.keys()].map((token): [Token, ProviderDefinition] => {
|
|
306
|
+
const cls = token === target ? (definition.class ?? this.#index.classOf(token)) : this.#index.classOf(token)
|
|
307
|
+
// placeholder definitions: they register the classes for lookups, the values are bound below
|
|
308
|
+
return [
|
|
309
|
+
token,
|
|
310
|
+
{ scope: 'singleton', deps: [], factory: () => undefined, ...(cls === undefined ? {} : { class: cls }) },
|
|
311
|
+
]
|
|
312
|
+
})
|
|
313
|
+
const aliases: [Token, Token][] = []
|
|
314
|
+
for (const [alias] of this.#index.aliases()) {
|
|
315
|
+
const canonical = this.#index.canonical(alias)
|
|
316
|
+
if (bound.has(canonical) && !bound.has(alias)) aliases.push([alias, canonical])
|
|
317
|
+
}
|
|
318
|
+
const metadata: ZnightMetadata = {
|
|
319
|
+
version: METADATA_VERSION,
|
|
320
|
+
providers: Object.fromEntries(providers),
|
|
321
|
+
resolutionOrder: [...bound.keys()],
|
|
322
|
+
aliases: Object.fromEntries(aliases),
|
|
323
|
+
}
|
|
324
|
+
const container = Container.create(metadata)
|
|
325
|
+
for (const [token, value] of bound) container.provide(token, value)
|
|
326
|
+
await container.init()
|
|
327
|
+
return container
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Indices of `definition.deps` used only by interceptor chains. */
|
|
332
|
+
function interceptorIndices(definition: ProviderDefinition): Set<number> {
|
|
333
|
+
const indices = new Set<number>()
|
|
334
|
+
for (const interception of Object.values(definition.interceptors ?? {})) {
|
|
335
|
+
for (const binding of interception.chain) indices.add(binding.dep)
|
|
336
|
+
}
|
|
337
|
+
return indices
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function tokensOfRef(ref: DependencyRef): readonly Token[] {
|
|
341
|
+
if (typeof ref === 'string') return [ref]
|
|
342
|
+
if (ref === null || typeof ref !== 'object') return []
|
|
343
|
+
if ('multi' in ref && Array.isArray(ref.multi)) return ref.multi
|
|
344
|
+
if ('lazy' in ref && typeof ref.lazy === 'string') return [ref.lazy]
|
|
345
|
+
if ('token' in ref && typeof ref.token === 'string') return [ref.token]
|
|
346
|
+
return []
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function qualifiedValueError(token: Token, name: string, path: readonly Token[]): ZnightError {
|
|
350
|
+
const owner = displayName(path[path.length - 1] as Token)
|
|
351
|
+
const key = displayName(token)
|
|
352
|
+
return new ZnightError(
|
|
353
|
+
ErrorCode.MissingProvider,
|
|
354
|
+
`TestBed cannot auto-mock ${describeToken(token)}, the value named "${name}" injected into ${owner}: ` +
|
|
355
|
+
'qualified values may be primitives, provide one',
|
|
356
|
+
{ hints: [`TestBed.create(${owner}, { metadata, provide: { ${JSON.stringify(key)}: value } })`] },
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function safeJson(value: unknown): string {
|
|
361
|
+
try {
|
|
362
|
+
return JSON.stringify(value) ?? String(value)
|
|
363
|
+
} catch {
|
|
364
|
+
return String(value)
|
|
365
|
+
}
|
|
366
|
+
}
|
package/src/testbed.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { ClassKey, Container, ProviderClass, ProviderValues, TypedToken, ZnightMetadata } from '@znight/core'
|
|
2
|
+
import type { ConfigValues } from './config.ts'
|
|
3
|
+
import type { MockRegistry } from './mock-registry.ts'
|
|
4
|
+
import type { MockFnOption } from './types.ts'
|
|
5
|
+
|
|
6
|
+
import { Builder } from './testbed-builder.ts'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How `@Intercept` interceptors of the target are handled:
|
|
10
|
+
* - `'none'` (default): interceptors are neither instantiated nor applied, the target's methods are
|
|
11
|
+
* the plain ones;
|
|
12
|
+
* - `'real'`: interceptors are built (their own dependencies auto-mocked) or taken from
|
|
13
|
+
* `provide`/`overrides`, and applied like the container does.
|
|
14
|
+
*/
|
|
15
|
+
export type AopMode = 'none' | 'real'
|
|
16
|
+
|
|
17
|
+
/** Options of {@link TestBed.create}. */
|
|
18
|
+
export interface TestBedOptions {
|
|
19
|
+
/** The generated metadata (`__ZNIGHT_METADATA__`). */
|
|
20
|
+
readonly metadata: ZnightMetadata
|
|
21
|
+
/**
|
|
22
|
+
* Values injected instead of auto-mocks, keyed by class, token, alias or display name. Required
|
|
23
|
+
* for qualified values (`@Inject('dbUrl')`, `@Provides({ name })` without a class) such as
|
|
24
|
+
* primitives; also used for optional dependencies and members of multi dependencies.
|
|
25
|
+
*/
|
|
26
|
+
readonly provide?: ProviderValues
|
|
27
|
+
/**
|
|
28
|
+
* Like `provide`, but a value shaped like a provider definition (`{ scope, deps, factory }`) is
|
|
29
|
+
* built with its factory, its own dependencies being resolved like the target's (auto-mocked...).
|
|
30
|
+
* A definition keyed by the target replaces the target's factory. Wins over `provide`.
|
|
31
|
+
*/
|
|
32
|
+
readonly overrides?: ProviderValues
|
|
33
|
+
/** Values of configuration classes (real instances are injected for `@Config` classes). */
|
|
34
|
+
readonly config?: ConfigValues
|
|
35
|
+
/** Spy implementation of the auto-mocks. Default: auto-detected. */
|
|
36
|
+
readonly mockFn?: MockFnOption
|
|
37
|
+
/** Auto-mocks return deep mocks from unconfigured methods instead of `undefined`. Default: `false`. */
|
|
38
|
+
readonly deep?: boolean
|
|
39
|
+
/** Interceptors handling, see {@link AopMode}. Default: `'none'`. */
|
|
40
|
+
readonly aop?: AopMode
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What {@link TestBed.create} returns. */
|
|
44
|
+
export interface TestBedResult<T> {
|
|
45
|
+
/** The target, built with its real (generated) factory. */
|
|
46
|
+
readonly instance: T
|
|
47
|
+
/** The auto-mocks injected into the target. */
|
|
48
|
+
readonly mocks: MockRegistry
|
|
49
|
+
/**
|
|
50
|
+
* A container holding the target and everything injected into it (auto-mocks, provided values,
|
|
51
|
+
* configuration instances), for code that resolves dependencies itself.
|
|
52
|
+
*/
|
|
53
|
+
readonly container: Container
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Unit-test harness: builds one provider with its real factory and auto-mocks its **direct**
|
|
58
|
+
* dependencies, by kind:
|
|
59
|
+
*
|
|
60
|
+
* | Dependency | Injected |
|
|
61
|
+
* | -------------------------------------------- | --------------------------------------------------- |
|
|
62
|
+
* | class / interface / `@Provides` / external | deep auto-mock |
|
|
63
|
+
* | configuration class (`@Config`) | real instance: defaults + `config` values |
|
|
64
|
+
* | qualified value (`@Inject('x')`, primitives) | the `provide` value (an error otherwise) |
|
|
65
|
+
* | optional (`@Optional()`, `T \| undefined`) | `undefined` (or the `provide` value) |
|
|
66
|
+
* | multi (`T[]`) | `[]` (or the provided members) |
|
|
67
|
+
* | `Lazy<T>` | `{ get: () => mock }` |
|
|
68
|
+
* | interceptor (`@Intercept`) | nothing, not applied (unless `aop: 'real'`) |
|
|
69
|
+
*
|
|
70
|
+
* ```ts
|
|
71
|
+
* const { instance, mocks } = await TestBed.create(UserService, { metadata: __ZNIGHT_METADATA__ })
|
|
72
|
+
* mocks.get(UserRepository).findById.mockResolvedValue({ id: '1' })
|
|
73
|
+
* await instance.findById('1')
|
|
74
|
+
* expect(mocks.get(UserRepository).findById).toHaveBeenCalledWith('1')
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export class TestBed {
|
|
78
|
+
private constructor() {
|
|
79
|
+
// static API only
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Builds `target` (a class, token, alias or display name of a provider of `options.metadata`).
|
|
84
|
+
*
|
|
85
|
+
* @throws ZnightError ZNIGHT001 (unknown target, qualified dependency without value), ZNIGHT012
|
|
86
|
+
* (invalid `config`), ZNIGHT015 (the factory failed).
|
|
87
|
+
*/
|
|
88
|
+
static create<T>(target: ProviderClass<T> | ClassKey<T>, options: TestBedOptions): Promise<TestBedResult<T>>
|
|
89
|
+
static create<T = unknown>(target: TypedToken<T>, options: TestBedOptions): Promise<TestBedResult<T>>
|
|
90
|
+
static create(target: unknown, options: TestBedOptions): Promise<TestBedResult<unknown>> {
|
|
91
|
+
try {
|
|
92
|
+
return new Builder(options).build(target)
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return Promise.reject(error)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public types of the mocks created by `@znight/testing`. They describe the Jest-compatible API
|
|
3
|
+
* shared by the built-in spy, `vi.fn()` (Vitest), `jest.fn()` (Jest) and `mock()` (`bun:test`).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Any function. Every function type is assignable to it (parameters are contravariant). */
|
|
7
|
+
export type AnyFunction = (...args: never[]) => unknown
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Signature of an untyped mock function: accepts any arguments and returns anything.
|
|
11
|
+
*/
|
|
12
|
+
// biome-ignore lint/suspicious/noExplicitAny: an untyped mock accepts any call shape, like jest.fn()
|
|
13
|
+
export type UntypedFunction = (...args: any[]) => any
|
|
14
|
+
|
|
15
|
+
/** Outcome of one call of a mock function (`mock.results`). */
|
|
16
|
+
export type MockResult<R> =
|
|
17
|
+
| { readonly type: 'return'; readonly value: R }
|
|
18
|
+
| { readonly type: 'throw'; readonly value: unknown }
|
|
19
|
+
| { readonly type: 'incomplete'; readonly value: undefined }
|
|
20
|
+
|
|
21
|
+
/** Recorded calls of a mock function (`fn.mock`). */
|
|
22
|
+
export interface MockState<F extends AnyFunction = UntypedFunction> {
|
|
23
|
+
/** Arguments of every call, in call order. */
|
|
24
|
+
readonly calls: Parameters<F>[]
|
|
25
|
+
/** Outcome of every call, in call order. */
|
|
26
|
+
readonly results: MockResult<ReturnType<F>>[]
|
|
27
|
+
/** `this` of every call (the new instance for `new` calls). */
|
|
28
|
+
readonly instances: unknown[]
|
|
29
|
+
/** `this` of every call. */
|
|
30
|
+
readonly contexts: unknown[]
|
|
31
|
+
/** Global, monotonic order of every call (comparable across mocks). */
|
|
32
|
+
readonly invocationCallOrder: number[]
|
|
33
|
+
/** Arguments of the last call. */
|
|
34
|
+
readonly lastCall: Parameters<F> | undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A mock function (spy): callable like `F`, records its calls and can be programmed. The API is the
|
|
39
|
+
* common subset of `jest.fn()`, `vi.fn()` and `bun:test`'s `mock()`.
|
|
40
|
+
*/
|
|
41
|
+
export interface MockFunction<F extends AnyFunction = UntypedFunction> {
|
|
42
|
+
(...args: Parameters<F>): ReturnType<F>
|
|
43
|
+
readonly mock: MockState<F>
|
|
44
|
+
/** Every call returns `value` (until reset). */
|
|
45
|
+
mockReturnValue(value: ReturnType<F>): this
|
|
46
|
+
/** The next call returns `value`. Queued values are used before the default implementation. */
|
|
47
|
+
mockReturnValueOnce(value: ReturnType<F>): this
|
|
48
|
+
/** Every call returns a promise resolved with `value`. */
|
|
49
|
+
mockResolvedValue(value: Awaited<ReturnType<F>>): this
|
|
50
|
+
mockResolvedValueOnce(value: Awaited<ReturnType<F>>): this
|
|
51
|
+
/** Every call returns a promise rejected with `error`. */
|
|
52
|
+
mockRejectedValue(error: unknown): this
|
|
53
|
+
mockRejectedValueOnce(error: unknown): this
|
|
54
|
+
mockImplementation(implementation: (...args: Parameters<F>) => ReturnType<F>): this
|
|
55
|
+
mockImplementationOnce(implementation: (...args: Parameters<F>) => ReturnType<F>): this
|
|
56
|
+
/** Every call returns its `this`. */
|
|
57
|
+
mockReturnThis(): this
|
|
58
|
+
/** Name displayed by assertion messages. */
|
|
59
|
+
mockName(name: string): this
|
|
60
|
+
getMockName(): string
|
|
61
|
+
getMockImplementation(): ((...args: Parameters<F>) => ReturnType<F>) | undefined
|
|
62
|
+
/** Forgets the recorded calls (implementations are kept). */
|
|
63
|
+
mockClear(): this
|
|
64
|
+
/** Forgets the recorded calls and the programmed implementations. */
|
|
65
|
+
mockReset(): this
|
|
66
|
+
mockRestore(): void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A member of a {@link Mocked} object: methods become {@link MockFunction}s, objects are mocked deeply. */
|
|
70
|
+
export type MockedMember<V> = V extends AnyFunction ? MockFunction<V> : V extends object ? Mocked<V> : V
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A mock of `T`: assignable to `T`, every method is a {@link MockFunction} and every object member
|
|
74
|
+
* is itself mocked.
|
|
75
|
+
*
|
|
76
|
+
* ```ts
|
|
77
|
+
* const repository: Mocked<UserRepository> = createMock<UserRepository>()
|
|
78
|
+
* repository.findById.mockResolvedValue(user)
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export type Mocked<T> = T & { [K in keyof T]: MockedMember<T[K]> }
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* An untyped deep mock: every property is a callable {@link MockFunction} which is itself a deep
|
|
85
|
+
* mock (`mock.repository.findById.mockResolvedValue(user)`).
|
|
86
|
+
*/
|
|
87
|
+
export interface DeepMock {
|
|
88
|
+
readonly [key: string]: DeepMockFunction
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A callable member of a {@link DeepMock}. */
|
|
92
|
+
export type DeepMockFunction = MockFunction & DeepMock
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Creates a mock function from an optional implementation: `vi.fn`, `jest.fn`, `bun:test`'s `mock`
|
|
96
|
+
* or {@link createSpy}.
|
|
97
|
+
*/
|
|
98
|
+
export type MockFnFactory = (implementation?: AnyFunction) => unknown
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Which spy implementation mocks use:
|
|
102
|
+
* - `'auto'` (default): Vitest (`globalThis.vi.fn`), Jest (`globalThis.jest.fn`), `bun:test`
|
|
103
|
+
* (`mock`, when running on Bun), otherwise the built-in spy;
|
|
104
|
+
* - `'builtin'`: the built-in Jest-compatible spy ({@link createSpy});
|
|
105
|
+
* - a factory such as `vi.fn`, `jest.fn` or `mock` from `bun:test`.
|
|
106
|
+
*/
|
|
107
|
+
export type MockFnOption = MockFnFactory | 'auto' | 'builtin'
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** Internal helpers. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Materialises a `Map`, an iterable of `[key, value]` pairs or a record into an array of pairs (an
|
|
5
|
+
* iterable such as a generator can only be read once).
|
|
6
|
+
*/
|
|
7
|
+
export function entriesOf<K, V>(
|
|
8
|
+
values: Iterable<readonly [K, V]> | Readonly<Record<string, V>>,
|
|
9
|
+
): (readonly [K | string, V])[] {
|
|
10
|
+
if (typeof (values as Partial<Iterable<unknown>>)[Symbol.iterator] === 'function') {
|
|
11
|
+
const entries: (readonly [K, V])[] = []
|
|
12
|
+
for (const entry of values as Iterable<readonly [K, V]>) {
|
|
13
|
+
if (!Array.isArray(entry) || entry.length !== 2) {
|
|
14
|
+
throw new TypeError('[znight] Expected [key, value] pairs (a Map, an array of pairs or a record)')
|
|
15
|
+
}
|
|
16
|
+
entries.push(entry)
|
|
17
|
+
}
|
|
18
|
+
return entries
|
|
19
|
+
}
|
|
20
|
+
return Object.entries(values as Readonly<Record<string, V>>)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Whether `value` is a promise-like object (has a callable `then`). */
|
|
24
|
+
export function isThenable(value: unknown): value is PromiseLike<unknown> {
|
|
25
|
+
return (
|
|
26
|
+
value !== null &&
|
|
27
|
+
(typeof value === 'object' || typeof value === 'function') &&
|
|
28
|
+
typeof (value as { then?: unknown }).then === 'function'
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Describes a provider key (class, token...) for messages. */
|
|
33
|
+
export function describeKey(key: unknown): string {
|
|
34
|
+
if (typeof key === 'function') return `class ${(key as { name?: string }).name || '<anonymous>'}`
|
|
35
|
+
if (typeof key === 'string') return `"${key}"`
|
|
36
|
+
return String(key)
|
|
37
|
+
}
|