From 495afaaa5d8dd3a795bd7236724eaa688c7fcb98 Mon Sep 17 00:00:00 2001 From: Tom Mrazauskas Date: Sun, 26 Feb 2023 15:57:00 +0200 Subject: [PATCH 1/3] feat(jest-mock): allow replacing undefined properties --- docs/JestObjectAPI.md | 20 +++++++++-- .../__typetests__/mock-functions.test.ts | 7 ++++ .../jest-mock/src/__tests__/index.test.ts | 36 +++++++++++++++++-- packages/jest-mock/src/index.ts | 24 +++++++------ 4 files changed, 72 insertions(+), 15 deletions(-) diff --git a/docs/JestObjectAPI.md b/docs/JestObjectAPI.md index c012e9323d4a..075f6affcb51 100644 --- a/docs/JestObjectAPI.md +++ b/docs/JestObjectAPI.md @@ -614,9 +614,9 @@ See the [Mock Functions](MockFunctionAPI.md#jestfnimplementation) page for detai Determines if the given function is a mocked function. -### `jest.replaceProperty(object, propertyKey, value)` +### `jest.replaceProperty(object, propertyKey, value, options?)` -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). +Replace `object[propertyKey]` with a `value`. The same property might be replaced multiple times. Returns a Jest [replaced property](MockFunctionAPI.md#replaced-properties). :::note @@ -663,6 +663,22 @@ test('isLocalhost returns false when HOSTNAME is not localhost', () => { }); ``` +Jest will make sure that the target property exists on the object and its value is not `undefined`. To skip this precaution, use `tolerateUndefined` option: + +```js +const isCI = () => process.env.CI === 'true'; + +afterEach(() => { + jest.restoreAllMocks(); +}); + +test('when process.env.CI is true', () => { + jest.replaceProperty(process.env, 'CI', 'true', {tolerateUndefined: true}); + + expect(isCI()).toBe(true); +}); +``` + ### `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). diff --git a/packages/jest-mock/__typetests__/mock-functions.test.ts b/packages/jest-mock/__typetests__/mock-functions.test.ts index a1c2157787ab..0abe75656050 100644 --- a/packages/jest-mock/__typetests__/mock-functions.test.ts +++ b/packages/jest-mock/__typetests__/mock-functions.test.ts @@ -562,3 +562,10 @@ expectType>( .replaceValue({foo: 1}) .replaceValue(null), ); + +expectType>( + replaceProperty(process.env, 'CI', 'true', {tolerateUndefined: true}), +); +expectError>( + replaceProperty(process.env, 'CI', 'true', {tolerateUndefined: 'all'}), +); diff --git a/packages/jest-mock/src/__tests__/index.test.ts b/packages/jest-mock/src/__tests__/index.test.ts index 8f178c896b8d..00fa25ad0767 100644 --- a/packages/jest-mock/src/__tests__/index.test.ts +++ b/packages/jest-mock/src/__tests__/index.test.ts @@ -1980,7 +1980,7 @@ describe('moduleMocker', () => { expect(obj.property).toBe(1); }); - it('should allow mocking a property multiple times', () => { + it('should allow replacing a property multiple times', () => { const obj = { property: 1, }; @@ -2000,8 +2000,8 @@ describe('moduleMocker', () => { expect(obj.property).toBe(1); }); - it('should allow mocking with value of different type', () => { - const obj = { + it('should allow replacing with value of different type', () => { + const obj: {property: unknown} = { property: 1, }; @@ -2016,6 +2016,36 @@ describe('moduleMocker', () => { expect(obj.property).toBe(1); }); + it('should allow replacing undefined property, when `tolerateUndefined: true` is set', () => { + const obj: {property: number | undefined} = { + property: undefined, + }; + + const replaced = moduleMocker.replaceProperty(obj, 'property', 2, { + tolerateUndefined: true, + }); + + expect(obj.property).toBe(2); + + replaced.restore(); + + expect(obj).toStrictEqual({property: undefined}); + }); + + it('should allow adding nonexistent property, when `tolerateUndefined: true` is set', () => { + const obj: Record = {}; + + const replaced = moduleMocker.replaceProperty(obj, 'nonexistent', 2, { + tolerateUndefined: true, + }); + + expect(obj.nonexistent).toBe(2); + + replaced.restore(); + + expect(obj).not.toHaveProperty('nonexistent'); + }); + describe('should throw', () => { it.each` value diff --git a/packages/jest-mock/src/index.ts b/packages/jest-mock/src/index.ts index 8456c1312ac7..98493c20bfb6 100644 --- a/packages/jest-mock/src/index.ts +++ b/packages/jest-mock/src/index.ts @@ -177,7 +177,6 @@ export interface Replaced { * Restore property to its original value known at the time of mocking. */ restore(): void; - /** * Change the value of the property. */ @@ -1332,8 +1331,13 @@ export class ModuleMocker { T extends object, K extends PropertyLikeKeys, V extends T[K], - >(object: T, propertyKey: K, value: V): Replaced { - if (object === undefined || object == null) { + >( + object: T, + propertyKey: K, + value: V, + options?: {tolerateUndefined?: boolean}, + ): Replaced { + if (object == null) { throw new Error( `replaceProperty could not find an object on which to replace ${String( propertyKey, @@ -1341,7 +1345,7 @@ export class ModuleMocker { ); } - if (propertyKey === undefined || propertyKey === null) { + if (propertyKey == null) { throw new Error('No property name supplied'); } @@ -1359,14 +1363,14 @@ export class ModuleMocker { descriptor = Object.getOwnPropertyDescriptor(proto, propertyKey); proto = Object.getPrototypeOf(proto); } - if (!descriptor) { + if (!descriptor && !options?.tolerateUndefined) { throw new Error(`${String(propertyKey)} property does not exist`); } - if (!descriptor.configurable) { + if (descriptor?.configurable === false) { throw new Error(`${String(propertyKey)} is not declared configurable`); } - if (descriptor.get !== undefined) { + if (descriptor?.get !== undefined) { throw new Error( `Cannot mock the ${String( propertyKey, @@ -1376,7 +1380,7 @@ export class ModuleMocker { ); } - if (descriptor.set !== undefined) { + if (descriptor?.set !== undefined) { throw new Error( `Cannot mock the ${String( propertyKey, @@ -1386,7 +1390,7 @@ export class ModuleMocker { ); } - if (typeof descriptor.value === 'function') { + if (typeof descriptor?.value === 'function') { throw new Error( `Cannot mock the ${String( propertyKey, @@ -1406,7 +1410,7 @@ export class ModuleMocker { object, propertyKey, ); - const originalValue = descriptor.value; + const originalValue = descriptor?.value; const restore: ReplacedPropertyRestorer = () => { if (isPropertyOwner) { From 1be2998b0529c510cb522cfd16de77b9b7d69241 Mon Sep 17 00:00:00 2001 From: Tom Mrazauskas Date: Sun, 26 Feb 2023 16:03:12 +0200 Subject: [PATCH 2/3] add changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4f5bbe26c2b..2b96d79d5133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - `[@jest/create-cache-key-function]` Allow passing `length` argument to `createCacheKey()` function and set its default value to `16` on Windows ([#13827](https://github.com/facebook/jest/pull/13827)) - `[jest-message-util]` Add support for [AggregateError](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AggregateError) ([#13946](https://github.com/facebook/jest/pull/13946) & [#13947](https://github.com/facebook/jest/pull/13947)) - `[jest-message-util]` Add support for [Error causes](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause) in `test` and `it` ([#13935](https://github.com/facebook/jest/pull/13935)) +- `[jest-mock]` Allow `jest.replaceProperty()` to replace `undefined` or nonexistent properties ([#13958](https://github.com/facebook/jest/pull/13958)) - `[jest-worker]` Add `start` method to worker farms ([#13937](https://github.com/facebook/jest/pull/13937)) ### Fixes From a621430aad3634f3b2e8e7dc1b160410123fd8c0 Mon Sep 17 00:00:00 2001 From: Tom Mrazauskas Date: Sun, 26 Feb 2023 19:42:21 +0200 Subject: [PATCH 3/3] handle non-writable props --- packages/jest-mock/src/__tests__/index.test.ts | 16 +++++++++++++++- packages/jest-mock/src/index.ts | 4 ++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/jest-mock/src/__tests__/index.test.ts b/packages/jest-mock/src/__tests__/index.test.ts index 00fa25ad0767..30b32787f699 100644 --- a/packages/jest-mock/src/__tests__/index.test.ts +++ b/packages/jest-mock/src/__tests__/index.test.ts @@ -2099,13 +2099,27 @@ describe('moduleMocker', () => { Object.defineProperty(obj, 'property', { configurable: false, value: 1, - writable: false, + writable: true, }); moduleMocker.replaceProperty(obj, 'property', 2); }).toThrow('property is not declared configurable'); }); + it('when property is not writable', () => { + expect(() => { + const obj = {}; + + Object.defineProperty(obj, 'property', { + configurable: true, + value: 1, + writable: false, + }); + + moduleMocker.replaceProperty(obj, 'property', 2); + }).toThrow('property is not declared writable'); + }); + it('when trying to mock a method', () => { expect(() => { moduleMocker.replaceProperty({method: () => {}}, 'method', () => {}); diff --git a/packages/jest-mock/src/index.ts b/packages/jest-mock/src/index.ts index 98493c20bfb6..c6ab3db8c67b 100644 --- a/packages/jest-mock/src/index.ts +++ b/packages/jest-mock/src/index.ts @@ -1366,9 +1366,13 @@ export class ModuleMocker { if (!descriptor && !options?.tolerateUndefined) { throw new Error(`${String(propertyKey)} property does not exist`); } + if (descriptor?.configurable === false) { throw new Error(`${String(propertyKey)} is not declared configurable`); } + if (descriptor?.writable === false) { + throw new Error(`${String(propertyKey)} is not declared writable`); + } if (descriptor?.get !== undefined) { throw new Error(