Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow mocking property value in tests #13496

Merged
merged 22 commits into from Jan 4, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
15697c7
feat(jest-mock): Add mockProperty() method
Oct 23, 2022
9c270ca
feat(jest-environment): Add mockProperty() method type to global Jest…
Oct 23, 2022
20b881b
refactor(jest-mock): Be more strict for MockedPropertyRestorer generi…
Dec 19, 2022
930036d
refactor(jest-mock): Allow mocking property named '0' or ""
Dec 19, 2022
c739864
refactor(jest-mock): Throw more descriptive error messages when tryin…
Dec 19, 2022
0f227c2
refactor(jest-mock): Allow mocking already mocked property with diffe…
Dec 19, 2022
1c4535b
refactor(jest-mock): Add type tests for mockProperty
Dec 19, 2022
4a1aeb6
refactor(jest-runtime): Fix missing mockProperty export
Dec 19, 2022
c79837d
Merge remote-tracking branch 'upstream/main'
Dec 19, 2022
cceffa0
refactor(jest-mock): Fix typing and interface of mockProperty methods
Dec 20, 2022
6c17cc0
refactor(jest-mock, docs): Document replaceProperty method and its im…
Dec 20, 2022
d828f11
refactor(docs): Remove forgotten TODO
Dec 20, 2022
4f9ac47
refactor(jest-mock, jest-types): Add additional tests for replaced pr…
Dec 25, 2022
b3fb383
refactor(jest-environment, jest-globals): Fix JSDoc comments for repl…
Dec 25, 2022
208df4d
refactor(docs): Improve style of replaced property sections and apply…
Dec 25, 2022
2ed2ca8
Merge branch 'main' into main
SimenB Jan 3, 2023
ba36a3b
Update docs/MockFunctionAPI.md
SimenB Jan 3, 2023
25d1b24
Merge remote-tracking branch 'upstream/main'
Jan 3, 2023
472841c
refactor(jest-mock): Fix type tests compatibility with TS 4.3
Jan 3, 2023
2f9c9c9
refactor(jest-runtime): Fix forgotten rename of replaceProperty from …
Jan 3, 2023
33c30b9
refactor(jest-mock): Hint to use replaceProperty when trying to mock …
Jan 3, 2023
f0ffae1
refactor(docs): Relate two files in examples by providing correct path
Jan 3, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/jest-environment/src/index.ts
Expand Up @@ -319,6 +319,16 @@ export interface Jest {
* behavior from most other test libraries.
*/
spyOn: ModuleMocker['spyOn'];
/**
* Replaces property on object with mock value.
*
* This method does not work on 'get' or 'set' accessors, and cannot be called
* on already replaced value.
michal-kocarek marked this conversation as resolved.
Show resolved Hide resolved
*
* @remarks
* For mocking functions, use `jest.spyOn()` instead.
*/
mockProperty: ModuleMocker['mockProperty'];
/**
* Indicates that the module system should never return a mocked version of
* the specified module from `require()` (e.g. that it should always return the
Expand Down
152 changes: 152 additions & 0 deletions packages/jest-mock/src/__tests__/index.test.ts
Expand Up @@ -1586,6 +1586,158 @@ describe('moduleMocker', () => {
expect(spy2.mock.calls).toHaveLength(1);
});
});

describe('mockProperty', () => {
it('should work', () => {
const obj = {
property: 1,
};

const replaced = moduleMocker.mockProperty(obj, 'property', 2);

expect(obj.property).toBe(2);

replaced.mockRestore();

expect(obj.property).toBe(1);
});

describe('should throw', () => {
it('when object is not provided', () => {
expect(() => {
moduleMocker.mockProperty(null, 'property', 1);
}).toThrow(
'mockProperty could not find an object on which to replace property',
);
});

it('when primitive value is provided instead of object', () => {
expect(() => {
moduleMocker.mockProperty(1, 'property', 1);
}).toThrow('Cannot mock property on a primitive value; number given');
});

it('when function is provided instead of object', () => {
expect(() => {
moduleMocker.mockProperty(() => {}, 'property', 1);
}).toThrow('Cannot mock property on a primitive value; function given');
});

it('when property name is not provided', () => {
expect(() => {
moduleMocker.mockProperty({}, null, 1);
}).toThrow('No property name supplied');
});

it('when property is not defined', () => {
expect(() => {
moduleMocker.mockProperty({}, 'doesNotExist', 1);
}).toThrow('doesNotExist property does not exist');
});

it('when property is not configurable', () => {
expect(() => {
const obj = {};

Object.defineProperty(obj, 'property', {
configurable: false,
value: 1,
writable: false,
});

moduleMocker.mockProperty(obj, 'property', 2);
}).toThrow('property is not declared configurable');
});

it('when mocking with value of different type', () => {
expect(() => {
moduleMocker.mockProperty({property: 1}, 'property', 'string');
}).toThrow(
'Cannot mock the property property because it is not a number; string given instead',
michal-kocarek marked this conversation as resolved.
Show resolved Hide resolved
);
});

it('when trying to mock a method', () => {
expect(() => {
moduleMocker.mockProperty({method: () => {}}, 'method', () => {});
}).toThrow(
'Cannot mock the method property because it is a function; use spyOn instead',
);
});

it('when mocking a getter', () => {
const obj = {
get getter() {
return 1;
},
};

expect(() => {
moduleMocker.mockProperty(obj, 'getter', 1);
}).toThrow('Cannot mock the getter property because it has a getter');
});

it('when mocking a setter', () => {
const obj = {
// eslint-disable-next-line accessor-pairs
set setter(_value: number) {},
};

expect(() => {
moduleMocker.mockProperty(obj, 'setter', 1);
}).toThrow('Cannot mock the setter property because it has a setter');
});
});

it('should not replace property that has been already replaced', () => {
const obj = {
property: 1,
};

moduleMocker.mockProperty(obj, 'property', 2);

expect(() => {
moduleMocker.mockProperty(obj, 'property', 3);
}).toThrow(
'Cannot mock the property property because it is already mocked',
);
});

it('should work for property from prototype chain', () => {
const parent = {property: 'abcd'};
const child = Object.create(parent);

const replaced = moduleMocker.mockProperty(child, 'property', 'defg');

expect(child.property).toBe('defg');

replaced.mockRestore();

expect(child.property).toBe('abcd');
expect(
Object.getOwnPropertyDescriptor(child, 'property'),
).toBeUndefined();
});

it('should restore property as part of calling restoreAllMocks', () => {
const obj = {
property: 1,
};

const replaced = moduleMocker.mockProperty(obj, 'property', 2);

expect(obj.property).toBe(2);

moduleMocker.restoreAllMocks();

expect(obj.property).toBe(1);

// Just make sure that this call won't break anything while calling after the property has been already restored
replaced.mockRestore();

expect(obj.property).toBe(1);
});
});
});

describe('mocked', () => {
Expand Down
144 changes: 144 additions & 0 deletions packages/jest-mock/src/index.ts
Expand Up @@ -172,6 +172,19 @@ export interface MockInstance<T extends FunctionLike = UnknownFunction> {
mockRejectedValueOnce(value: RejectType<T>): this;
}

export interface MockedProperty {
/**
* Restore property to its original value known at the time of mocking.
*/
mockRestore(): void;
}

type MockedPropertyRestorer<T = object> = {
michal-kocarek marked this conversation as resolved.
Show resolved Hide resolved
(): void;
object: T;
property: keyof T;
};

type MockFunctionResultIncomplete = {
type: 'incomplete';
/**
Expand Down Expand Up @@ -987,6 +1000,27 @@ export class ModuleMocker {
return mock as Mocked<T>;
}

/**
* Check whether given property of an object has been already mocked.
*/
private _isAlreadyMocked<T extends object>(
object: T,
propertyKey: keyof T,
): boolean {
for (const spyState of this._spyState) {
if (
'object' in spyState &&
'property' in spyState &&
(spyState as MockedPropertyRestorer).object === object &&
(spyState as MockedPropertyRestorer).property === propertyKey
michal-kocarek marked this conversation as resolved.
Show resolved Hide resolved
) {
return true;
}
}

return false;
}

/**
* @see README.md
* @param metadata Metadata for the mock in the schema returned by the
Expand Down Expand Up @@ -1277,6 +1311,116 @@ export class ModuleMocker {
return mock;
}

mockProperty<
T extends object,
P extends PropertyLikeKeys<T>,
V extends Required<T>[P],
>(object: T, propertyKey: P, value: V): MockedProperty {
if (!object) {
throw new Error(
`mockProperty could not find an object on which to replace ${String(
propertyKey,
)}`,
);
}

if (!propertyKey) {
michal-kocarek marked this conversation as resolved.
Show resolved Hide resolved
throw new Error('No property name supplied');
}

if (typeof object !== 'object') {
throw new Error(
`Cannot mock property on a primitive value; ${this._typeOf(
object,
)} given`,
);
}

let descriptor = Object.getOwnPropertyDescriptor(object, propertyKey);
let proto = Object.getPrototypeOf(object);
while (!descriptor && proto !== null) {
descriptor = Object.getOwnPropertyDescriptor(proto, propertyKey);
proto = Object.getPrototypeOf(proto);
}
if (!descriptor) {
throw new Error(`${String(propertyKey)} property does not exist`);
}
if (!descriptor.configurable) {
throw new Error(`${String(propertyKey)} is not declared configurable`);
}

if (this._isAlreadyMocked(object, propertyKey)) {
throw new Error(
`Cannot mock the ${String(
propertyKey,
)} property because it is already mocked`,
michal-kocarek marked this conversation as resolved.
Show resolved Hide resolved
);
}

if (descriptor.get !== undefined) {
throw new Error(
`Cannot mock the ${String(
propertyKey,
)} property because it has a getter`,
michal-kocarek marked this conversation as resolved.
Show resolved Hide resolved
);
}

if (descriptor.set !== undefined) {
throw new Error(
`Cannot mock the ${String(
propertyKey,
)} property because it has a setter`,
michal-kocarek marked this conversation as resolved.
Show resolved Hide resolved
);
}

if (typeof descriptor.value !== typeof value) {
michal-kocarek marked this conversation as resolved.
Show resolved Hide resolved
throw new Error(
`Cannot mock the ${String(
propertyKey,
)} property because it is not a ${this._typeOf(
descriptor.value,
)}; ${this._typeOf(value)} given instead`,
);
}

if (typeof descriptor.value === 'function') {
throw new Error(
`Cannot mock the ${String(
propertyKey,
)} property because it is a function; use spyOn instead`,
);
}

const isPropertyOwner = Object.prototype.hasOwnProperty.call(
object,
propertyKey,
);
const originalValue = descriptor.value;

object[propertyKey] = value;

const restore: MockedPropertyRestorer<typeof object> = () => {
if (isPropertyOwner) {
object[propertyKey] = originalValue;
} else {
delete object[propertyKey];
}
};

restore.object = object;
restore.property = propertyKey;

this._spyState.add(restore);

return {
mockRestore: (): void => {
restore();

this._spyState.delete(restore);
},
};
}

clearAllMocks(): void {
this._mockState = new WeakMap();
}
Expand Down