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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy