-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
/
Copy pathtesting.ts
270 lines (241 loc) · 7.96 KB
/
testing.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
import { computed, createApp, isReactive, isRef, toRaw, triggerRef } from 'vue'
import type { App, ComputedRef, WritableComputedRef } from 'vue'
import {
Pinia,
PiniaPlugin,
setActivePinia,
createPinia,
StateTree,
_DeepPartial,
PiniaPluginContext,
} from 'pinia'
// NOTE: the implementation type is correct and contains up to date types
// while the other types hide internal properties
import type { ComputedRefImpl } from '@vue/reactivity'
type MockFn = ((...args: any[]) => any) & {
mockReturnValue: (value: any) => MockFn
}
export interface TestingOptions {
/**
* Allows defining a partial initial state of all your stores. This state gets applied after a store is created,
* allowing you to only set a few properties that are required in your test.
*/
initialState?: StateTree
/**
* Plugins to be installed before the testing plugin. Add any plugins used in
* your application that will be used while testing.
*/
plugins?: PiniaPlugin[]
/**
* When set to false, actions are only spied, but they will still get executed. When
* set to true, actions will be replaced with spies, resulting in their code
* not being executed. Defaults to true. NOTE: when providing `createSpy()`,
* it will **only** make the `fn` argument `undefined`. You still have to
* handle this in `createSpy()`.
*/
stubActions?: boolean
/**
* When set to true, calls to `$patch()` won't change the state. Defaults to
* false. NOTE: when providing `createSpy()`, it will **only** make the `fn`
* argument `undefined`. You still have to handle this in `createSpy()`.
*/
stubPatch?: boolean
/**
* When set to true, calls to `$reset()` won't change the state. Defaults to
* false.
*/
stubReset?: boolean
/**
* Creates an empty App and calls `app.use(pinia)` with the created testing
* pinia. This allows you to use plugins while unit testing stores as
* plugins **will wait for pinia to be installed in order to be executed**.
* Defaults to false.
*/
fakeApp?: boolean
/**
* Function used to create a spy for actions and `$patch()`. Pre-configured
* with `jest.fn` in Jest projects or `vi.fn` in Vitest projects if
* `globals: true` is set.
*/
createSpy?: (fn?: (...args: any[]) => any) => MockFn
}
/**
* Pinia instance specifically designed for testing. Extends a regular
* `Pinia` instance with test specific properties.
*/
export interface TestingPinia extends Pinia {
/** App used by Pinia */
app: App
}
declare var vi:
| undefined
| {
fn: (fn?: (...args: any[]) => any) => MockFn
}
/**
* Creates a pinia instance designed for unit tests that **requires mocking**
* the stores. By default, **all actions are mocked** and therefore not
* executed. This allows you to unit test your store and components separately.
* You can change this with the `stubActions` option. If you are using jest,
* they are replaced with `jest.fn()`, otherwise, you must provide your own
* `createSpy` option.
*
* @param options - options to configure the testing pinia
* @returns a augmented pinia instance
*/
export function createTestingPinia({
initialState = {},
plugins = [],
stubActions = true,
stubPatch = false,
stubReset = false,
fakeApp = false,
createSpy: _createSpy,
}: TestingOptions = {}): TestingPinia {
const pinia = createPinia()
// allow adding initial state
pinia._p.push(({ store }) => {
if (initialState[store.$id]) {
mergeReactiveObjects(store.$state, initialState[store.$id])
}
})
// bypass waiting for the app to be installed to ensure the action stubbing happens last
plugins.forEach((plugin) => pinia._p.push(plugin))
// allow computed to be manually overridden
pinia._p.push(WritableComputed)
const createSpy =
_createSpy ||
// @ts-ignore
(typeof jest !== 'undefined' && (jest.fn as typeof _createSpy)) ||
(typeof vi !== 'undefined' && vi.fn)
/* istanbul ignore if */
if (!createSpy) {
throw new Error(
'[@pinia/testing]: You must configure the `createSpy` option. See https://pinia.vuejs.org/cookbook/testing.html#Specifying-the-createSpy-function'
)
} else if (
typeof createSpy !== 'function' ||
// When users pass vi.fn() instead of vi.fn
// https://github.com/vuejs/pinia/issues/2896
'mockReturnValue' in createSpy
) {
throw new Error(
'[@pinia/testing]: Invalid `createSpy` option. See https://pinia.vuejs.org/cookbook/testing.html#Specifying-the-createSpy-function'
)
}
const trySpyWithMock = (fn: any) => {
try {
// user provided createSpy might not have a mockReturnValue similar to jest and vitest
return createSpy(fn).mockReturnValue(undefined)
} catch (e) {
return createSpy()
}
}
// stub actions
pinia._p.push(({ store, options }) => {
Object.keys(options.actions).forEach((action) => {
if (action === '$reset') return
store[action] = stubActions
? trySpyWithMock(store[action])
: createSpy(store[action])
})
store.$patch = stubPatch
? trySpyWithMock(store.$patch)
: createSpy(store.$patch)
store.$reset = stubReset
? trySpyWithMock(store.$reset)
: createSpy(store.$reset)
})
if (fakeApp) {
const app = createApp({})
app.use(pinia)
}
pinia._testing = true
setActivePinia(pinia)
Object.defineProperty(pinia, 'app', {
configurable: true,
enumerable: true,
get(): App {
return this._a
},
})
return pinia as TestingPinia
}
function mergeReactiveObjects<T extends StateTree>(
target: T,
patchToApply: _DeepPartial<T>
): T {
// no need to go through symbols because they cannot be serialized anyway
for (const key in patchToApply) {
if (!patchToApply.hasOwnProperty(key)) continue
const subPatch = patchToApply[key]
const targetValue = target[key]
if (
isPlainObject(targetValue) &&
isPlainObject(subPatch) &&
target.hasOwnProperty(key) &&
!isRef(subPatch) &&
!isReactive(subPatch)
) {
target[key] = mergeReactiveObjects(targetValue, subPatch)
} else {
// @ts-expect-error: subPatch is a valid value
target[key] =
//
subPatch
}
}
return target
}
function isPlainObject<S extends StateTree>(value: S | unknown): value is S
function isPlainObject(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
o: any
): o is StateTree {
return (
o &&
typeof o === 'object' &&
Object.prototype.toString.call(o) === '[object Object]' &&
typeof o.toJSON !== 'function'
)
}
function isComputed<T>(
v: ComputedRef<T> | WritableComputedRef<T> | unknown
): v is (ComputedRef<T> | WritableComputedRef<T>) & ComputedRefImpl<T> {
return !!v && isRef(v) && 'effect' in v
}
function WritableComputed({ store }: PiniaPluginContext) {
const rawStore = toRaw(store)
for (const key in rawStore) {
const originalComputed = rawStore[key]
if (isComputed(originalComputed)) {
const originalFn = originalComputed.fn
// override the computed with a new one
const overriddenFn = () =>
// @ts-expect-error: internal cached value
originalComputed._value
// originalComputed.fn = overriddenFn
rawStore[key] = computed<unknown>({
get() {
return originalComputed.value
},
set(newValue) {
// reset the computed to its original value by setting it to its initial state
if (newValue === undefined) {
originalComputed.fn = originalFn
// @ts-expect-error: private api to remove the current cached value
delete originalComputed._value
// @ts-expect-error: private api to force the recomputation
originalComputed._dirty = true
} else {
originalComputed.fn = overriddenFn
// @ts-expect-error: private api
originalComputed._value = newValue
}
// this allows to trigger the original computed in setup stores
triggerRef(originalComputed)
},
})
}
}
}