Skip to content

Commit

Permalink
fix: mock properties of classes/functions (fix #1523) (#1648)
Browse files Browse the repository at this point in the history
  • Loading branch information
simon-abbott committed Jul 16, 2022
1 parent b181772 commit 4aeeee0
Show file tree
Hide file tree
Showing 3 changed files with 153 additions and 31 deletions.
120 changes: 93 additions & 27 deletions packages/vitest/src/runtime/mocker.ts
Expand Up @@ -8,6 +8,34 @@ import { distDir } from '../constants'
import type { PendingSuiteMock } from '../types/mocker'
import type { ExecuteOptions } from './execute'

class RefTracker {
private idMap = new Map<any, number>()
private mockedValueMap = new Map<number, any>()

public getId(value: any) {
return this.idMap.get(value)
}

public getMockedValue(id: number) {
return this.mockedValueMap.get(id)
}

public track(originalValue: any, mockedValue: any): number {
const newId = this.idMap.size
this.idMap.set(originalValue, newId)
this.mockedValueMap.set(newId, mockedValue)
return newId
}
}

type Key = string | symbol

function isSpecialProp(prop: Key, parentType: string) {
return parentType.includes('Function')
&& typeof prop === 'string'
&& ['arguments', 'callee', 'caller', 'length', 'name'].includes(prop)
}

interface ViteRunnerRequest {
(dep: string): any
callstack: string[]
Expand Down Expand Up @@ -129,40 +157,78 @@ export class VitestMocker {
return existsSync(fullPath) ? fullPath : null
}

public mockValue(value: any) {
public mockObject(object: Record<string | symbol, any>) {
if (!VitestMocker.spyModule) {
throw new Error(
'Error: Spy module is not defined. '
+ 'This is likely an internal bug in Vitest. '
+ 'Please report it to https://github.com/vitest-dev/vitest/issues')
}

const type = getType(value)

if (Array.isArray(value))
return []
else if (type !== 'Object' && type !== 'Module')
return value

const newObj: Record<string | symbol, any> = {}

const properties = getAllProperties(value)

for (const k of properties) {
newObj[k] = this.mockValue(value[k])
const type = getType(value[k])

if (type.includes('Function') && !value[k]._isMockFunction) {
VitestMocker.spyModule.spyOn(newObj, k).mockImplementation(() => undefined)
Object.defineProperty(newObj[k], 'length', { value: 0 }) // tinyspy retains length, but jest doesnt
const spyModule = VitestMocker.spyModule

const finalizers = new Array<() => void>()
const refs = new RefTracker()

const mockPropertiesOf = (container: Record<Key, any>, newContainer: Record<Key, any>) => {
const containerType = getType(container)
const isModule = containerType === 'Module' || !!container.__esModule
for (const property of getAllProperties(container)) {
// Modules define their exports as getters. We want to process those.
if (!isModule) {
// TODO: Mock getters/setters somehow?
const descriptor = Object.getOwnPropertyDescriptor(container, property)
if (descriptor?.get || descriptor?.set)
continue
}

// Skip special read-only props, we don't want to mess with those.
if (isSpecialProp(property, containerType))
continue

const value = container[property]

// Special handling of references we've seen before to prevent infinite
// recursion in circular objects.
const refId = refs.getId(value)
if (refId) {
finalizers.push(() => newContainer[property] = refs.getMockedValue(refId))
continue
}

const type = getType(value)

if (Array.isArray(value)) {
newContainer[property] = []
continue
}

const isFunction = type.includes('Function') && typeof value === 'function'
if ((!isFunction || value.__isMockFunction) && type !== 'Object' && type !== 'Module') {
newContainer[property] = value
continue
}

newContainer[property] = isFunction ? value : {}

if (isFunction) {
spyModule.spyOn(newContainer, property).mockImplementation(() => undefined)
// tinyspy retains length, but jest doesn't.
Object.defineProperty(newContainer[property], 'length', { value: 0 })
}

refs.track(value, newContainer[property])
mockPropertiesOf(value, newContainer[property])
}
}

// should be defined after object, because it may contain
// special logic on getting/settings properties
// and we don't want to invoke it
Object.setPrototypeOf(newObj, Object.getPrototypeOf(value))
return newObj
const mockedObject: Record<Key, any> = {}
mockPropertiesOf(object, mockedObject)

// Plug together refs
for (const finalizer of finalizers)
finalizer()

return mockedObject
}

public unmockPath(path: string) {
Expand Down Expand Up @@ -205,7 +271,7 @@ export class VitestMocker {
if (mock === null) {
await this.ensureSpy()
const mod = await this.request(fsPath)
return this.mockValue(mod)
return this.mockObject(mod)
}

if (typeof mock === 'function')
Expand Down Expand Up @@ -236,7 +302,7 @@ export class VitestMocker {
return cache.exports
const cacheKey = toFilePath(dep, this.root)
const mod = this.moduleCache.get(cacheKey)?.exports || await this.request(dep)
const exports = this.mockValue(mod)
const exports = this.mockObject(mod)
this.moduleCache.set(cacheName, { exports })
return exports
}
Expand Down
24 changes: 24 additions & 0 deletions test/core/src/mockedC.ts
@@ -0,0 +1,24 @@
import { mockedA } from './mockedA'

export class MockedC {
public value: number

constructor() {
this.value = 42
}

public doSomething() {
return mockedA()
}

get getSetProp(): number {
return 123
}

set getSetProp(_val: number) {}
}

export async function asyncFunc(): Promise<string> {
await new Promise<void>(resolve => resolve())
return '1234'
}
40 changes: 36 additions & 4 deletions test/core/test/mocked.test.ts
@@ -1,21 +1,22 @@
import { assert, expect, test, vi, vitest } from 'vitest'
import { assert, describe, expect, test, vi, vitest } from 'vitest'
// @ts-expect-error not typed module
import { value as virtualValue } from 'virtual-module'
import { two } from '../src/submodule'
import * as mocked from '../src/mockedA'
import { mockedB } from '../src/mockedB'
import { MockedC, asyncFunc } from '../src/mockedC'
import * as globalMock from '../src/global-mock'

vitest.mock('../src/submodule')
vitest.mock('virtual-module', () => {
return { value: 'mock' }
})
vitest.mock('virtual-module', () => ({ value: 'mock' }))
vitest.mock('../src/mockedC')

test('submodule is mocked to return "two" as 3', () => {
assert.equal(3, two)
})

test('globally mocked files are mocked', () => {
// Mocked in setup.ts
expect(globalMock.mocked).toBe(true)
})

Expand All @@ -31,3 +32,34 @@ test('can mock esm', () => {
test('mocked exports should override original exports', () => {
expect(virtualValue).toBe('mock')
})

describe('mocked classes', () => {
test('should not delete the prototype', () => {
expect(MockedC).toBeTypeOf('function')
expect(MockedC.prototype.doSomething).toBeTypeOf('function')
})

test('should mock the constructor', () => {
const instance = new MockedC()

expect(instance.value).not.toBe(42)
expect(MockedC).toHaveBeenCalledOnce()
})

test('should mock functions in the prototype', () => {
const instance = new MockedC()

expect(instance.doSomething).toBeTypeOf('function')
expect(instance.doSomething()).not.toBe('A')

expect(MockedC.prototype.doSomething).toHaveBeenCalledOnce()
expect(MockedC.prototype.doSomething).not.toHaveReturnedWith('A')
})
})

test('async functions should be mocked', () => {
expect(asyncFunc()).toBeUndefined()
expect(vi.mocked(asyncFunc).mockResolvedValue).toBeDefined()
vi.mocked(asyncFunc).mockResolvedValue('foo')
expect(asyncFunc()).resolves.toBe('foo')
})

0 comments on commit 4aeeee0

Please sign in to comment.