Skip to content

Commit 9b9f09b

Browse files
authored
fix(spy): copy over static properties from the function (#7780)
1 parent ea4f167 commit 9b9f09b

File tree

7 files changed

+221
-24
lines changed

7 files changed

+221
-24
lines changed

packages/spy/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,6 @@
3333
"dev": "rollup -c --watch"
3434
},
3535
"dependencies": {
36-
"tinyspy": "^3.0.2"
36+
"tinyspy": "^4.0.3"
3737
}
3838
}

packages/spy/src/index.ts

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,14 @@ export interface MockContext<T extends Procedure> {
145145
* @see https://vitest.dev/api/mock#mock-lastcall
146146
*/
147147
lastCall: Parameters<T> | undefined
148+
/** @internal */
149+
_state: (state?: InternalState) => InternalState
150+
}
151+
152+
interface InternalState {
153+
implementation: Procedure | undefined
154+
onceImplementations: Procedure[]
155+
implementationChangedTemporarily: boolean
148156
}
149157

150158
type Procedure = (...args: any[]) => any
@@ -412,7 +420,7 @@ export type Mocked<T> = {
412420
: T[P];
413421
} & T
414422

415-
export const mocks: Set<MockInstance> = new Set()
423+
export const mocks: Set<MockInstance<any>> = new Set()
416424

417425
export function isMockFunction(fn: any): fn is MockInstance {
418426
return (
@@ -449,23 +457,40 @@ export function spyOn<T, K extends keyof T>(
449457
} as const
450458
const objMethod = accessType ? { [dictionary[accessType]]: method } : method
451459

460+
let state: InternalState | undefined
461+
462+
const descriptor = getDescriptor(obj, method)
463+
const fn = descriptor && descriptor[accessType || 'value']
464+
465+
// inherit implementations if it was already mocked
466+
if (isMockFunction(fn)) {
467+
state = fn.mock._state()
468+
}
469+
452470
const stub = tinyspy.internalSpyOn(obj, objMethod as any)
471+
const spy = enhanceSpy(stub) as MockInstance
472+
473+
if (state) {
474+
spy.mock._state(state)
475+
}
453476

454-
return enhanceSpy(stub) as MockInstance
477+
return spy
455478
}
456479

457480
let callOrder = 0
458481

459482
function enhanceSpy<T extends Procedure>(
460483
spy: SpyInternalImpl<Parameters<T>, ReturnType<T>>,
461484
): MockInstance<T> {
462-
type TArgs = Parameters<T>
463485
type TReturns = ReturnType<T>
464486

465487
const stub = spy as unknown as MockInstance<T>
466488

467489
let implementation: T | undefined
468490

491+
let onceImplementations: T[] = []
492+
let implementationChangedTemporarily = false
493+
469494
let instances: any[] = []
470495
let contexts: any[] = []
471496
let invocations: number[] = []
@@ -502,11 +527,20 @@ function enhanceSpy<T extends Procedure>(
502527
get lastCall() {
503528
return state.calls[state.calls.length - 1]
504529
},
530+
_state(state) {
531+
if (state) {
532+
implementation = state.implementation as T
533+
onceImplementations = state.onceImplementations as T[]
534+
implementationChangedTemporarily = state.implementationChangedTemporarily
535+
}
536+
return {
537+
implementation,
538+
onceImplementations,
539+
implementationChangedTemporarily,
540+
}
541+
},
505542
}
506543

507-
let onceImplementations: ((...args: TArgs) => TReturns)[] = []
508-
let implementationChangedTemporarily = false
509-
510544
function mockCall(this: unknown, ...args: any) {
511545
instances.push(this)
512546
contexts.push(this)
@@ -582,7 +616,7 @@ function enhanceSpy<T extends Procedure>(
582616

583617
const result = cb()
584618

585-
if (result instanceof Promise) {
619+
if (typeof result === 'object' && result && typeof result.then === 'function') {
586620
return result.then(() => {
587621
reset()
588622
return stub
@@ -639,3 +673,21 @@ export function fn<T extends Procedure = Procedure>(
639673

640674
return enhancedSpy as any
641675
}
676+
677+
function getDescriptor(
678+
obj: any,
679+
method: string | symbol | number,
680+
): PropertyDescriptor | undefined {
681+
const objDescriptor = Object.getOwnPropertyDescriptor(obj, method)
682+
if (objDescriptor) {
683+
return objDescriptor
684+
}
685+
let currentProto = Object.getPrototypeOf(obj)
686+
while (currentProto !== null) {
687+
const descriptor = Object.getOwnPropertyDescriptor(currentProto, method)
688+
if (descriptor) {
689+
return descriptor
690+
}
691+
currentProto = Object.getPrototypeOf(currentProto)
692+
}
693+
}

pnpm-lock.yaml

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
"sweetalert2": "^11.22.0",
3333
"temporal-polyfill": "~0.3.0",
3434
"tinyrainbow": "catalog:",
35-
"tinyspy": "^1.1.1",
35+
"tinyspy": "^4.0.1",
3636
"url": "^0.11.4",
3737
"vite-node": "workspace:*",
3838
"vitest": "workspace:*",

test/core/test/jest-mock.test.ts

Lines changed: 125 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,33 @@ describe('jest mock compat layer', () => {
363363
expect(obj.property).toBe(true)
364364
})
365365

366+
it('respyin on a spy resets the counter', () => {
367+
const obj = {
368+
method() {
369+
return 'original'
370+
},
371+
}
372+
vi.spyOn(obj, 'method')
373+
obj.method()
374+
expect(obj.method).toHaveBeenCalledTimes(1)
375+
vi.spyOn(obj, 'method')
376+
obj.method()
377+
expect(obj.method).toHaveBeenCalledTimes(1)
378+
})
379+
380+
it('spyOn on the getter multiple times', () => {
381+
const obj = {
382+
get getter() {
383+
return 'original'
384+
},
385+
}
386+
387+
vi.spyOn(obj, 'getter', 'get').mockImplementation(() => 'mocked')
388+
vi.spyOn(obj, 'getter', 'get')
389+
390+
expect(obj.getter).toBe('mocked')
391+
})
392+
366393
it('spyOn multiple times', () => {
367394
const obj = {
368395
method() {
@@ -383,9 +410,9 @@ describe('jest mock compat layer', () => {
383410

384411
spy2.mockRestore()
385412

386-
expect(obj.method()).toBe('mocked')
387-
expect(vi.isMockFunction(obj.method)).toBe(true)
388-
expect(obj.method).toBe(spy1)
413+
expect(obj.method()).toBe('original')
414+
expect(vi.isMockFunction(obj.method)).toBe(false)
415+
expect(obj.method).not.toBe(spy1)
389416

390417
spy1.mockRestore()
391418
expect(vi.isMockFunction(obj.method)).toBe(false)
@@ -560,11 +587,104 @@ describe('jest mock compat layer', () => {
560587
expect(fn.getMockImplementation()).toBe(temporaryMockImplementation)
561588
})
562589

590+
it('keeps the descriptor the same as the original one when restoring', () => {
591+
class Foo {
592+
f() {
593+
return 'original'
594+
}
595+
}
596+
597+
// initially there's no own properties
598+
const foo = new Foo()
599+
expect(foo.f()).toMatchInlineSnapshot(`"original"`)
600+
expect(Object.getOwnPropertyDescriptors(foo)).toMatchInlineSnapshot(`{}`)
601+
602+
// mocked function in own property
603+
const spy = vi.spyOn(foo, 'f').mockImplementation(() => 'mocked')
604+
expect(foo.f()).toMatchInlineSnapshot(`"mocked"`)
605+
expect(Object.getOwnPropertyDescriptors(foo)).toMatchInlineSnapshot(`
606+
{
607+
"f": {
608+
"configurable": true,
609+
"enumerable": false,
610+
"value": [MockFunction f] {
611+
"calls": [
612+
[],
613+
],
614+
"results": [
615+
{
616+
"type": "return",
617+
"value": "mocked",
618+
},
619+
],
620+
},
621+
"writable": true,
622+
},
623+
}
624+
`)
625+
626+
// probably original prototype method is not moved to own property
627+
spy.mockRestore()
628+
expect(foo.f()).toMatchInlineSnapshot(`"original"`)
629+
expect(Object.getOwnPropertyDescriptors(foo)).toMatchInlineSnapshot(`{}`)
630+
})
631+
632+
it('mocks inherited methods', () => {
633+
class Bar {
634+
_bar = 'bar'
635+
get bar(): string {
636+
return this._bar
637+
}
638+
639+
set bar(bar: string) {
640+
this._bar = bar
641+
}
642+
}
643+
class Foo extends Bar {}
644+
const foo = new Foo()
645+
vi.spyOn(foo, 'bar', 'get').mockImplementation(() => 'foo')
646+
expect(foo.bar).toEqual('foo')
647+
// foo.bar setter is inherited from Bar, so we can set it
648+
expect(() => {
649+
foo.bar = 'baz'
650+
}).not.toThrowError()
651+
expect(foo.bar).toEqual('foo')
652+
})
653+
654+
it('mocks inherited overridden methods', () => {
655+
class Bar {
656+
_bar = 'bar'
657+
get bar(): string {
658+
return this._bar
659+
}
660+
661+
set bar(bar: string) {
662+
this._bar = bar
663+
}
664+
}
665+
class Foo extends Bar {
666+
get bar(): string {
667+
return `${super.bar}-foo`
668+
}
669+
}
670+
const foo = new Foo()
671+
expect(foo.bar).toEqual('bar-foo')
672+
vi.spyOn(foo, 'bar', 'get').mockImplementation(() => 'foo')
673+
expect(foo.bar).toEqual('foo')
674+
// foo.bar setter is not inherited from Bar
675+
expect(() => {
676+
// @ts-expect-error bar is readonly
677+
foo.bar = 'baz'
678+
}).toThrowError()
679+
expect(foo.bar).toEqual('foo')
680+
})
681+
563682
describe('is disposable', () => {
564683
describe.runIf(Symbol.dispose)('in environments supporting it', () => {
565684
it('has dispose property', () => {
566685
expect(vi.fn()[Symbol.dispose]).toBeTypeOf('function')
567686
})
687+
568688
it('calls mockRestore when disposing', () => {
569689
const fn = vi.fn()
570690
const restoreSpy = vi.spyOn(fn, 'mockRestore')
@@ -573,10 +693,12 @@ describe('jest mock compat layer', () => {
573693
}
574694
expect(restoreSpy).toHaveBeenCalled()
575695
})
696+
576697
it('allows disposal when using mockImplementation', () => {
577698
expect(vi.fn().mockImplementation(() => {})[Symbol.dispose]).toBeTypeOf('function')
578699
})
579700
})
701+
580702
describe.skipIf(Symbol.dispose)('in environments not supporting it', () => {
581703
it('does not have dispose property', () => {
582704
expect(vi.fn()[Symbol.dispose]).toBeUndefined()

test/core/test/spy.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,27 @@ describe('spyOn', () => {
2929

3030
expect(hw.hello()).toEqual('hello world')
3131
})
32+
33+
test('spying copies properties from functions', () => {
34+
function a() {}
35+
a.HELLO_WORLD = true
36+
const obj = {
37+
a,
38+
}
39+
const spy = vi.spyOn(obj, 'a')
40+
expect(obj.a.HELLO_WORLD).toBe(true)
41+
expect((spy as any).HELLO_WORLD).toBe(true)
42+
})
43+
44+
test('spying copies properties from classes', () => {
45+
class A {
46+
static HELLO_WORLD = true
47+
}
48+
const obj = {
49+
A,
50+
}
51+
const spy = vi.spyOn(obj, 'A')
52+
expect(obj.A.HELLO_WORLD).toBe(true)
53+
expect((spy as any).HELLO_WORLD).toBe(true)
54+
})
3255
})

test/reporters/tests/import-durations.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ describe('import durations', () => {
7171

7272
const throwsFile = resolve(root, 'import-durations-25ms-throws.ts')
7373

74-
expect(file.importDurations?.[throwsFile]?.totalTime).toBeGreaterThanOrEqual(25)
75-
expect(file.importDurations?.[throwsFile]?.selfTime).toBeGreaterThanOrEqual(25)
74+
expect(file.importDurations?.[throwsFile]?.totalTime).toBeGreaterThanOrEqual(24)
75+
expect(file.importDurations?.[throwsFile]?.selfTime).toBeGreaterThanOrEqual(24)
7676
})
7777
})

0 commit comments

Comments
 (0)