@znight/testing 0.0.0-stage → 0.1.0
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,118 @@
|
|
|
1
|
+
import type { ClassKey, Token, TypedToken } from '@znight/core'
|
|
2
|
+
import type { MetadataIndex } from './metadata-index.ts'
|
|
3
|
+
import type { DeepMock, Mocked } from './types.ts'
|
|
4
|
+
|
|
5
|
+
import { describeToken, displayName, ErrorCode, ZnightError } from '@znight/core'
|
|
6
|
+
|
|
7
|
+
import { clearMock, resetMock } from './mock.ts'
|
|
8
|
+
import { describeKey } from './utils.ts'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The auto-mocks of a `TestBed`, looked up by class, token, alias or display name.
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* const { instance, mocks } = await TestBed.create(UserService, { metadata })
|
|
15
|
+
* mocks.get(UserRepository).findById.mockResolvedValue(user) // typed
|
|
16
|
+
* mocks.get('Logger').info // untyped deep mock
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export interface MockRegistry {
|
|
20
|
+
/**
|
|
21
|
+
* The auto-mock injected for `key`: a class (typed result), a token, an alias (interface, base
|
|
22
|
+
* class) or a unique display name (`'UserRepository'`).
|
|
23
|
+
*
|
|
24
|
+
* @throws ZnightError ZNIGHT001 when no auto-mock matches (the message says why: provided value,
|
|
25
|
+
* configuration class, optional dependency...), ZNIGHT008 when a display name is ambiguous.
|
|
26
|
+
*/
|
|
27
|
+
get<T>(key: ClassKey<T>): Mocked<T>
|
|
28
|
+
get<T = never>(key: TypedToken<T>): [T] extends [never] ? DeepMock : Mocked<T>
|
|
29
|
+
/** Whether an auto-mock matches `key`. */
|
|
30
|
+
has(key: ClassKey | Token): boolean
|
|
31
|
+
/** Tokens of the auto-mocked dependencies, in injection order. */
|
|
32
|
+
tokens(): Token[]
|
|
33
|
+
/** `mockClear()` on every spy of every auto-mock (recorded calls are forgotten). */
|
|
34
|
+
clearAll(): void
|
|
35
|
+
/** Resets every auto-mock to its initial state (calls and programmed behaviours are forgotten). */
|
|
36
|
+
resetAll(): void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Why a dependency was not auto-mocked (used in error messages). */
|
|
40
|
+
export type InjectionKind = 'provided' | 'override' | 'config' | 'optional' | 'interceptor' | 'real'
|
|
41
|
+
|
|
42
|
+
const REASONS: Readonly<Record<InjectionKind, string>> = {
|
|
43
|
+
provided: 'its value was passed with `provide`',
|
|
44
|
+
override: 'it was replaced with `overrides`',
|
|
45
|
+
config: 'it is a configuration class (a real instance is injected; set values with `config`)',
|
|
46
|
+
optional: 'it is an optional dependency (`undefined` is injected unless provided)',
|
|
47
|
+
interceptor: 'it is an interceptor (not applied unless `aop: "real"`)',
|
|
48
|
+
real: 'it was built for real (aop: "real")',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Default {@link MockRegistry} implementation (internal). */
|
|
52
|
+
export class TestMocks implements MockRegistry {
|
|
53
|
+
readonly #mocks: ReadonlyMap<Token, object>
|
|
54
|
+
readonly #others: ReadonlyMap<Token, InjectionKind>
|
|
55
|
+
readonly #index: MetadataIndex
|
|
56
|
+
readonly #owner: Token
|
|
57
|
+
|
|
58
|
+
constructor(
|
|
59
|
+
mocks: ReadonlyMap<Token, object>,
|
|
60
|
+
others: ReadonlyMap<Token, InjectionKind>,
|
|
61
|
+
index: MetadataIndex,
|
|
62
|
+
owner: Token,
|
|
63
|
+
) {
|
|
64
|
+
this.#mocks = mocks
|
|
65
|
+
this.#others = others
|
|
66
|
+
this.#index = index
|
|
67
|
+
this.#owner = owner
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get<T>(key: ClassKey<T>): Mocked<T>
|
|
71
|
+
get<T = never>(key: TypedToken<T>): [T] extends [never] ? DeepMock : Mocked<T>
|
|
72
|
+
get(key: unknown): unknown {
|
|
73
|
+
const token = this.#index.find(key, new Set(this.#mocks.keys()))
|
|
74
|
+
if (token !== undefined) return this.#mocks.get(token)
|
|
75
|
+
throw this.#notMockedError(key)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
has(key: ClassKey | Token): boolean {
|
|
79
|
+
return this.#index.find(key, new Set(this.#mocks.keys())) !== undefined
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
tokens(): Token[] {
|
|
83
|
+
return [...this.#mocks.keys()]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
clearAll(): void {
|
|
87
|
+
for (const mock of this.#mocks.values()) clearMock(mock)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
resetAll(): void {
|
|
91
|
+
for (const mock of this.#mocks.values()) resetMock(mock)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#notMockedError(key: unknown): ZnightError {
|
|
95
|
+
const owner = displayName(this.#owner)
|
|
96
|
+
let other: Token | undefined
|
|
97
|
+
try {
|
|
98
|
+
other = this.#index.find(key, new Set(this.#others.keys()))
|
|
99
|
+
} catch {
|
|
100
|
+
other = undefined
|
|
101
|
+
}
|
|
102
|
+
if (other !== undefined) {
|
|
103
|
+
const kind = this.#others.get(other) as InjectionKind
|
|
104
|
+
return new ZnightError(ErrorCode.MissingProvider, `${describeToken(other)} is not mocked: ${REASONS[kind]}`)
|
|
105
|
+
}
|
|
106
|
+
const mocked = [...this.#mocks.keys()].map(displayName)
|
|
107
|
+
return new ZnightError(
|
|
108
|
+
ErrorCode.MissingProvider,
|
|
109
|
+
`No mock for ${describeKey(key)}: it is not a direct dependency of ${owner}`,
|
|
110
|
+
{
|
|
111
|
+
hints: [
|
|
112
|
+
mocked.length === 0 ? `${owner} has no auto-mocked dependency` : `Auto-mocked: ${mocked.join(', ')}`,
|
|
113
|
+
'TestBed only mocks direct dependencies; regenerate the metadata if the constructor changed',
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
}
|
package/src/mock.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import type { ClassKey } from '@znight/core'
|
|
2
|
+
import type { AnyFunction, DeepMock, Mocked, MockFnFactory, MockFnOption } from './types.ts'
|
|
3
|
+
|
|
4
|
+
import { resolveMockFn } from './mock-fn.ts'
|
|
5
|
+
|
|
6
|
+
/** Options of {@link createMock}. */
|
|
7
|
+
export interface CreateMockOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Name of the mock, used to name its spies in assertion messages (`UserRepository.findById`).
|
|
10
|
+
* Default: the class name, or `mock`.
|
|
11
|
+
*/
|
|
12
|
+
readonly name?: string
|
|
13
|
+
/**
|
|
14
|
+
* When `true`, calling an unconfigured method returns a deep mock (the same one for every call)
|
|
15
|
+
* instead of `undefined`, so fluent APIs work: `db.select().from('users')`. Default: `false`.
|
|
16
|
+
*/
|
|
17
|
+
readonly deep?: boolean
|
|
18
|
+
/** Spy implementation. Default: auto-detected (see `MockFnOption`). */
|
|
19
|
+
readonly mockFn?: MockFnOption
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Shared state of a mock and every spy created under it. */
|
|
23
|
+
interface MockTree {
|
|
24
|
+
readonly create: MockFnFactory
|
|
25
|
+
readonly deep: boolean
|
|
26
|
+
readonly spies: SpyRecord[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface SpyRecord {
|
|
30
|
+
readonly spy: SpyLike
|
|
31
|
+
readonly implementation: AnyFunction | undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface SpyLike {
|
|
35
|
+
(...args: unknown[]): unknown
|
|
36
|
+
mockName?: (name: string) => unknown
|
|
37
|
+
mockClear?: () => unknown
|
|
38
|
+
mockReset?: () => unknown
|
|
39
|
+
mockImplementation?: (implementation: AnyFunction) => unknown
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Property names never auto-mocked (they read as `undefined` unless set explicitly). Libraries probe
|
|
44
|
+
* them to detect promises (`then`), JSON values (`toJSON`), asymmetric matchers (`asymmetricMatch`),
|
|
45
|
+
* React elements (`$$typeof`), DOM nodes (`nodeType`, `tagName`), ES module namespaces
|
|
46
|
+
* (`__esModule`) or mock functions (`_isMockFunction`). Names starting with `@@` (Immutable.js
|
|
47
|
+
* sentinels) are reserved too. Symbol-keyed properties are never auto-mocked.
|
|
48
|
+
*/
|
|
49
|
+
const RESERVED: ReadonlySet<string> = new Set([
|
|
50
|
+
'then',
|
|
51
|
+
'toJSON',
|
|
52
|
+
'asymmetricMatch',
|
|
53
|
+
'$$typeof',
|
|
54
|
+
'nodeType',
|
|
55
|
+
'tagName',
|
|
56
|
+
'__esModule',
|
|
57
|
+
'_isMockFunction',
|
|
58
|
+
])
|
|
59
|
+
|
|
60
|
+
const TREE: unique symbol = Symbol('znight.mock')
|
|
61
|
+
|
|
62
|
+
const mocks = new WeakSet<object>()
|
|
63
|
+
|
|
64
|
+
function isReserved(key: string): boolean {
|
|
65
|
+
return RESERVED.has(key) || key.startsWith('@@')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Creates a deep auto-mock.
|
|
70
|
+
*
|
|
71
|
+
* - Every property read returns a spy (`MockFunction`), created on first access and then stable.
|
|
72
|
+
* Spies are deep too: `mock.pool.query.mockResolvedValue(rows)`.
|
|
73
|
+
* - Unconfigured spies return `undefined` (or a deep mock with `{ deep: true }`).
|
|
74
|
+
* - The mock is **not thenable** and inert for serialisers: `then`, `toJSON`, `asymmetricMatch`,
|
|
75
|
+
* `$$typeof`, `Symbol.toPrimitive`... read as `undefined`, so `await mock`, `JSON.stringify(mock)`
|
|
76
|
+
* and `expect(...).toEqual(mock)` behave.
|
|
77
|
+
* - Assigned properties win: `mock.enabled = false`.
|
|
78
|
+
* - With a class, the mock inherits its prototype (`instanceof` works) and getters read as
|
|
79
|
+
* `undefined` instead of a (truthy) spy.
|
|
80
|
+
* - Functions of `partial` become spies with that implementation; other values are set as is.
|
|
81
|
+
*
|
|
82
|
+
* ```ts
|
|
83
|
+
* const repository = createMock(UserRepository, { findById: async id => ({ id }) })
|
|
84
|
+
* const logger = createMock<Logger>()
|
|
85
|
+
* logger.info('hello')
|
|
86
|
+
* expect(logger.info).toHaveBeenCalledWith('hello')
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export function createMock<T>(cls: ClassKey<T>, partial?: Partial<T>, options?: CreateMockOptions): Mocked<T>
|
|
90
|
+
export function createMock<T = never>(
|
|
91
|
+
partial?: Partial<T>,
|
|
92
|
+
options?: CreateMockOptions,
|
|
93
|
+
): [T] extends [never] ? DeepMock : Mocked<T>
|
|
94
|
+
export function createMock(first?: unknown, second?: unknown, third?: unknown): unknown {
|
|
95
|
+
if (typeof first === 'function') {
|
|
96
|
+
return mockObject(first as ClassKey<unknown>, second as object | undefined, third as CreateMockOptions | undefined)
|
|
97
|
+
}
|
|
98
|
+
return mockObject(undefined, first as object | undefined, second as CreateMockOptions | undefined)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** {@link createMock} with an optional class (internal). */
|
|
102
|
+
export function mockObject(
|
|
103
|
+
cls: ClassKey<unknown> | undefined,
|
|
104
|
+
partial: object | undefined,
|
|
105
|
+
options: CreateMockOptions = {},
|
|
106
|
+
): object {
|
|
107
|
+
if (partial !== undefined && (partial === null || typeof partial !== 'object')) {
|
|
108
|
+
throw new TypeError('[znight] createMock(): the partial implementation must be an object')
|
|
109
|
+
}
|
|
110
|
+
const tree: MockTree = {
|
|
111
|
+
create: resolveMockFn(options.mockFn).create,
|
|
112
|
+
deep: options.deep === true,
|
|
113
|
+
spies: [],
|
|
114
|
+
}
|
|
115
|
+
const className = typeof cls === 'function' ? (cls as { name?: unknown }).name : undefined
|
|
116
|
+
const name = options.name ?? (typeof className === 'string' && className !== '' ? className : 'mock')
|
|
117
|
+
return createNode(name, tree, cls, partial)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Whether `value` is a mock (or one of its spies) created by {@link createMock}. */
|
|
121
|
+
export function isMock(value: unknown): boolean {
|
|
122
|
+
return value !== null && (typeof value === 'object' || typeof value === 'function') && mocks.has(value)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Forgets the calls recorded by every spy of `mock` (`mockClear()`); implementations are kept. */
|
|
126
|
+
export function clearMock(mock: object): void {
|
|
127
|
+
for (const { spy } of treeOf(mock, 'clearMock').spies) spy.mockClear?.()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Resets every spy of `mock` to its initial state: recorded calls and programmed implementations
|
|
132
|
+
* are forgotten, implementations given to {@link createMock} (and deep returns) are restored.
|
|
133
|
+
*/
|
|
134
|
+
export function resetMock(mock: object): void {
|
|
135
|
+
for (const { spy, implementation } of treeOf(mock, 'resetMock').spies) {
|
|
136
|
+
spy.mockReset?.()
|
|
137
|
+
if (implementation !== undefined) spy.mockImplementation?.(implementation)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function treeOf(mock: object, where: string): MockTree {
|
|
142
|
+
const tree = isMock(mock) ? (mock as { [TREE]?: MockTree })[TREE] : undefined
|
|
143
|
+
if (tree === undefined) throw new TypeError(`[znight] ${where}(): expected a mock created by createMock()`)
|
|
144
|
+
return tree
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** An object mock (the root of a mock, or the value returned by a deep spy). */
|
|
148
|
+
function createNode(
|
|
149
|
+
name: string,
|
|
150
|
+
tree: MockTree,
|
|
151
|
+
cls: ClassKey<unknown> | undefined,
|
|
152
|
+
partial: object | undefined,
|
|
153
|
+
): object {
|
|
154
|
+
const target: Record<PropertyKey, unknown> = Object.create(
|
|
155
|
+
typeof cls === 'function' && isObjectLike(cls.prototype) ? (cls.prototype as object) : Object.prototype,
|
|
156
|
+
)
|
|
157
|
+
const accessors = typeof cls === 'function' ? accessorKeys(cls.prototype) : undefined
|
|
158
|
+
const children = new Map<string, unknown>()
|
|
159
|
+
if (partial !== undefined) {
|
|
160
|
+
for (const key of Reflect.ownKeys(partial)) {
|
|
161
|
+
const value = (partial as Record<PropertyKey, unknown>)[key]
|
|
162
|
+
if (typeof key === 'string' && typeof value === 'function') {
|
|
163
|
+
children.set(key, createSpyNode(`${name}.${key}`, tree, value as AnyFunction))
|
|
164
|
+
} else {
|
|
165
|
+
defineValue(target, key, value)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const proxy: object = new Proxy(target, {
|
|
170
|
+
get(object, key, receiver): unknown {
|
|
171
|
+
if (typeof key === 'symbol') {
|
|
172
|
+
if (key === TREE) return tree
|
|
173
|
+
return Object.hasOwn(object, key) ? object[key] : undefined
|
|
174
|
+
}
|
|
175
|
+
if (Object.hasOwn(object, key)) return object[key]
|
|
176
|
+
if (key === 'constructor') return Reflect.get(object, key, receiver)
|
|
177
|
+
if (isReserved(key)) return undefined
|
|
178
|
+
if (Object.hasOwn(Object.prototype, key)) return Reflect.get(Object.prototype, key, receiver)
|
|
179
|
+
if (accessors?.has(key) === true) return undefined
|
|
180
|
+
let child = children.get(key)
|
|
181
|
+
if (child === undefined) {
|
|
182
|
+
child = createSpyNode(`${name}.${key}`, tree, undefined)
|
|
183
|
+
children.set(key, child)
|
|
184
|
+
}
|
|
185
|
+
return child
|
|
186
|
+
},
|
|
187
|
+
set(object, key, value): boolean {
|
|
188
|
+
defineValue(object, key, value)
|
|
189
|
+
return true
|
|
190
|
+
},
|
|
191
|
+
deleteProperty(object, key): boolean {
|
|
192
|
+
if (typeof key === 'string') children.delete(key)
|
|
193
|
+
return Reflect.deleteProperty(object, key)
|
|
194
|
+
},
|
|
195
|
+
})
|
|
196
|
+
mocks.add(proxy)
|
|
197
|
+
return proxy
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** A spy whose unknown properties are deep spies (`spy.nested.method`). */
|
|
201
|
+
function createSpyNode(path: string, tree: MockTree, implementation: AnyFunction | undefined): SpyLike {
|
|
202
|
+
const effective = implementation ?? (tree.deep ? deepReturn(path, tree) : undefined)
|
|
203
|
+
const spy = tree.create(effective) as SpyLike
|
|
204
|
+
if (typeof spy !== 'function') {
|
|
205
|
+
throw new TypeError(`[znight] The mockFn factory returned ${typeof spy} instead of a function`)
|
|
206
|
+
}
|
|
207
|
+
if (typeof spy.mockName === 'function') spy.mockName(path)
|
|
208
|
+
makeDeep(spy, path, tree)
|
|
209
|
+
tree.spies.push({ spy, implementation: effective })
|
|
210
|
+
mocks.add(spy)
|
|
211
|
+
return spy
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The implementation of a deep spy: returns the same deep mock on every call. */
|
|
215
|
+
function deepReturn(path: string, tree: MockTree): AnyFunction {
|
|
216
|
+
let result: object | undefined
|
|
217
|
+
return () => {
|
|
218
|
+
result ??= createNode(`${path}()`, tree, undefined, undefined)
|
|
219
|
+
return result
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Inserts a proxy between `spy` and its prototype: reading a property the spy does not have (own or
|
|
225
|
+
* inherited) creates a child spy. The spy itself stays a genuine spy of its framework, which
|
|
226
|
+
* `bun:test`'s `expect` requires (it rejects proxies).
|
|
227
|
+
*/
|
|
228
|
+
function makeDeep(spy: SpyLike, path: string, tree: MockTree): void {
|
|
229
|
+
const original: unknown = Object.getPrototypeOf(spy)
|
|
230
|
+
if (!isObjectLike(original)) return
|
|
231
|
+
const children = new Map<string, SpyLike>()
|
|
232
|
+
const prototype = new Proxy(original as object, {
|
|
233
|
+
get(object, key, receiver): unknown {
|
|
234
|
+
if (typeof key === 'symbol' || key in object || isReserved(key)) return Reflect.get(object, key, receiver)
|
|
235
|
+
let child = children.get(key)
|
|
236
|
+
if (child === undefined) {
|
|
237
|
+
child = createSpyNode(`${path}.${key}`, tree, undefined)
|
|
238
|
+
children.set(key, child)
|
|
239
|
+
}
|
|
240
|
+
return child
|
|
241
|
+
},
|
|
242
|
+
})
|
|
243
|
+
try {
|
|
244
|
+
Object.setPrototypeOf(spy, prototype)
|
|
245
|
+
} catch {
|
|
246
|
+
// a frozen or exotic spy: it keeps working, without deep members
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function defineValue(object: object, key: PropertyKey, value: unknown): void {
|
|
251
|
+
Object.defineProperty(object, key, { value, writable: true, enumerable: true, configurable: true })
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Names of the accessors (getters/setters) declared along a prototype chain, `Object.prototype` excluded. */
|
|
255
|
+
function accessorKeys(prototype: unknown): ReadonlySet<string> {
|
|
256
|
+
const keys = new Set<string>()
|
|
257
|
+
let current: unknown = prototype
|
|
258
|
+
while (isObjectLike(current) && current !== Object.prototype && current !== Function.prototype) {
|
|
259
|
+
for (const key of Object.getOwnPropertyNames(current)) {
|
|
260
|
+
const descriptor = Object.getOwnPropertyDescriptor(current, key)
|
|
261
|
+
if (descriptor !== undefined && (descriptor.get !== undefined || descriptor.set !== undefined)) keys.add(key)
|
|
262
|
+
}
|
|
263
|
+
current = Object.getPrototypeOf(current)
|
|
264
|
+
}
|
|
265
|
+
return keys
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function isObjectLike(value: unknown): value is object {
|
|
269
|
+
return value !== null && (typeof value === 'object' || typeof value === 'function')
|
|
270
|
+
}
|
package/src/overrides.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { DependencyRef, ProviderDefinition, ProviderFactory, ProviderKey, Scope } from '@znight/core'
|
|
2
|
+
|
|
3
|
+
/** A typed `[key, value]` entry for `overrides` / `providers` / `provide`. */
|
|
4
|
+
export type OverrideEntry<T = unknown> = readonly [ProviderKey<T>, T]
|
|
5
|
+
|
|
6
|
+
/** A `[key, definition]` entry replacing a provider with a factory. */
|
|
7
|
+
export type OverrideDefinitionEntry<T = unknown> = readonly [ProviderKey<T>, ProviderDefinition<T>]
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A type-checked override: the value must match the type designated by the class or typed token.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* await TestApp.create({ metadata, overrides: [override(UserRepository, new InMemoryUserRepository())] })
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export function override<T>(key: ProviderKey<T>, value: NoInfer<T>): OverrideEntry<T> {
|
|
17
|
+
return [key, value]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Options of {@link overrideFactory}. */
|
|
21
|
+
export interface OverrideFactoryOptions {
|
|
22
|
+
/** Tokens resolved and passed to the factory, in order. Default: none. */
|
|
23
|
+
readonly deps?: readonly DependencyRef[]
|
|
24
|
+
/** Default: `'singleton'`. */
|
|
25
|
+
readonly scope?: Scope
|
|
26
|
+
/** Set when the factory returns a promise (singletons only). */
|
|
27
|
+
readonly async?: boolean
|
|
28
|
+
/** Method called when the instance is disposed. */
|
|
29
|
+
readonly dispose?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Replaces a provider with a factory whose dependencies are resolved by the container (or, in
|
|
34
|
+
* `TestBed`, like the target's dependencies).
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* overrideFactory(UserRepository, ([db]) => new InMemoryUserRepository(db), { deps: ['src/db.ts#Database'] })
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function overrideFactory<T>(
|
|
41
|
+
key: ProviderKey<T>,
|
|
42
|
+
factory: ProviderFactory<NoInfer<T>>,
|
|
43
|
+
options: OverrideFactoryOptions = {},
|
|
44
|
+
): OverrideDefinitionEntry<T> {
|
|
45
|
+
if (typeof factory !== 'function') throw new TypeError('[znight] overrideFactory() expects a factory function')
|
|
46
|
+
const definition: ProviderDefinition<T> = {
|
|
47
|
+
scope: options.scope ?? 'singleton',
|
|
48
|
+
deps: options.deps ?? [],
|
|
49
|
+
factory,
|
|
50
|
+
...(options.async === undefined ? {} : { async: options.async }),
|
|
51
|
+
...(options.dispose === undefined ? {} : { dispose: options.dispose }),
|
|
52
|
+
}
|
|
53
|
+
return [key, definition]
|
|
54
|
+
}
|
package/src/spy.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import type { AnyFunction, MockFunction, UntypedFunction } from './types.ts'
|
|
2
|
+
|
|
3
|
+
type Implementation = (this: unknown, ...args: unknown[]) => unknown
|
|
4
|
+
|
|
5
|
+
interface MutableResult {
|
|
6
|
+
type: 'return' | 'throw' | 'incomplete'
|
|
7
|
+
value: unknown
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface MutableState {
|
|
11
|
+
readonly calls: unknown[][]
|
|
12
|
+
readonly results: MutableResult[]
|
|
13
|
+
readonly instances: unknown[]
|
|
14
|
+
readonly contexts: unknown[]
|
|
15
|
+
readonly invocationCallOrder: number[]
|
|
16
|
+
readonly lastCall: unknown[] | undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface SpyInternals {
|
|
20
|
+
name: string
|
|
21
|
+
implementation: Implementation | undefined
|
|
22
|
+
readonly initial: Implementation | undefined
|
|
23
|
+
readonly once: Implementation[]
|
|
24
|
+
readonly state: MutableState
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEFAULT_NAME = 'spy'
|
|
28
|
+
|
|
29
|
+
/** Global call counter shared by every built-in spy (`mock.invocationCallOrder`). */
|
|
30
|
+
let callOrder = 0
|
|
31
|
+
|
|
32
|
+
const internalsBySpy = new WeakMap<object, SpyInternals>()
|
|
33
|
+
|
|
34
|
+
function internalsOf(spy: unknown): SpyInternals {
|
|
35
|
+
const internals = typeof spy === 'function' ? internalsBySpy.get(spy) : undefined
|
|
36
|
+
if (internals === undefined) throw new TypeError('[znight] Not a spy created by createSpy()')
|
|
37
|
+
return internals
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function createState(): MutableState {
|
|
41
|
+
const calls: unknown[][] = []
|
|
42
|
+
return {
|
|
43
|
+
calls,
|
|
44
|
+
results: [],
|
|
45
|
+
instances: [],
|
|
46
|
+
contexts: [],
|
|
47
|
+
invocationCallOrder: [],
|
|
48
|
+
get lastCall(): unknown[] | undefined {
|
|
49
|
+
return calls[calls.length - 1]
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function clearState(state: MutableState): void {
|
|
55
|
+
state.calls.length = 0
|
|
56
|
+
state.results.length = 0
|
|
57
|
+
state.instances.length = 0
|
|
58
|
+
state.contexts.length = 0
|
|
59
|
+
state.invocationCallOrder.length = 0
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isObjectLike(value: unknown): boolean {
|
|
63
|
+
return value !== null && (typeof value === 'object' || typeof value === 'function')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function invoke(internals: SpyInternals, self: unknown, args: unknown[], isNew: boolean): unknown {
|
|
67
|
+
const state = internals.state
|
|
68
|
+
state.calls.push(args)
|
|
69
|
+
state.contexts.push(self)
|
|
70
|
+
state.instances.push(self)
|
|
71
|
+
state.invocationCallOrder.push(++callOrder)
|
|
72
|
+
const result: MutableResult = { type: 'incomplete', value: undefined }
|
|
73
|
+
state.results.push(result)
|
|
74
|
+
const implementation = internals.once.shift() ?? internals.implementation
|
|
75
|
+
try {
|
|
76
|
+
const value = implementation === undefined ? undefined : implementation.apply(self, args)
|
|
77
|
+
const returned = isNew && !isObjectLike(value) ? self : value
|
|
78
|
+
result.type = 'return'
|
|
79
|
+
result.value = returned
|
|
80
|
+
return returned
|
|
81
|
+
} catch (error) {
|
|
82
|
+
result.type = 'throw'
|
|
83
|
+
result.value = error
|
|
84
|
+
throw error
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function returning(value: unknown): Implementation {
|
|
89
|
+
return () => value
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function resolving(value: unknown): Implementation {
|
|
93
|
+
return () => Promise.resolve(value)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function rejecting(error: unknown): Implementation {
|
|
97
|
+
return () => Promise.reject(error)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function returnThis(this: unknown): unknown {
|
|
101
|
+
return this
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Methods shared by every built-in spy (the prototype of the spy functions). */
|
|
105
|
+
const spyPrototype: object = Object.create(Function.prototype, {
|
|
106
|
+
_isMockFunction: { value: true },
|
|
107
|
+
mock: {
|
|
108
|
+
get(this: unknown): MutableState {
|
|
109
|
+
return internalsOf(this).state
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
mockImplementation: {
|
|
113
|
+
value(this: unknown, implementation: Implementation): unknown {
|
|
114
|
+
internalsOf(this).implementation = implementation
|
|
115
|
+
return this
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
mockImplementationOnce: {
|
|
119
|
+
value(this: unknown, implementation: Implementation): unknown {
|
|
120
|
+
internalsOf(this).once.push(implementation)
|
|
121
|
+
return this
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
mockReturnValue: {
|
|
125
|
+
value(this: unknown, value: unknown): unknown {
|
|
126
|
+
internalsOf(this).implementation = returning(value)
|
|
127
|
+
return this
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
mockReturnValueOnce: {
|
|
131
|
+
value(this: unknown, value: unknown): unknown {
|
|
132
|
+
internalsOf(this).once.push(returning(value))
|
|
133
|
+
return this
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
mockResolvedValue: {
|
|
137
|
+
value(this: unknown, value: unknown): unknown {
|
|
138
|
+
internalsOf(this).implementation = resolving(value)
|
|
139
|
+
return this
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
mockResolvedValueOnce: {
|
|
143
|
+
value(this: unknown, value: unknown): unknown {
|
|
144
|
+
internalsOf(this).once.push(resolving(value))
|
|
145
|
+
return this
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
mockRejectedValue: {
|
|
149
|
+
value(this: unknown, error: unknown): unknown {
|
|
150
|
+
internalsOf(this).implementation = rejecting(error)
|
|
151
|
+
return this
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
mockRejectedValueOnce: {
|
|
155
|
+
value(this: unknown, error: unknown): unknown {
|
|
156
|
+
internalsOf(this).once.push(rejecting(error))
|
|
157
|
+
return this
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
mockReturnThis: {
|
|
161
|
+
value(this: unknown): unknown {
|
|
162
|
+
internalsOf(this).implementation = returnThis
|
|
163
|
+
return this
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
mockName: {
|
|
167
|
+
value(this: unknown, name: string): unknown {
|
|
168
|
+
internalsOf(this).name = String(name)
|
|
169
|
+
return this
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
getMockName: {
|
|
173
|
+
value(this: unknown): string {
|
|
174
|
+
return internalsOf(this).name
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
getMockImplementation: {
|
|
178
|
+
value(this: unknown): Implementation | undefined {
|
|
179
|
+
return internalsOf(this).implementation
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
mockClear: {
|
|
183
|
+
value(this: unknown): unknown {
|
|
184
|
+
clearState(internalsOf(this).state)
|
|
185
|
+
return this
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
mockReset: {
|
|
189
|
+
value(this: unknown): unknown {
|
|
190
|
+
const internals = internalsOf(this)
|
|
191
|
+
clearState(internals.state)
|
|
192
|
+
internals.once.length = 0
|
|
193
|
+
internals.implementation = internals.initial
|
|
194
|
+
return this
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
mockRestore: {
|
|
198
|
+
value(this: unknown): void {
|
|
199
|
+
const internals = internalsOf(this)
|
|
200
|
+
clearState(internals.state)
|
|
201
|
+
internals.once.length = 0
|
|
202
|
+
internals.implementation = internals.initial
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Creates the built-in, dependency-free, Jest-compatible spy. It records `mock.calls`,
|
|
209
|
+
* `mock.results`, `mock.instances`, `mock.contexts`, `mock.invocationCallOrder` and `mock.lastCall`,
|
|
210
|
+
* supports `mockReturnValue(Once)`, `mockResolvedValue(Once)`, `mockRejectedValue(Once)`,
|
|
211
|
+
* `mockImplementation(Once)`, `mockReturnThis`, `mockName`, `mockClear`, `mockReset` and
|
|
212
|
+
* `mockRestore`, and is recognised by Jest's and Vitest's `expect` (`_isMockFunction`).
|
|
213
|
+
*
|
|
214
|
+
* `mockReset()` restores the implementation given to `createSpy()` (Vitest semantics).
|
|
215
|
+
*
|
|
216
|
+
* ```ts
|
|
217
|
+
* const find = createSpy((id: string) => ({ id }))
|
|
218
|
+
* find.mockReturnValueOnce({ id: 'x' })
|
|
219
|
+
* find('1')
|
|
220
|
+
* find.mock.calls // [['1']]
|
|
221
|
+
* ```
|
|
222
|
+
*/
|
|
223
|
+
export function createSpy<F extends AnyFunction = UntypedFunction>(implementation?: F): MockFunction<F> {
|
|
224
|
+
if (implementation !== undefined && typeof implementation !== 'function') {
|
|
225
|
+
throw new TypeError('[znight] createSpy() expects a function implementation')
|
|
226
|
+
}
|
|
227
|
+
const initial = implementation as Implementation | undefined
|
|
228
|
+
const internals: SpyInternals = {
|
|
229
|
+
name: DEFAULT_NAME,
|
|
230
|
+
implementation: initial,
|
|
231
|
+
initial,
|
|
232
|
+
once: [],
|
|
233
|
+
state: createState(),
|
|
234
|
+
}
|
|
235
|
+
const spy = function (this: unknown, ...args: unknown[]): unknown {
|
|
236
|
+
return invoke(internals, this, args, new.target !== undefined)
|
|
237
|
+
}
|
|
238
|
+
Object.setPrototypeOf(spy, spyPrototype)
|
|
239
|
+
internalsBySpy.set(spy, internals)
|
|
240
|
+
return spy as unknown as MockFunction<F>
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Whether `value` is a spy created by {@link createSpy}. */
|
|
244
|
+
export function isBuiltinSpy(value: unknown): boolean {
|
|
245
|
+
return typeof value === 'function' && internalsBySpy.has(value)
|
|
246
|
+
}
|