@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.
@@ -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
+ }