From e77128b12839a712f268a856968d4d7a413c7405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Ko=C4=8D=C3=A1rek?= <762095+michal-kocarek@users.noreply.github.com> Date: Wed, 4 Jan 2023 11:21:38 +0100 Subject: [PATCH] feat: Allow mocking property value in tests (#13496) --- CHANGELOG.md | 1 + docs/JestObjectAPI.md | 57 ++++- 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 + packages/jest-environment/src/index.ts | 12 +- packages/jest-globals/src/index.ts | 5 + .../__typetests__/mock-functions.test.ts | 70 ++++++ .../jest-mock/src/__tests__/index.test.ts | 238 +++++++++++++++++- packages/jest-mock/src/index.ts | 171 ++++++++++++- .../runtime_jest_replaceProperty.test.js | 37 +++ packages/jest-runtime/src/index.ts | 4 + .../jest-types/__typetests__/jest.test.ts | 8 + 15 files changed, 689 insertions(+), 10 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 create mode 100644 packages/jest-runtime/src/__tests__/runtime_jest_replaceProperty.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fb895c3062e..39d0963fbc1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Features +- `[@jest/globals, jest-mock]` Add `jest.replaceProperty()` that replaces property value ([#13496](https://github.com/facebook/jest/pull/13496)) - `[expect, @jest/expect-utils]` Support custom equality testers ([#13654](https://github.com/facebook/jest/pull/13654)) - `[jest-haste-map]` ignore Sapling vcs directories (`.sl/`) ([#13674](https://github.com/facebook/jest/pull/13674)) - `[jest-resolve]` Support subpath imports ([#13705](https://github.com/facebook/jest/pull/13705)) diff --git a/docs/JestObjectAPI.md b/docs/JestObjectAPI.md index dfb574052f85..7019ea4935dc 100644 --- a/docs/JestObjectAPI.md +++ b/docs/JestObjectAPI.md @@ -608,13 +608,62 @@ 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#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. + +::: + +:::tip + +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. + +::: + +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));` ::: @@ -713,6 +762,10 @@ test('plays audio', () => { }); ``` +### `jest.Replaced` + +See [TypeScript Usage](MockFunctionAPI.md#replacedpropertyreplacevaluevalue) chapter of Mock Functions page for documentation. + ### `jest.Spied` See [TypeScript Usage](MockFunctionAPI.md#jestspiedsource) chapter of Mock Functions page for documentation. @@ -731,7 +784,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..b77e9a27d856 100644 --- a/docs/MockFunctionAPI.md +++ b/docs/MockFunctionAPI.md @@ -515,6 +515,20 @@ 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. + +### `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 +608,39 @@ 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 [replaced property](#replaced-properties). + +```ts title="src/utils.ts" +export function isLocalhost(): boolean { + return process.env['HOSTNAME'] === 'localhost'; +} +``` + +```ts title="src/__tests__/utils.test.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'; +} diff --git a/packages/jest-environment/src/index.ts b/packages/jest-environment/src/index.ts index f2b3bf4ee961..8ec2980e414a 100644 --- a/packages/jest-environment/src/index.ts +++ b/packages/jest-environment/src/index.ts @@ -223,6 +223,13 @@ export interface Jest { * mocked behavior. */ mocked: ModuleMocker['mocked']; + /** + * Replaces property on an object with another value. + * + * @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. @@ -239,8 +246,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. diff --git a/packages/jest-globals/src/index.ts b/packages/jest-globals/src/index.ts index c2bd3abb7f59..1cf9965afc6e 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 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 2fbc7c7b9cff..8d4a92dce3f4 100644 --- a/packages/jest-mock/__typetests__/mock-functions.test.ts +++ b/packages/jest-mock/__typetests__/mock-functions.test.ts @@ -15,11 +15,13 @@ import { } from 'tsd-lite'; import { Mock, + Replaced, SpiedClass, SpiedFunction, SpiedGetter, SpiedSetter, fn, + replaceProperty, spyOn, } from 'jest-mock'; @@ -492,3 +494,71 @@ expectError( (key: string, value: number) => {}, ), ); + +// replaceProperty + Replaced + +const obj = { + fn: () => {}, + + property: 1, +}; + +expectType>(replaceProperty(obj, 'property', 1)); +expectType(replaceProperty(obj, 'property', 1).replaceValue(1).restore()); + +expectError(replaceProperty(obj, 'invalid', 1)); +expectError(replaceProperty(obj, 'property', 'not a number')); +expectError(replaceProperty(obj, 'fn', () => {})); + +expectError(replaceProperty(obj, 'property', 1).replaceValue('not a number')); + +interface ComplexObject { + numberOrUndefined: number | undefined; + optionalString?: string; + 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), +); +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(objectWithDynamicProperties, 'dynamic prop 1', true), +); +expectError( + replaceProperty(objectWithDynamicProperties, '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 39146edd7e30..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.", ); }); @@ -1604,6 +1604,236 @@ describe('moduleMocker', () => { expect(spy2.mock.calls).toHaveLength(1); }); }); + + describe('replaceProperty', () => { + it('should work', () => { + const obj = { + property: 1, + }; + + const replaced = moduleMocker.replaceProperty(obj, 'property', 2); + + expect(obj.property).toBe(2); + + replaced.restore(); + + expect(obj.property).toBe(1); + }); + + it('should allow mocking a property multiple times', () => { + const obj = { + property: 1, + }; + + const replacedFirst = moduleMocker.replaceProperty(obj, 'property', 2); + + const replacedSecond = moduleMocker.replaceProperty(obj, 'property', 3); + + expect(obj.property).toBe(3); + + replacedSecond.restore(); + + expect(obj.property).toBe(1); + + 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.each` + value + ${null} + ${undefined} + `('when $value is provided instead of an object', ({value}) => { + expect(() => { + moduleMocker.replaceProperty(value, 'property', 1); + }).toThrow( + 'replaceProperty could not find an object on which to replace property', + ); + }); + + 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.replaceProperty({}, null, 1); + }).toThrow('No property name supplied'); + }); + + it('when property is not defined', () => { + expect(() => { + moduleMocker.replaceProperty({}, '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.replaceProperty(obj, 'property', 2); + }).toThrow('property is not declared configurable'); + }); + + it('when trying to mock a method', () => { + expect(() => { + moduleMocker.replaceProperty({method: () => {}}, 'method', () => {}); + }).toThrow( + "Cannot mock the method property because it is a function. Use `jest.spyOn(object, 'method')` instead.", + ); + }); + + it('when mocking a getter', () => { + const obj = { + get getter() { + return 1; + }, + }; + + expect(() => { + moduleMocker.replaceProperty(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.replaceProperty(obj, 'setter', 1); + }).toThrow('Cannot mock the setter property because it has a setter'); + }); + }); + + it('should work for property from prototype chain', () => { + const parent = {property: 'abcd'}; + const child = Object.create(parent); + + const replaced = moduleMocker.replaceProperty(child, 'property', 'defg'); + + expect(child.property).toBe('defg'); + + replaced.restore(); + + expect(child.property).toBe('abcd'); + expect( + Object.getOwnPropertyDescriptor(child, 'property'), + ).toBeUndefined(); + }); + + describe('with restoreAllMocks', () => { + it('should work', () => { + const obj = { + property: 1, + }; + + const replaced = moduleMocker.replaceProperty(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.restore(); + + 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('replaceValue', () => { + it('should work', () => { + const obj = { + property: 1, + }; + + const replaced = moduleMocker.replaceProperty(obj, 'property', 2); + + const result = replaced.replaceValue(3); + + expect(obj.property).toBe(3); + 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); + }); + }); + }); }); describe('mocked', () => { diff --git a/packages/jest-mock/src/index.ts b/packages/jest-mock/src/index.ts index 93c33413c3f0..f143564bf733 100644 --- a/packages/jest-mock/src/index.ts +++ b/packages/jest-mock/src/index.ts @@ -172,6 +172,28 @@ export interface MockInstance { mockRejectedValueOnce(value: RejectType): this; } +export interface Replaced { + /** + * Restore property to its original value known at the time of mocking. + */ + restore(): void; + + /** + * Change the value of the property. + */ + replaceValue(value: T): this; +} + +type ReplacedPropertyRestorer< + T extends object, + K extends PropertyLikeKeys, +> = { + (): void; + object: T; + property: K; + replaced: Replaced; +}; + type MockFunctionResultIncomplete = { type: 'incomplete'; /** @@ -956,6 +978,27 @@ export class ModuleMocker { return mock as Mocked; } + /** + * Check whether the given property of an object has been already replaced. + */ + 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.object === object && + spyState.property === propertyKey + ) { + return spyState as ReplacedPropertyRestorer; + } + } + + return; + } + /** * @see README.md * @param metadata Metadata for the mock in the schema returned by the @@ -1123,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.` + : '' + }`, ); } @@ -1208,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.` + : '' + }`, ); } @@ -1230,6 +1285,117 @@ export class ModuleMocker { return descriptor[accessType] as Mock; } + replaceProperty< + T extends object, + K extends PropertyLikeKeys, + V extends T[K], + >(object: T, propertyKey: K, value: V): Replaced { + if (object === undefined || object == null) { + throw new Error( + `replaceProperty could not find an object on which to replace ${String( + propertyKey, + )}`, + ); + } + + if (propertyKey === undefined || propertyKey === null) { + throw new Error('No property name supplied'); + } + + if (typeof object !== 'object') { + throw new Error( + `Cannot mock property on a non-object 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 (descriptor.get !== undefined) { + throw new Error( + `Cannot mock the ${String( + propertyKey, + )} property because it has a getter. Use \`jest.spyOn(object, '${String( + propertyKey, + )}', 'get').mockReturnValue(value)\` instead.`, + ); + } + + if (descriptor.set !== undefined) { + throw new Error( + `Cannot mock the ${String( + propertyKey, + )} property because it has a setter. Use \`jest.spyOn(object, '${String( + propertyKey, + )}', 'set').mockReturnValue(value)\` instead.`, + ); + } + + if (typeof descriptor.value === 'function') { + throw new Error( + `Cannot mock the ${String( + propertyKey, + )} property because it is a function. Use \`jest.spyOn(object, '${String( + propertyKey, + )}')\` instead.`, + ); + } + + const existingRestore = this._findReplacedProperty(object, propertyKey); + + if (existingRestore) { + return existingRestore.replaced.replaceValue(value); + } + + const isPropertyOwner = Object.prototype.hasOwnProperty.call( + object, + propertyKey, + ); + const originalValue = descriptor.value; + + const restore: ReplacedPropertyRestorer = () => { + if (isPropertyOwner) { + object[propertyKey] = originalValue; + } else { + delete object[propertyKey]; + } + }; + + const replaced: Replaced = { + replaceValue: value => { + object[propertyKey] = value; + + return replaced; + }, + + restore: () => { + restore(); + + this._spyState.delete(restore); + }, + }; + + restore.object = object; + restore.property = propertyKey; + restore.replaced = replaced; + + this._spyState.add(restore); + + return replaced.replaceValue(value); + } + clearAllMocks(): void { this._mockState = new WeakMap(); } @@ -1266,3 +1432,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 replaceProperty = JestMock.replaceProperty.bind(JestMock); diff --git a/packages/jest-runtime/src/__tests__/runtime_jest_replaceProperty.test.js b/packages/jest-runtime/src/__tests__/runtime_jest_replaceProperty.test.js new file mode 100644 index 000000000000..6e5b825143ea --- /dev/null +++ b/packages/jest-runtime/src/__tests__/runtime_jest_replaceProperty.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.replaceProperty', () => { + it('should work', async () => { + const runtime = await createRuntime(__filename); + const root = runtime.requireModule(runtime.__mockRootPath); + const mocked = root.jest.replaceProperty(obj, 'property', 2); + expect(obj.property).toBe(2); + + mocked.replaceValue(3); + expect(obj.property).toBe(3); + + mocked.restore(); + expect(obj.property).toBe(1); + }); + }); +}); diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index 21bf82700fdf..dcca9ceef75d 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -2193,6 +2193,9 @@ export default class Runtime { 'Your test environment does not support `mocked`, please update it.', ); }); + const replaceProperty = this._moduleMocker.replaceProperty.bind( + this._moduleMocker, + ); const setTimeout = (timeout: number) => { this._environment.global[testTimeoutSymbol] = timeout; @@ -2253,6 +2256,7 @@ export default class Runtime { mock, mocked, now: () => _getFakeTimers().now(), + replaceProperty, requireActual: moduleName => this.requireActual(from, moduleName), requireMock: moduleName => this.requireMock(from, moduleName), resetAllMocks, diff --git a/packages/jest-types/__typetests__/jest.test.ts b/packages/jest-types/__typetests__/jest.test.ts index 691bcd330f9a..392fdfff2cb4 100644 --- a/packages/jest-types/__typetests__/jest.test.ts +++ b/packages/jest-types/__typetests__/jest.test.ts @@ -266,6 +266,8 @@ expectType(jest.fn); expectType(jest.spyOn); +expectType(jest.replaceProperty); + // Mock expectType boolean>>({} as jest.Mock<() => boolean>); @@ -447,6 +449,12 @@ expectError( expectAssignable(mockObjectB); +// Replaced + +expectAssignable>( + jest.replaceProperty(someObject, 'propertyA', 123), +); + // Spied expectAssignable>(