From 15697c785f1144a58badf85ac137c7cae192f359 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Ko=C4=8D=C3=A1rek?= Date: Sun, 23 Oct 2022 23:10:45 +0200 Subject: [PATCH 01/19] feat(jest-mock): Add mockProperty() method --- .../jest-mock/src/__tests__/index.test.ts | 152 ++++++++++++++++++ packages/jest-mock/src/index.ts | 144 +++++++++++++++++ 2 files changed, 296 insertions(+) diff --git a/packages/jest-mock/src/__tests__/index.test.ts b/packages/jest-mock/src/__tests__/index.test.ts index 8f1436f4b042..042eea447ee0 100644 --- a/packages/jest-mock/src/__tests__/index.test.ts +++ b/packages/jest-mock/src/__tests__/index.test.ts @@ -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', + ); + }); + + 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', () => { diff --git a/packages/jest-mock/src/index.ts b/packages/jest-mock/src/index.ts index d0c6ba44ac8c..121be6bf3164 100644 --- a/packages/jest-mock/src/index.ts +++ b/packages/jest-mock/src/index.ts @@ -172,6 +172,19 @@ export interface MockInstance { mockRejectedValueOnce(value: RejectType): this; } +export interface MockedProperty { + /** + * Restore property to its original value known at the time of mocking. + */ + mockRestore(): void; +} + +type MockedPropertyRestorer = { + (): void; + object: T; + property: keyof T; +}; + type MockFunctionResultIncomplete = { type: 'incomplete'; /** @@ -987,6 +1000,27 @@ export class ModuleMocker { return mock as Mocked; } + /** + * Check whether given property of an object has been already mocked. + */ + private _isAlreadyMocked( + 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 + ) { + return true; + } + } + + return false; + } + /** * @see README.md * @param metadata Metadata for the mock in the schema returned by the @@ -1277,6 +1311,116 @@ export class ModuleMocker { return mock; } + mockProperty< + T extends object, + P extends PropertyLikeKeys, + V extends Required[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) { + 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`, + ); + } + + if (descriptor.get !== undefined) { + throw new Error( + `Cannot mock the ${String( + propertyKey, + )} property because it has a getter`, + ); + } + + if (descriptor.set !== undefined) { + throw new Error( + `Cannot mock the ${String( + propertyKey, + )} property because it has a setter`, + ); + } + + if (typeof descriptor.value !== typeof value) { + 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 = () => { + 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(); } From 9c270ca7ff63f82b2c71ea704a2319ada84d65fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Ko=C4=8D=C3=A1rek?= Date: Sun, 23 Oct 2022 23:11:09 +0200 Subject: [PATCH 02/19] feat(jest-environment): Add mockProperty() method type to global Jest object --- packages/jest-environment/src/index.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/jest-environment/src/index.ts b/packages/jest-environment/src/index.ts index df50111e37f5..d2aab6d3555d 100644 --- a/packages/jest-environment/src/index.ts +++ b/packages/jest-environment/src/index.ts @@ -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. + * + * @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 From 20b881b723bc62b09326cdffaac63ad25c6cb6be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Ko=C4=8D=C3=A1rek?= Date: Mon, 19 Dec 2022 10:53:14 +0100 Subject: [PATCH 03/19] refactor(jest-mock): Be more strict for MockedPropertyRestorer generic type --- packages/jest-mock/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jest-mock/src/index.ts b/packages/jest-mock/src/index.ts index 121be6bf3164..6257aad05e65 100644 --- a/packages/jest-mock/src/index.ts +++ b/packages/jest-mock/src/index.ts @@ -179,7 +179,7 @@ export interface MockedProperty { mockRestore(): void; } -type MockedPropertyRestorer = { +type MockedPropertyRestorer = { (): void; object: T; property: keyof T; From 930036d3b33198a85b83aa49e6098097ca1349c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Ko=C4=8D=C3=A1rek?= Date: Mon, 19 Dec 2022 10:53:38 +0100 Subject: [PATCH 04/19] refactor(jest-mock): Allow mocking property named '0' or "" --- packages/jest-mock/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/jest-mock/src/index.ts b/packages/jest-mock/src/index.ts index 6257aad05e65..8f103b9414a5 100644 --- a/packages/jest-mock/src/index.ts +++ b/packages/jest-mock/src/index.ts @@ -1324,7 +1324,7 @@ export class ModuleMocker { ); } - if (!propertyKey) { + if (propertyKey === undefined || propertyKey === null) { throw new Error('No property name supplied'); } From c739864da5ddecd27a2eb5722ba543ccbccd1290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Ko=C4=8D=C3=A1rek?= Date: Mon, 19 Dec 2022 10:54:00 +0100 Subject: [PATCH 05/19] refactor(jest-mock): Throw more descriptive error messages when trying to mock a property --- packages/jest-mock/src/__tests__/index.test.ts | 2 +- packages/jest-mock/src/index.ts | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/jest-mock/src/__tests__/index.test.ts b/packages/jest-mock/src/__tests__/index.test.ts index 042eea447ee0..257bc4096746 100644 --- a/packages/jest-mock/src/__tests__/index.test.ts +++ b/packages/jest-mock/src/__tests__/index.test.ts @@ -1661,7 +1661,7 @@ describe('moduleMocker', () => { expect(() => { moduleMocker.mockProperty({method: () => {}}, 'method', () => {}); }).toThrow( - 'Cannot mock the method property because it is a function; use spyOn instead', + "Cannot mock the method property because it is a function. Use `jest.spyOn(object, 'method')` instead.", ); }); diff --git a/packages/jest-mock/src/index.ts b/packages/jest-mock/src/index.ts index 8f103b9414a5..a41e287a51a7 100644 --- a/packages/jest-mock/src/index.ts +++ b/packages/jest-mock/src/index.ts @@ -1361,7 +1361,9 @@ export class ModuleMocker { throw new Error( `Cannot mock the ${String( propertyKey, - )} property because it has a getter`, + )} property because it has a getter. Use \`jest.spyOn(object, '${String( + propertyKey, + )}', 'get').mockReturnValue(value)\` instead.`, ); } @@ -1369,7 +1371,9 @@ export class ModuleMocker { throw new Error( `Cannot mock the ${String( propertyKey, - )} property because it has a setter`, + )} property because it has a setter. Use \`jest.spyOn(object, '${String( + propertyKey, + )}', 'set').mockReturnValue(value)\` instead.`, ); } @@ -1387,7 +1391,9 @@ export class ModuleMocker { throw new Error( `Cannot mock the ${String( propertyKey, - )} property because it is a function; use spyOn instead`, + )} property because it is a function. Use \`jest.spyOn(object, '${String( + propertyKey, + )}')\` instead.`, ); } From 0f227c2db8030a0108f7faed50f65d98d1878cc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Ko=C4=8D=C3=A1rek?= Date: Mon, 19 Dec 2022 11:44:51 +0100 Subject: [PATCH 06/19] refactor(jest-mock): Allow mocking already mocked property with different value --- .../jest-mock/src/__tests__/index.test.ts | 49 +++++++++---- packages/jest-mock/src/index.ts | 70 +++++++++++-------- 2 files changed, 77 insertions(+), 42 deletions(-) diff --git a/packages/jest-mock/src/__tests__/index.test.ts b/packages/jest-mock/src/__tests__/index.test.ts index 257bc4096746..76d88c461c86 100644 --- a/packages/jest-mock/src/__tests__/index.test.ts +++ b/packages/jest-mock/src/__tests__/index.test.ts @@ -1602,6 +1602,26 @@ describe('moduleMocker', () => { expect(obj.property).toBe(1); }); + it('should allow mocking a property multiple times', () => { + const obj = { + property: 1, + }; + + const replacedFirst = moduleMocker.mockProperty(obj, 'property', 2); + + const replacedSecond = moduleMocker.mockProperty(obj, 'property', 3); + + expect(obj.property).toBe(3); + + replacedSecond.mockRestore(); + + expect(obj.property).toBe(1); + + replacedFirst.mockRestore(); + + expect(obj.property).toBe(1); + }); + describe('should throw', () => { it('when object is not provided', () => { expect(() => { @@ -1689,20 +1709,6 @@ describe('moduleMocker', () => { }); }); - 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); @@ -1737,6 +1743,21 @@ describe('moduleMocker', () => { expect(obj.property).toBe(1); }); + + describe('mockValue', () => { + it('should work', () => { + const obj = { + property: 1, + }; + + const replaced = moduleMocker.mockProperty(obj, 'property', 2); + + const mockValueResult = replaced.mockValue(3); + + expect(obj.property).toBe(3); + expect(mockValueResult).toBe(replaced); + }); + }); }); }); diff --git a/packages/jest-mock/src/index.ts b/packages/jest-mock/src/index.ts index a41e287a51a7..28c2f3946c94 100644 --- a/packages/jest-mock/src/index.ts +++ b/packages/jest-mock/src/index.ts @@ -172,17 +172,26 @@ export interface MockInstance { mockRejectedValueOnce(value: RejectType): this; } -export interface MockedProperty { +export interface MockedProperty { /** * Restore property to its original value known at the time of mocking. */ mockRestore(): void; + + /** + * Change the value of the property. + */ + mockValue(value: T): this; } -type MockedPropertyRestorer = { +type MockedPropertyRestorer< + T extends object = object, + P = string | number | symbol, +> = { (): void; object: T; - property: keyof T; + property: P; + mock: MockedProperty; }; type MockFunctionResultIncomplete = { @@ -1001,12 +1010,12 @@ export class ModuleMocker { } /** - * Check whether given property of an object has been already mocked. + * Check whether the given property of an object has been already mocked. */ - private _isAlreadyMocked( + private _findMockedProperty( object: T, - propertyKey: keyof T, - ): boolean { + propertyKey: K, + ): MockedPropertyRestorer | undefined { for (const spyState of this._spyState) { if ( 'object' in spyState && @@ -1014,11 +1023,11 @@ export class ModuleMocker { (spyState as MockedPropertyRestorer).object === object && (spyState as MockedPropertyRestorer).property === propertyKey ) { - return true; + return spyState as MockedPropertyRestorer; } } - return false; + return; } /** @@ -1315,7 +1324,7 @@ export class ModuleMocker { T extends object, P extends PropertyLikeKeys, V extends Required[P], - >(object: T, propertyKey: P, value: V): MockedProperty { + >(object: T, propertyKey: P, value: V): MockedProperty { if (!object) { throw new Error( `mockProperty could not find an object on which to replace ${String( @@ -1349,14 +1358,6 @@ export class ModuleMocker { 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`, - ); - } - if (descriptor.get !== undefined) { throw new Error( `Cannot mock the ${String( @@ -1397,15 +1398,19 @@ export class ModuleMocker { ); } + const existingRestore = this._findMockedProperty(object, propertyKey); + + if (existingRestore) { + return existingRestore.mock.mockValue(value); + } + const isPropertyOwner = Object.prototype.hasOwnProperty.call( object, propertyKey, ); const originalValue = descriptor.value; - object[propertyKey] = value; - - const restore: MockedPropertyRestorer = () => { + const restore: MockedPropertyRestorer = () => { if (isPropertyOwner) { object[propertyKey] = originalValue; } else { @@ -1413,18 +1418,27 @@ export class ModuleMocker { } }; - restore.object = object; - restore.property = propertyKey; - - this._spyState.add(restore); - - return { - mockRestore: (): void => { + const mock: MockedProperty = { + mockRestore: () => { restore(); this._spyState.delete(restore); }, + + mockValue: value => { + object[propertyKey] = value; + + return mock; + }, }; + + restore.object = object; + restore.property = propertyKey; + restore.mock = mock; + + this._spyState.add(restore); + + return mock.mockValue(value); } clearAllMocks(): void { From 1c4535b9d298ab10d513d452359a3c32807ebe01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Ko=C4=8D=C3=A1rek?= Date: Mon, 19 Dec 2022 12:04:05 +0100 Subject: [PATCH 07/19] refactor(jest-mock): Add type tests for mockProperty --- .../__typetests__/mock-functions.test.ts | 19 +++++++++++++++++++ packages/jest-mock/src/index.ts | 1 + 2 files changed, 20 insertions(+) diff --git a/packages/jest-mock/__typetests__/mock-functions.test.ts b/packages/jest-mock/__typetests__/mock-functions.test.ts index 58efe52a828b..4b20bdb88c05 100644 --- a/packages/jest-mock/__typetests__/mock-functions.test.ts +++ b/packages/jest-mock/__typetests__/mock-functions.test.ts @@ -13,11 +13,13 @@ import { } from 'tsd-lite'; import { Mock, + MockedProperty, SpiedClass, SpiedFunction, SpiedGetter, SpiedSetter, fn, + mockProperty, spyOn, } from 'jest-mock'; @@ -476,3 +478,20 @@ expectType>( expectError(spyOn(optionalSpiedObject, 'propertyA')); expectError(spyOn(optionalSpiedObject, 'propertyB')); + +// mockProperty + +const obj = { + fn: () => {}, + + property: 1, +}; + +expectType>(mockProperty(obj, 'property', 1)); +mockProperty(obj, 'property', 1).mockValue(1).mockRestore(); + +expectError(mockProperty(obj, 'invalid', 1)); +expectError(mockProperty(obj, 'property', 'not a number')); +expectError(mockProperty(obj, 'fn', () => {})); + +expectError(mockProperty(obj, 'property', 1).mockValue('not a number')); diff --git a/packages/jest-mock/src/index.ts b/packages/jest-mock/src/index.ts index 28c2f3946c94..964072f861c2 100644 --- a/packages/jest-mock/src/index.ts +++ b/packages/jest-mock/src/index.ts @@ -1477,3 +1477,4 @@ const JestMock = new ModuleMocker(globalThis); export const fn = JestMock.fn.bind(JestMock); export const spyOn = JestMock.spyOn.bind(JestMock); export const mocked = JestMock.mocked.bind(JestMock); +export const mockProperty = JestMock.mockProperty.bind(JestMock); From 4a1aeb66ffb9a12cbe5ca60420aab6e53bcf1192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Ko=C4=8D=C3=A1rek?= Date: Mon, 19 Dec 2022 12:04:34 +0100 Subject: [PATCH 08/19] refactor(jest-runtime): Fix missing mockProperty export --- .../runtime_jest_mockProperty.test.js | 37 +++++++++++++++++++ packages/jest-runtime/src/index.ts | 4 ++ 2 files changed, 41 insertions(+) create mode 100644 packages/jest-runtime/src/__tests__/runtime_jest_mockProperty.test.js diff --git a/packages/jest-runtime/src/__tests__/runtime_jest_mockProperty.test.js b/packages/jest-runtime/src/__tests__/runtime_jest_mockProperty.test.js new file mode 100644 index 000000000000..612252fe1a65 --- /dev/null +++ b/packages/jest-runtime/src/__tests__/runtime_jest_mockProperty.test.js @@ -0,0 +1,37 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +'use strict'; + +let createRuntime; +let obj; + +describe('Runtime', () => { + beforeEach(() => { + createRuntime = require('createRuntime'); + + obj = { + property: 1, + }; + }); + + describe('jest.mockProperty', () => { + it('should work', async () => { + const runtime = await createRuntime(__filename); + const root = runtime.requireModule(runtime.__mockRootPath); + const mocked = root.jest.mockProperty(obj, 'property', 2); + expect(obj.property).toBe(2); + + mocked.mockValue(3); + expect(obj.property).toBe(3); + + mocked.mockRestore(); + expect(obj.property).toBe(1); + }); + }); +}); diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index 7be6cf2db53d..b0961c0aac4c 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -2083,6 +2083,9 @@ export default class Runtime { 'Your test environment does not support `mocked`, please update it.', ); }); + const mockProperty = this._moduleMocker.mockProperty.bind( + this._moduleMocker, + ); const setTimeout = (timeout: number) => { this._environment.global[testTimeoutSymbol] = timeout; @@ -2140,6 +2143,7 @@ export default class Runtime { isMockFunction: this._moduleMocker.isMockFunction, isolateModules, mock, + mockProperty, mocked, now: () => _getFakeTimers().now(), requireActual: moduleName => this.requireActual(from, moduleName), From cceffa0e9689fe73b667d19e1b84a151ab83250f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Ko=C4=8D=C3=A1rek?= Date: Tue, 20 Dec 2022 22:30:22 +0100 Subject: [PATCH 09/19] refactor(jest-mock): Fix typing and interface of mockProperty methods - Rename mockProperty to replaceProperty, as it's not exactly mocking. - Allow passing different types when mocking value. - Fix typings in TypeScript and cover them with tests. --- CHANGELOG.md | 2 + packages/jest-environment/src/index.ts | 24 +-- packages/jest-globals/src/index.ts | 5 + .../__typetests__/mock-functions.test.ts | 63 ++++++- .../jest-mock/src/__tests__/index.test.ts | 161 ++++++++++++------ packages/jest-mock/src/index.ts | 82 ++++----- .../runtime_jest_mockProperty.test.js | 4 +- packages/jest-runtime/src/index.ts | 4 +- 8 files changed, 222 insertions(+), 123 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07f7984aa970..11f5f77402e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ### Features +- `[@jest/globals, jest-mock]` Add `jest.replaceProperty()` that replaces property value ([#13496](https://github.com/facebook/jest/pull/13496)) + ### Fixes - `[jest-resolve]` add global paths to `require.resolve.paths` ([#13633](https://github.com/facebook/jest/pull/13633)) diff --git a/packages/jest-environment/src/index.ts b/packages/jest-environment/src/index.ts index d2aab6d3555d..23ea2efea5c7 100644 --- a/packages/jest-environment/src/index.ts +++ b/packages/jest-environment/src/index.ts @@ -217,6 +217,15 @@ export interface Jest { * mocked behavior. */ mocked: ModuleMocker['mocked']; + /** + * Replaces property on an object with another value. + * + * This method does not work on 'get' or 'set' accessors. + * + * @remarks + * For mocking functions or 'get' or 'set' accessors, use `jest.spyOn()` instead. + */ + replaceProperty: ModuleMocker['replaceProperty']; /** * Returns a mock module instead of the actual module, bypassing all checks * on whether the module should be required normally or not. @@ -233,8 +242,9 @@ export interface Jest { */ resetModules(): Jest; /** - * Restores all mocks back to their original value. Equivalent to calling - * `.mockRestore()` on every mocked function. + * Restores all mocks and replaced properties back to their original value. + * Equivalent to calling `.mockRestore()` on every mocked function + * and `.restore()` on every replaced property. * * Beware that `jest.restoreAllMocks()` only works when the mock was created * with `jest.spyOn()`; other mocks will require you to manually restore them. @@ -319,16 +329,6 @@ 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. - * - * @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 diff --git a/packages/jest-globals/src/index.ts b/packages/jest-globals/src/index.ts index c2bd3abb7f59..e72615120ccf 100644 --- a/packages/jest-globals/src/index.ts +++ b/packages/jest-globals/src/index.ts @@ -16,6 +16,7 @@ import type { MockedClass as JestMockedClass, MockedFunction as JestMockedFunction, MockedObject as JestMockedObject, + Replaced as JestReplaced, Spied as JestSpied, SpiedClass as JestSpiedClass, SpiedFunction as JestSpiedFunction, @@ -63,6 +64,10 @@ declare namespace jest { * Wraps an object type with Jest mock type definitions. */ export type MockedObject = JestMockedObject; + /** + * Constructs the type of a replaced object's property. + */ + export type Replaced = JestReplaced; /** * Constructs the type of a spied class or function. */ diff --git a/packages/jest-mock/__typetests__/mock-functions.test.ts b/packages/jest-mock/__typetests__/mock-functions.test.ts index dcf83258b565..01f2b8ec78e0 100644 --- a/packages/jest-mock/__typetests__/mock-functions.test.ts +++ b/packages/jest-mock/__typetests__/mock-functions.test.ts @@ -15,13 +15,13 @@ import { } from 'tsd-lite'; import { Mock, - MockedProperty, + Replaced, SpiedClass, SpiedFunction, SpiedGetter, SpiedSetter, fn, - mockProperty, + replaceProperty, spyOn, } from 'jest-mock'; @@ -495,7 +495,7 @@ expectError( ), ); -// mockProperty +// replaceProperty + Replaced const obj = { fn: () => {}, @@ -503,11 +503,56 @@ const obj = { property: 1, }; -expectType>(mockProperty(obj, 'property', 1)); -mockProperty(obj, 'property', 1).mockValue(1).mockRestore(); +expectType>(replaceProperty(obj, 'property', 1)); +replaceProperty(obj, 'property', 1).replaceValue(1).restore(); -expectError(mockProperty(obj, 'invalid', 1)); -expectError(mockProperty(obj, 'property', 'not a number')); -expectError(mockProperty(obj, 'fn', () => {})); +expectError(replaceProperty(obj, 'invalid', 1)); +expectError(replaceProperty(obj, 'property', 'not a number')); +expectError(replaceProperty(obj, 'fn', () => {})); -expectError(mockProperty(obj, 'property', 1).mockValue('not a number')); +expectError(replaceProperty(obj, 'property', 1).replaceValue('not a number')); + +interface ComplexObject { + numberOrUndefined: number | undefined; + optionalString?: string; + [key: `dynamic prop ${number}`]: boolean; + multipleTypes: number | string | {foo: number} | null; +} +const complexObject: ComplexObject = undefined as unknown as ComplexObject; + +// Resulting type should not be narrowed down to a type of value passed to replaceProperty, but should retain the original property type instead +expectType>( + replaceProperty(complexObject, 'numberOrUndefined', undefined), +); +expectType>( + replaceProperty(complexObject, 'numberOrUndefined', 1), +); + +expectError( + replaceProperty( + complexObject, + 'numberOrUndefined', + 'string is not valid TypeScript type', + ), +); + +expectType>( + replaceProperty(complexObject, 'optionalString', 'foo'), +); +expectType>( + replaceProperty(complexObject, 'optionalString', undefined), +); + +expectType>( + replaceProperty(complexObject, 'dynamic prop 1', true), +); +expectError(replaceProperty(complexObject, 'dynamic prop 1', undefined)); + +expectError(replaceProperty(complexObject, 'not a property', undefined)); + +expectType>( + replaceProperty(complexObject, 'multipleTypes', 1) + .replaceValue('foo') + .replaceValue({foo: 1}) + .replaceValue(null), +); diff --git a/packages/jest-mock/src/__tests__/index.test.ts b/packages/jest-mock/src/__tests__/index.test.ts index a79a36b1016b..c23e4a437e1d 100644 --- a/packages/jest-mock/src/__tests__/index.test.ts +++ b/packages/jest-mock/src/__tests__/index.test.ts @@ -1605,17 +1605,17 @@ describe('moduleMocker', () => { }); }); - describe('mockProperty', () => { + describe('replaceProperty', () => { it('should work', () => { const obj = { property: 1, }; - const replaced = moduleMocker.mockProperty(obj, 'property', 2); + const replaced = moduleMocker.replaceProperty(obj, 'property', 2); expect(obj.property).toBe(2); - replaced.mockRestore(); + replaced.restore(); expect(obj.property).toBe(1); }); @@ -1625,51 +1625,80 @@ describe('moduleMocker', () => { property: 1, }; - const replacedFirst = moduleMocker.mockProperty(obj, 'property', 2); + const replacedFirst = moduleMocker.replaceProperty(obj, 'property', 2); - const replacedSecond = moduleMocker.mockProperty(obj, 'property', 3); + const replacedSecond = moduleMocker.replaceProperty(obj, 'property', 3); expect(obj.property).toBe(3); - replacedSecond.mockRestore(); + replacedSecond.restore(); expect(obj.property).toBe(1); - replacedFirst.mockRestore(); + replacedFirst.restore(); + + expect(obj.property).toBe(1); + }); + + it('should allow mocking with value of different value', () => { + const obj = { + property: 1, + }; + + const replaced = moduleMocker.replaceProperty(obj, 'property', { + foo: 'bar', + }); + + expect(obj.property).toStrictEqual({foo: 'bar'}); + + replaced.restore(); expect(obj.property).toBe(1); }); describe('should throw', () => { - it('when object is not provided', () => { + it.each` + value + ${null} + ${undefined} + `('when $value is provided instead of an object', ({value}) => { expect(() => { - moduleMocker.mockProperty(null, 'property', 1); + moduleMocker.replaceProperty(value, 'property', 1); }).toThrow( - 'mockProperty could not find an object on which to replace property', + 'replaceProperty 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.each` + value | type + ${'foo'} | ${'string'} + ${1} | ${'number'} + ${NaN} | ${'number'} + ${1n} | ${'bigint'} + ${Symbol()} | ${'symbol'} + ${true} | ${'boolean'} + ${false} | ${'boolean'} + ${() => {}} | ${'function'} + `( + 'when primitive value $value is provided instead of an object', + ({value, type}) => { + expect(() => { + moduleMocker.replaceProperty(value, 'property', 1); + }).toThrow( + `Cannot mock property on a non-object value; ${type} given`, + ); + }, + ); it('when property name is not provided', () => { expect(() => { - moduleMocker.mockProperty({}, null, 1); + moduleMocker.replaceProperty({}, null, 1); }).toThrow('No property name supplied'); }); it('when property is not defined', () => { expect(() => { - moduleMocker.mockProperty({}, 'doesNotExist', 1); + moduleMocker.replaceProperty({}, 'doesNotExist', 1); }).toThrow('doesNotExist property does not exist'); }); @@ -1683,21 +1712,13 @@ describe('moduleMocker', () => { writable: false, }); - moduleMocker.mockProperty(obj, 'property', 2); + moduleMocker.replaceProperty(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', - ); - }); - it('when trying to mock a method', () => { expect(() => { - moduleMocker.mockProperty({method: () => {}}, 'method', () => {}); + moduleMocker.replaceProperty({method: () => {}}, 'method', () => {}); }).toThrow( "Cannot mock the method property because it is a function. Use `jest.spyOn(object, 'method')` instead.", ); @@ -1711,7 +1732,7 @@ describe('moduleMocker', () => { }; expect(() => { - moduleMocker.mockProperty(obj, 'getter', 1); + moduleMocker.replaceProperty(obj, 'getter', 1); }).toThrow('Cannot mock the getter property because it has a getter'); }); @@ -1722,7 +1743,7 @@ describe('moduleMocker', () => { }; expect(() => { - moduleMocker.mockProperty(obj, 'setter', 1); + moduleMocker.replaceProperty(obj, 'setter', 1); }).toThrow('Cannot mock the setter property because it has a setter'); }); }); @@ -1731,11 +1752,11 @@ describe('moduleMocker', () => { const parent = {property: 'abcd'}; const child = Object.create(parent); - const replaced = moduleMocker.mockProperty(child, 'property', 'defg'); + const replaced = moduleMocker.replaceProperty(child, 'property', 'defg'); expect(child.property).toBe('defg'); - replaced.mockRestore(); + replaced.restore(); expect(child.property).toBe('abcd'); expect( @@ -1743,37 +1764,73 @@ describe('moduleMocker', () => { ).toBeUndefined(); }); - it('should restore property as part of calling restoreAllMocks', () => { - const obj = { - property: 1, - }; + describe('with restoreAllMocks', () => { + it('should work', () => { + const obj = { + property: 1, + }; - const replaced = moduleMocker.mockProperty(obj, 'property', 2); + const replaced = moduleMocker.replaceProperty(obj, 'property', 2); - expect(obj.property).toBe(2); + expect(obj.property).toBe(2); - moduleMocker.restoreAllMocks(); + moduleMocker.restoreAllMocks(); - expect(obj.property).toBe(1); + 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(); + // Just make sure that this call won't break anything while calling after the property has been already restored + replaced.restore(); - expect(obj.property).toBe(1); + expect(obj.property).toBe(1); + }); + + it('should work for property mocked multiple times', () => { + const obj = { + property: 1, + }; + + const replaced1 = moduleMocker.replaceProperty(obj, 'property', 2); + const replaced2 = moduleMocker.replaceProperty(obj, 'property', 3); + + expect(obj.property).toBe(3); + + 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 + replaced2.restore(); + replaced1.restore(); + + expect(obj.property).toBe(1); + }); }); - describe('mockValue', () => { + describe('replaceValue', () => { it('should work', () => { const obj = { property: 1, }; - const replaced = moduleMocker.mockProperty(obj, 'property', 2); + const replaced = moduleMocker.replaceProperty(obj, 'property', 2); - const mockValueResult = replaced.mockValue(3); + const result = replaced.replaceValue(3); expect(obj.property).toBe(3); - expect(mockValueResult).toBe(replaced); + expect(result).toBe(replaced); + }); + + it('should work while passing different type', () => { + const obj = { + property: 1, + }; + + const replaced = moduleMocker.replaceProperty(obj, 'property', 2); + + const result = replaced.replaceValue('foo'); + + expect(obj.property).toBe('foo'); + expect(result).toBe(replaced); }); }); }); diff --git a/packages/jest-mock/src/index.ts b/packages/jest-mock/src/index.ts index 2bf6244f625a..b4c60c9120c3 100644 --- a/packages/jest-mock/src/index.ts +++ b/packages/jest-mock/src/index.ts @@ -172,26 +172,26 @@ export interface MockInstance { mockRejectedValueOnce(value: RejectType): this; } -export interface MockedProperty { +export interface Replaced { /** * Restore property to its original value known at the time of mocking. */ - mockRestore(): void; + restore(): void; /** * Change the value of the property. */ - mockValue(value: T): this; + replaceValue(value: T): this; } -type MockedPropertyRestorer< - T extends object = object, - P = string | number | symbol, +type ReplacedPropertyRestorer< + T extends object, + K extends PropertyLikeKeys, > = { (): void; object: T; - property: P; - mock: MockedProperty; + property: K; + replaced: Replaced; }; type MockFunctionResultIncomplete = { @@ -979,20 +979,20 @@ export class ModuleMocker { } /** - * Check whether the given property of an object has been already mocked. + * Check whether the given property of an object has been already replaced. */ - private _findMockedProperty( - object: T, - propertyKey: K, - ): MockedPropertyRestorer | undefined { + private _findReplacedProperty< + T extends object, + K extends PropertyLikeKeys, + >(object: T, propertyKey: K): ReplacedPropertyRestorer | undefined { for (const spyState of this._spyState) { if ( 'object' in spyState && 'property' in spyState && - (spyState as MockedPropertyRestorer).object === object && - (spyState as MockedPropertyRestorer).property === propertyKey + spyState.object === object && + spyState.property === propertyKey ) { - return spyState as MockedPropertyRestorer; + return spyState as ReplacedPropertyRestorer; } } @@ -1273,14 +1273,14 @@ export class ModuleMocker { return descriptor[accessType] as Mock; } - mockProperty< + replaceProperty< T extends object, - P extends PropertyLikeKeys, - V extends Required[P], - >(object: T, propertyKey: P, value: V): MockedProperty { - if (!object) { + K extends PropertyLikeKeys, + V extends T[K], + >(object: T, propertyKey: K, value: V): Replaced { + if (object === undefined || object == null) { throw new Error( - `mockProperty could not find an object on which to replace ${String( + `replaceProperty could not find an object on which to replace ${String( propertyKey, )}`, ); @@ -1292,7 +1292,7 @@ export class ModuleMocker { if (typeof object !== 'object') { throw new Error( - `Cannot mock property on a primitive value; ${this._typeOf( + `Cannot mock property on a non-object value; ${this._typeOf( object, )} given`, ); @@ -1331,16 +1331,6 @@ export class ModuleMocker { ); } - if (typeof descriptor.value !== typeof value) { - 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( @@ -1351,10 +1341,10 @@ export class ModuleMocker { ); } - const existingRestore = this._findMockedProperty(object, propertyKey); + const existingRestore = this._findReplacedProperty(object, propertyKey); if (existingRestore) { - return existingRestore.mock.mockValue(value); + return existingRestore.replaced.replaceValue(value); } const isPropertyOwner = Object.prototype.hasOwnProperty.call( @@ -1363,7 +1353,7 @@ export class ModuleMocker { ); const originalValue = descriptor.value; - const restore: MockedPropertyRestorer = () => { + const restore: ReplacedPropertyRestorer = () => { if (isPropertyOwner) { object[propertyKey] = originalValue; } else { @@ -1371,27 +1361,27 @@ export class ModuleMocker { } }; - const mock: MockedProperty = { - mockRestore: () => { - restore(); + const replaced: Replaced = { + replaceValue: value => { + object[propertyKey] = value; - this._spyState.delete(restore); + return replaced; }, - mockValue: value => { - object[propertyKey] = value; + restore: () => { + restore(); - return mock; + this._spyState.delete(restore); }, }; restore.object = object; restore.property = propertyKey; - restore.mock = mock; + restore.replaced = replaced; this._spyState.add(restore); - return mock.mockValue(value); + return replaced.replaceValue(value); } clearAllMocks(): void { @@ -1430,4 +1420,4 @@ const JestMock = new ModuleMocker(globalThis); export const fn = JestMock.fn.bind(JestMock); export const spyOn = JestMock.spyOn.bind(JestMock); export const mocked = JestMock.mocked.bind(JestMock); -export const mockProperty = JestMock.mockProperty.bind(JestMock); +export const replaceProperty = JestMock.replaceProperty.bind(JestMock); diff --git a/packages/jest-runtime/src/__tests__/runtime_jest_mockProperty.test.js b/packages/jest-runtime/src/__tests__/runtime_jest_mockProperty.test.js index 612252fe1a65..4e5594062ea1 100644 --- a/packages/jest-runtime/src/__tests__/runtime_jest_mockProperty.test.js +++ b/packages/jest-runtime/src/__tests__/runtime_jest_mockProperty.test.js @@ -20,11 +20,11 @@ describe('Runtime', () => { }; }); - describe('jest.mockProperty', () => { + describe('jest.replaceProperty', () => { it('should work', async () => { const runtime = await createRuntime(__filename); const root = runtime.requireModule(runtime.__mockRootPath); - const mocked = root.jest.mockProperty(obj, 'property', 2); + const mocked = root.jest.replaceProperty(obj, 'property', 2); expect(obj.property).toBe(2); mocked.mockValue(3); diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index 5d0cdf6d0eba..61b68fa7f9f6 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -2170,7 +2170,7 @@ export default class Runtime { 'Your test environment does not support `mocked`, please update it.', ); }); - const mockProperty = this._moduleMocker.mockProperty.bind( + const replaceProperty = this._moduleMocker.replaceProperty.bind( this._moduleMocker, ); @@ -2230,9 +2230,9 @@ export default class Runtime { isMockFunction: this._moduleMocker.isMockFunction, isolateModules, mock, - mockProperty, mocked, now: () => _getFakeTimers().now(), + replaceProperty, requireActual: moduleName => this.requireActual(from, moduleName), requireMock: moduleName => this.requireMock(from, moduleName), resetAllMocks, From 6c17cc077e0e91d39f877d9805fb3648819ada37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Ko=C4=8D=C3=A1rek?= Date: Tue, 20 Dec 2022 22:31:30 +0100 Subject: [PATCH 10/19] refactor(jest-mock, docs): Document replaceProperty method and its impact on the API --- docs/JestObjectAPI.md | 60 ++++++++++++++++++- docs/MockFunctionAPI.md | 47 +++++++++++++++ examples/manual-mocks/__tests__/utils.test.js | 17 ++++++ examples/manual-mocks/utils.js | 3 + examples/typescript/__tests__/utils.test.ts | 24 ++++++++ examples/typescript/utils.ts | 5 ++ 6 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 examples/manual-mocks/__tests__/utils.test.js create mode 100644 examples/manual-mocks/utils.js create mode 100644 examples/typescript/__tests__/utils.test.ts create mode 100644 examples/typescript/utils.ts diff --git a/docs/JestObjectAPI.md b/docs/JestObjectAPI.md index a961dab0fd0d..63b5421d7a71 100644 --- a/docs/JestObjectAPI.md +++ b/docs/JestObjectAPI.md @@ -594,13 +594,64 @@ See [Mock Functions](MockFunctionAPI.md#jestfnimplementation) page for details o Determines if the given function is a mocked function. +### `jest.replaceProperty(object, propertyKey, value)` + +Replace `object[propertyKey]` with a `value`. The property must already exist on the object. The same property might be replaced multiple times. +Returns a Jest [replaced property](MockFunctionAPI.md#replacedpropertyreplacevaluevalue). + +:::note + +To mock properties that are defined as getters or setters, use [`jest.spyOn(object, methodName, accessType)`](#jestspyonobject-methodname-accesstype) instead. +To mock functions, use [`jest.spyOn(object, methodName)`](#jestspyonobject-methodname) instead. + +::: + +:::tip + +Since `jest.replaceProperty` regi. You could restore the initial state calling [jest.restoreAllMocks](#jestrestoreallmocks) on [afterEach](GlobalAPI.md#aftereachfn-timeout) method. + +::: + +Example: + +```js +const utils = { + isLocalhost() { + return process.env.HOSTNAME === 'localhost'; + }, +}; + +module.exports = utils; +``` + +Example test: + +```js +const utils = require('./utils'); + +afterEach(() => { + // restore replaced property + jest.restoreAllMocks(); +}); + +test('isLocalhost returns true when HOSTNAME is localhost', () => { + jest.replaceProperty(process, 'env', {HOSTNAME: 'localhost'}); + expect(utils.isLocalhost()).toBe(true); +}); + +test('isLocalhost returns false when HOSTNAME is not localhost', () => { + jest.replaceProperty(process, 'env', {HOSTNAME: 'not-localhost'}); + expect(utils.isLocalhost()).toBe(false); +}); +``` + ### `jest.spyOn(object, methodName)` Creates a mock function similar to `jest.fn` but also tracks calls to `object[methodName]`. Returns a Jest [mock function](MockFunctionAPI.md). :::note -By default, `jest.spyOn` also calls the **spied** method. This is different behavior from most other test libraries. If you want to overwrite the original function, you can use `jest.spyOn(object, methodName).mockImplementation(() => customImplementation)` or `object[methodName] = jest.fn(() => customImplementation);` +By default, `jest.spyOn` also calls the **spied** method. This is different behavior from most other test libraries. If you want to overwrite the original function, you can use `jest.spyOn(object, methodName).mockImplementation(() => customImplementation)` or `jest.replaceProperty(object, methodName, jest.fn(() => customImplementation));` ::: @@ -699,6 +750,11 @@ test('plays audio', () => { }); ``` +### `jest.Replaced` + +// TODO: Fix this +See [TypeScript Usage](MockFunctionAPI.md#jestreplacedsource) chapter of XXXX ?Mock Functions page for documentation. + ### `jest.Spied` See [TypeScript Usage](MockFunctionAPI.md#jestspiedsource) chapter of Mock Functions page for documentation. @@ -717,7 +773,7 @@ Returns the `jest` object for chaining. ### `jest.restoreAllMocks()` -Restores all mocks back to their original value. Equivalent to calling [`.mockRestore()`](MockFunctionAPI.md#mockfnmockrestore) on every mocked function. Beware that `jest.restoreAllMocks()` only works when the mock was created with `jest.spyOn`; other mocks will require you to manually restore them. +Restores all mocks and replaced properties back to their original value. Equivalent to calling [`.mockRestore()`](MockFunctionAPI.md#mockfnmockrestore) on every mocked function and [`.restore()`](MockFunctionAPI.md#replacedpropertyrestore) on every replaced property. Beware that `jest.restoreAllMocks()` only works for mocks created with [`jest.spyOn()`](#jestspyonobject-methodname) and properties replaced with [`jest.replaceProperty()`](#jestreplacepropertyobject-propertykey-value); other mocks will require you to manually restore them. ## Fake Timers diff --git a/docs/MockFunctionAPI.md b/docs/MockFunctionAPI.md index 157b1e0b873b..b97cabe44d49 100644 --- a/docs/MockFunctionAPI.md +++ b/docs/MockFunctionAPI.md @@ -515,6 +515,19 @@ test('async test', async () => { }); ``` +### `replacedProperty.replaceValue(value)` + +Changes the value of already replaced property. This is useful when you want to replace property and then adjust the value in specific tests. +As an alternative, you can call [`jest.replaceProperty()`](JestObjectAPI.md#jestreplacepropertyobject-propertykey-value) multiple times on same property. + +### `replacedProperty.restore()` + +Restores object's property to the original value. + +Beware that `replacedProperty.restore()` only works when the property value was replaced with [`jest.replaceProperty()`](JestObjectAPI.md#jestreplacepropertyobject-propertykey-value). + +The [`restoreMocks`](configuration#restoremocks-boolean) configuration option is available to restore replaced properties automatically before each test. + ## TypeScript Usage @@ -594,6 +607,40 @@ test('returns correct data', () => { Types of classes, functions or objects can be passed as type argument to `jest.Mocked`. If you prefer to constrain the input type, use: `jest.MockedClass`, `jest.MockedFunction` or `jest.MockedObject`. +### `jest.Replaced` + +The `jest.Replaced` utility type returns the `Source` type wrapped with type definitions of Jest mock property. + +```ts title="src/utils.ts" +export function isLocalhost(): boolean { + return process.env['HOSTNAME'] === 'localhost'; +} +``` + +```ts +import {afterEach, expect, it, jest} from '@jest/globals'; +import {isLocalhost} from '../utils'; + +let replacedEnv: jest.Replaced | undefined = undefined; + +afterEach(() => { + replacedEnv?.restore(); +}); + +it('isLocalhost should detect localhost environment', () => { + replacedEnv = jest.replaceProperty(process, 'env', {HOSTNAME: 'localhost'}); + + expect(isLocalhost()).toBe(true); +}); + +it('isLocalhost should detect non-localhost environment', () => { + replacedEnv = jest.replaceProperty(process, 'env', {HOSTNAME: 'example.com'}); + + expect(isLocalhost()).toBe(false); +}); + +``` + ### `jest.mocked(source, options?)` The `mocked()` helper method wraps types of the `source` object and its deep nested members with type definitions of Jest mock function. You can pass `{shallow: true}` as the `options` argument to disable the deeply mocked behavior. diff --git a/examples/manual-mocks/__tests__/utils.test.js b/examples/manual-mocks/__tests__/utils.test.js new file mode 100644 index 000000000000..dfc427ea6733 --- /dev/null +++ b/examples/manual-mocks/__tests__/utils.test.js @@ -0,0 +1,17 @@ +import {isLocalhost} from '../utils'; + +afterEach(() => { + jest.restoreAllMocks(); +}); + +it('isLocalhost should detect localhost environment', () => { + jest.replaceProperty(process, 'env', {HOSTNAME: 'localhost'}); + + expect(isLocalhost()).toBe(true); +}); + +it('isLocalhost should detect non-localhost environment', () => { + jest.replaceProperty(process, 'env', {HOSTNAME: 'example.com'}); + + expect(isLocalhost()).toBe(false); +}); diff --git a/examples/manual-mocks/utils.js b/examples/manual-mocks/utils.js new file mode 100644 index 000000000000..0561c3fd3736 --- /dev/null +++ b/examples/manual-mocks/utils.js @@ -0,0 +1,3 @@ +export function isLocalhost() { + return process.env.HOSTNAME === 'localhost'; +} diff --git a/examples/typescript/__tests__/utils.test.ts b/examples/typescript/__tests__/utils.test.ts new file mode 100644 index 000000000000..f5eac1b0795c --- /dev/null +++ b/examples/typescript/__tests__/utils.test.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2014-present, Facebook, Inc. All rights reserved. + +import {afterEach, beforeEach, expect, it, jest} from '@jest/globals'; +import {isLocalhost} from '../utils'; + +let replacedEnv: jest.Replaced | undefined = undefined; + +beforeEach(() => { + replacedEnv = jest.replaceProperty(process, 'env', {}); +}); + +afterEach(() => { + replacedEnv?.restore(); +}); + +it('isLocalhost should detect localhost environment', () => { + replacedEnv.replaceValue({HOSTNAME: 'localhost'}); + + expect(isLocalhost()).toBe(true); +}); + +it('isLocalhost should detect non-localhost environment', () => { + expect(isLocalhost()).toBe(false); +}); diff --git a/examples/typescript/utils.ts b/examples/typescript/utils.ts new file mode 100644 index 000000000000..36175ec684a2 --- /dev/null +++ b/examples/typescript/utils.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2014-present, Facebook, Inc. All rights reserved. + +export function isLocalhost() { + return process.env.HOSTNAME === 'localhost'; +} From d828f118665d652ac1730d7a4c8e777a80a25210 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Ko=C4=8D=C3=A1rek?= Date: Tue, 20 Dec 2022 22:38:22 +0100 Subject: [PATCH 11/19] refactor(docs): Remove forgotten TODO --- docs/JestObjectAPI.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/JestObjectAPI.md b/docs/JestObjectAPI.md index 63b5421d7a71..cd74760deeb4 100644 --- a/docs/JestObjectAPI.md +++ b/docs/JestObjectAPI.md @@ -752,8 +752,7 @@ test('plays audio', () => { ### `jest.Replaced` -// TODO: Fix this -See [TypeScript Usage](MockFunctionAPI.md#jestreplacedsource) chapter of XXXX ?Mock Functions page for documentation. +See [TypeScript Usage](MockFunctionAPI.md#replacedpropertyreplacevaluevalue) chapter of Mock Functions page for documentation. ### `jest.Spied` From 4f9ac478e1381e7f9f45a14c519ce134e28902ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Ko=C4=8D=C3=A1rek?= Date: Sun, 25 Dec 2022 13:46:28 +0100 Subject: [PATCH 12/19] refactor(jest-mock, jest-types): Add additional tests for replaced property types --- packages/jest-mock/__typetests__/mock-functions.test.ts | 6 +++--- packages/jest-types/__typetests__/jest.test.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/jest-mock/__typetests__/mock-functions.test.ts b/packages/jest-mock/__typetests__/mock-functions.test.ts index 01f2b8ec78e0..b907f3b87ef9 100644 --- a/packages/jest-mock/__typetests__/mock-functions.test.ts +++ b/packages/jest-mock/__typetests__/mock-functions.test.ts @@ -504,7 +504,7 @@ const obj = { }; expectType>(replaceProperty(obj, 'property', 1)); -replaceProperty(obj, 'property', 1).replaceValue(1).restore(); +expectType(replaceProperty(obj, 'property', 1).replaceValue(1).restore()); expectError(replaceProperty(obj, 'invalid', 1)); expectError(replaceProperty(obj, 'property', 'not a number')); @@ -518,9 +518,9 @@ interface ComplexObject { [key: `dynamic prop ${number}`]: boolean; multipleTypes: number | string | {foo: number} | null; } -const complexObject: ComplexObject = undefined as unknown as ComplexObject; +declare const complexObject: ComplexObject; -// Resulting type should not be narrowed down to a type of value passed to replaceProperty, but should retain the original property type instead +// Resulting type should retain the original property type expectType>( replaceProperty(complexObject, 'numberOrUndefined', undefined), ); diff --git a/packages/jest-types/__typetests__/jest.test.ts b/packages/jest-types/__typetests__/jest.test.ts index ad5717d43645..64909f587222 100644 --- a/packages/jest-types/__typetests__/jest.test.ts +++ b/packages/jest-types/__typetests__/jest.test.ts @@ -262,6 +262,8 @@ expectType(jest.fn); expectType(jest.spyOn); +expectType(jest.replaceProperty); + // Mock expectType boolean>>({} as jest.Mock<() => boolean>); @@ -443,6 +445,12 @@ expectError( expectAssignable(mockObjectB); +// Replaced + +expectAssignable>( + jest.replaceProperty(someObject, 'propertyA', 123), +); + // Spied expectAssignable>( From b3fb383b082b2a92c72d14b53b57b4f13c022da9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Ko=C4=8D=C3=A1rek?= Date: Sun, 25 Dec 2022 13:46:56 +0100 Subject: [PATCH 13/19] refactor(jest-environment, jest-globals): Fix JSDoc comments for replaced property types and methods --- packages/jest-environment/src/index.ts | 2 -- packages/jest-globals/src/index.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/jest-environment/src/index.ts b/packages/jest-environment/src/index.ts index 23ea2efea5c7..64d573a1d4f0 100644 --- a/packages/jest-environment/src/index.ts +++ b/packages/jest-environment/src/index.ts @@ -220,8 +220,6 @@ export interface Jest { /** * Replaces property on an object with another value. * - * This method does not work on 'get' or 'set' accessors. - * * @remarks * For mocking functions or 'get' or 'set' accessors, use `jest.spyOn()` instead. */ diff --git a/packages/jest-globals/src/index.ts b/packages/jest-globals/src/index.ts index e72615120ccf..1cf9965afc6e 100644 --- a/packages/jest-globals/src/index.ts +++ b/packages/jest-globals/src/index.ts @@ -65,7 +65,7 @@ declare namespace jest { */ export type MockedObject = JestMockedObject; /** - * Constructs the type of a replaced object's property. + * Constructs the type of a replaced property. */ export type Replaced = JestReplaced; /** From 208df4df618515bac5113192dba0c478aa0d74f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Ko=C4=8D=C3=A1rek?= Date: Sun, 25 Dec 2022 13:47:28 +0100 Subject: [PATCH 14/19] refactor(docs): Improve style of replaced property sections and apply changes from code review --- docs/JestObjectAPI.md | 8 +++----- docs/MockFunctionAPI.md | 12 ++++++------ 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/docs/JestObjectAPI.md b/docs/JestObjectAPI.md index cd74760deeb4..dfc1c182eb6b 100644 --- a/docs/JestObjectAPI.md +++ b/docs/JestObjectAPI.md @@ -596,19 +596,17 @@ Determines if the given function is a mocked function. ### `jest.replaceProperty(object, propertyKey, value)` -Replace `object[propertyKey]` with a `value`. The property must already exist on the object. The same property might be replaced multiple times. -Returns a Jest [replaced property](MockFunctionAPI.md#replacedpropertyreplacevaluevalue). +Replace `object[propertyKey]` with a `value`. The property must already exist on the object. The same property might be replaced multiple times. Returns a Jest [replaced property](MockFunctionAPI.md#replaced-properties). :::note -To mock properties that are defined as getters or setters, use [`jest.spyOn(object, methodName, accessType)`](#jestspyonobject-methodname-accesstype) instead. -To mock functions, use [`jest.spyOn(object, methodName)`](#jestspyonobject-methodname) instead. +To mock properties that are defined as getters or setters, use [`jest.spyOn(object, methodName, accessType)`](#jestspyonobject-methodname-accesstype) instead. To mock functions, use [`jest.spyOn(object, methodName)`](#jestspyonobject-methodname) instead. ::: :::tip -Since `jest.replaceProperty` regi. You could restore the initial state calling [jest.restoreAllMocks](#jestrestoreallmocks) on [afterEach](GlobalAPI.md#aftereachfn-timeout) method. +All properties replaced with `jest.replaceProperty` could be restored to the original value by calling [jest.restoreAllMocks](#jestrestoreallmocks) on [afterEach](GlobalAPI.md#aftereachfn-timeout) method. ::: diff --git a/docs/MockFunctionAPI.md b/docs/MockFunctionAPI.md index b97cabe44d49..9efab5ae7ab9 100644 --- a/docs/MockFunctionAPI.md +++ b/docs/MockFunctionAPI.md @@ -17,7 +17,7 @@ import TOCInline from '@theme/TOCInline'; --- -## Reference +## Mock Functions ### `mockFn.getMockName()` @@ -515,10 +515,11 @@ test('async test', async () => { }); ``` +## Replaced Properties + ### `replacedProperty.replaceValue(value)` -Changes the value of already replaced property. This is useful when you want to replace property and then adjust the value in specific tests. -As an alternative, you can call [`jest.replaceProperty()`](JestObjectAPI.md#jestreplacepropertyobject-propertykey-value) multiple times on same property. +Changes the value of already replaced property. This is useful when you want to replace property and then adjust the value in specific tests. As an alternative, you can call [`jest.replaceProperty()`](JestObjectAPI.md#jestreplacepropertyobject-propertykey-value) multiple times on same property. ### `replacedProperty.restore()` @@ -609,7 +610,7 @@ Types of classes, functions or objects can be passed as type argument to `jest.M ### `jest.Replaced` -The `jest.Replaced` utility type returns the `Source` type wrapped with type definitions of Jest mock property. +The `jest.Replaced` utility type returns the `Source` type wrapped with type definitions of Jest [replaced property](#replaced-properties). ```ts title="src/utils.ts" export function isLocalhost(): boolean { @@ -635,10 +636,9 @@ it('isLocalhost should detect localhost environment', () => { it('isLocalhost should detect non-localhost environment', () => { replacedEnv = jest.replaceProperty(process, 'env', {HOSTNAME: 'example.com'}); - + expect(isLocalhost()).toBe(false); }); - ``` ### `jest.mocked(source, options?)` From ba36a3b52ddbf0254ecba2565f3c7381a6ad1140 Mon Sep 17 00:00:00 2001 From: Simen Bekkhus Date: Tue, 3 Jan 2023 08:04:00 +0100 Subject: [PATCH 15/19] Update docs/MockFunctionAPI.md --- docs/MockFunctionAPI.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/MockFunctionAPI.md b/docs/MockFunctionAPI.md index 9efab5ae7ab9..6e8444a7218d 100644 --- a/docs/MockFunctionAPI.md +++ b/docs/MockFunctionAPI.md @@ -17,7 +17,7 @@ import TOCInline from '@theme/TOCInline'; --- -## Mock Functions +## Reference ### `mockFn.getMockName()` From 472841c6f1eb3684ade0e51fc2d45f73957f4188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Ko=C4=8D=C3=A1rek?= Date: Tue, 3 Jan 2023 19:24:47 +0100 Subject: [PATCH 16/19] refactor(jest-mock): Fix type tests compatibility with TS 4.3 --- .../jest-mock/__typetests__/mock-functions.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/jest-mock/__typetests__/mock-functions.test.ts b/packages/jest-mock/__typetests__/mock-functions.test.ts index b907f3b87ef9..8d4a92dce3f4 100644 --- a/packages/jest-mock/__typetests__/mock-functions.test.ts +++ b/packages/jest-mock/__typetests__/mock-functions.test.ts @@ -515,11 +515,15 @@ expectError(replaceProperty(obj, 'property', 1).replaceValue('not a number')); interface ComplexObject { numberOrUndefined: number | undefined; optionalString?: string; - [key: `dynamic prop ${number}`]: boolean; multipleTypes: number | string | {foo: number} | null; } declare const complexObject: ComplexObject; +interface ObjectWithDynamicProperties { + [key: string]: boolean; +} +declare const objectWithDynamicProperties: ObjectWithDynamicProperties; + // Resulting type should retain the original property type expectType>( replaceProperty(complexObject, 'numberOrUndefined', undefined), @@ -544,9 +548,11 @@ expectType>( ); expectType>( - replaceProperty(complexObject, 'dynamic prop 1', true), + replaceProperty(objectWithDynamicProperties, 'dynamic prop 1', true), +); +expectError( + replaceProperty(objectWithDynamicProperties, 'dynamic prop 1', undefined), ); -expectError(replaceProperty(complexObject, 'dynamic prop 1', undefined)); expectError(replaceProperty(complexObject, 'not a property', undefined)); From 2f9c9c9dad9fbb1a0379a4a41392ff16b85c16a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Ko=C4=8D=C3=A1rek?= Date: Tue, 3 Jan 2023 19:29:10 +0100 Subject: [PATCH 17/19] refactor(jest-runtime): Fix forgotten rename of replaceProperty from previous commits --- ...kProperty.test.js => runtime_jest_replaceProperty.test.js} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename packages/jest-runtime/src/__tests__/{runtime_jest_mockProperty.test.js => runtime_jest_replaceProperty.test.js} (93%) diff --git a/packages/jest-runtime/src/__tests__/runtime_jest_mockProperty.test.js b/packages/jest-runtime/src/__tests__/runtime_jest_replaceProperty.test.js similarity index 93% rename from packages/jest-runtime/src/__tests__/runtime_jest_mockProperty.test.js rename to packages/jest-runtime/src/__tests__/runtime_jest_replaceProperty.test.js index 4e5594062ea1..6e5b825143ea 100644 --- a/packages/jest-runtime/src/__tests__/runtime_jest_mockProperty.test.js +++ b/packages/jest-runtime/src/__tests__/runtime_jest_replaceProperty.test.js @@ -27,10 +27,10 @@ describe('Runtime', () => { const mocked = root.jest.replaceProperty(obj, 'property', 2); expect(obj.property).toBe(2); - mocked.mockValue(3); + mocked.replaceValue(3); expect(obj.property).toBe(3); - mocked.mockRestore(); + mocked.restore(); expect(obj.property).toBe(1); }); }); From 33c30b9986bae9557272ac9af4a31b423dc5d11c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Ko=C4=8D=C3=A1rek?= Date: Tue, 3 Jan 2023 22:18:36 +0100 Subject: [PATCH 18/19] refactor(jest-mock): Hint to use replaceProperty when trying to mock primitive value --- packages/jest-mock/src/__tests__/index.test.ts | 8 ++++---- packages/jest-mock/src/index.ts | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/jest-mock/src/__tests__/index.test.ts b/packages/jest-mock/src/__tests__/index.test.ts index c23e4a437e1d..5bfbbd877223 100644 --- a/packages/jest-mock/src/__tests__/index.test.ts +++ b/packages/jest-mock/src/__tests__/index.test.ts @@ -1279,12 +1279,12 @@ describe('moduleMocker', () => { expect(() => { moduleMocker.spyOn({}, 'method'); }).toThrow( - 'Cannot spy the method property because it is not a function; undefined given instead', + "Cannot spy the method property because it is not a function; undefined given instead. If you are trying to mock a property, use `jest.replaceProperty(object, 'method', value)` instead.", ); expect(() => { moduleMocker.spyOn({method: 10}, 'method'); }).toThrow( - 'Cannot spy the method property because it is not a function; number given instead', + "Cannot spy the method property because it is not a function; number given instead. If you are trying to mock a property, use `jest.replaceProperty(object, 'method', value)` instead.", ); }); @@ -1449,12 +1449,12 @@ describe('moduleMocker', () => { expect(() => { moduleMocker.spyOn({}, 'method'); }).toThrow( - 'Cannot spy the method property because it is not a function; undefined given instead', + "Cannot spy the method property because it is not a function; undefined given instead. If you are trying to mock a property, use `jest.replaceProperty(object, 'method', value)` instead.", ); expect(() => { moduleMocker.spyOn({method: 10}, 'method'); }).toThrow( - 'Cannot spy the method property because it is not a function; number given instead', + "Cannot spy the method property because it is not a function; number given instead. If you are trying to mock a property, use `jest.replaceProperty(object, 'method', value)` instead.", ); }); diff --git a/packages/jest-mock/src/index.ts b/packages/jest-mock/src/index.ts index b4c60c9120c3..f143564bf733 100644 --- a/packages/jest-mock/src/index.ts +++ b/packages/jest-mock/src/index.ts @@ -1166,7 +1166,13 @@ export class ModuleMocker { methodKey, )} property because it is not a function; ${this._typeOf( original, - )} given instead`, + )} given instead.${ + typeof original !== 'object' + ? ` If you are trying to mock a property, use \`jest.replaceProperty(object, '${String( + methodKey, + )}', value)\` instead.` + : '' + }`, ); } @@ -1251,7 +1257,13 @@ export class ModuleMocker { propertyKey, )} property because it is not a function; ${this._typeOf( original, - )} given instead`, + )} given instead.${ + typeof original !== 'object' + ? ` If you are trying to mock a property, use \`jest.replaceProperty(object, '${String( + propertyKey, + )}', value)\` instead.` + : '' + }`, ); } From f0ffae189836c4dfea30c54a39fe26dbdc28bce6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Ko=C4=8D=C3=A1rek?= Date: Tue, 3 Jan 2023 22:25:51 +0100 Subject: [PATCH 19/19] refactor(docs): Relate two files in examples by providing correct path --- docs/MockFunctionAPI.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/MockFunctionAPI.md b/docs/MockFunctionAPI.md index 6e8444a7218d..b77e9a27d856 100644 --- a/docs/MockFunctionAPI.md +++ b/docs/MockFunctionAPI.md @@ -618,7 +618,7 @@ export function isLocalhost(): boolean { } ``` -```ts +```ts title="src/__tests__/utils.test.ts" import {afterEach, expect, it, jest} from '@jest/globals'; import {isLocalhost} from '../utils';