Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat(jest-mock): allow jest.replaceProperty to replace undefined or nonexistent properties #13958

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions docs/JestObjectAPI.md
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really like the name tolerateUndefined. I don't have any better suggestions off the top of my head, tho.

tolerateMissing, allowMissing, setMissing... neither sounds good, but tolerates is even worse imo 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about respectUndefined? Not sure. Hm.. Did you see my comment on non-writable values? What about option strict: false which would allow creating undefined props and overwriting non-writable values? Simple thing.

The target property cannot be undefined and non-writable at the same time. So in a way one option which turns off precautions should be enough.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does replaceProperty actually disallow existing but undefined properties? I would consider this a bug.

And for non-existing properties, it probably makes more sense to add jest.addProperty instead which would act like replaceProperty + tolerateUndefined.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for thinking together. How do you see replacement of non-writeable props? Should it just work or do we need an option?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think for mocking, replacing non-writeable is fine (though they should remain non-writeable). I view non-writeable more as a hint for the runtime behavior while testing setup comes before the actual runtime.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think I want addProperty - then I think we'd need a removeProperty as well. It might have been better for replaceProperty to have been called setProperty, then taken options for replacement strategy. Anyways, I think a single API on jest is enough.


```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).
Expand Down
7 changes: 7 additions & 0 deletions packages/jest-mock/__typetests__/mock-functions.test.ts
Expand Up @@ -562,3 +562,10 @@ expectType<Replaced<ComplexObject['multipleTypes']>>(
.replaceValue({foo: 1})
.replaceValue(null),
);

expectType<Replaced<string | undefined>>(
replaceProperty(process.env, 'CI', 'true', {tolerateUndefined: true}),
);
expectError<Replaced<string | undefined>>(
replaceProperty(process.env, 'CI', 'true', {tolerateUndefined: 'all'}),
);
52 changes: 48 additions & 4 deletions packages/jest-mock/src/__tests__/index.test.ts
Expand Up @@ -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,
};
Expand All @@ -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,
};

Expand All @@ -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<string, unknown> = {};

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
Expand Down Expand Up @@ -2069,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', () => {});
Expand Down
28 changes: 18 additions & 10 deletions packages/jest-mock/src/index.ts
Expand Up @@ -177,7 +177,6 @@ export interface Replaced<T = unknown> {
* Restore property to its original value known at the time of mocking.
*/
restore(): void;

/**
* Change the value of the property.
*/
Expand Down Expand Up @@ -1332,16 +1331,21 @@ export class ModuleMocker {
T extends object,
K extends PropertyLikeKeys<T>,
V extends T[K],
>(object: T, propertyKey: K, value: V): Replaced<T[K]> {
if (object === undefined || object == null) {
>(
object: T,
propertyKey: K,
value: V,
options?: {tolerateUndefined?: boolean},
): Replaced<T[K]> {
if (object == null) {
throw new Error(
`replaceProperty could not find an object on which to replace ${String(
propertyKey,
)}`,
);
}

if (propertyKey === undefined || propertyKey === null) {
if (propertyKey == null) {
throw new Error('No property name supplied');
}

Expand All @@ -1359,14 +1363,18 @@ 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?.writable === false) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

writable check was missing. I bumped into it while playing with this case:

https://github.com/facebook/jest/blob/bb39cb2c617a3334bf18daeca66bd87b7ccab28b/packages/babel-jest/src/__tests__/getCacheKey.test.ts#L195-L197

Hm.. What to do with non-writable values like process.version? Would be nice to replace them too. Perhaps via tolerateFrozen option? (Alternatives like tolerateNonWritable, or tolerateNonwritable don’t look convincing.)

throw new Error(`${String(propertyKey)} is not declared writable`);
}

if (descriptor.get !== undefined) {
if (descriptor?.get !== undefined) {
throw new Error(
`Cannot mock the ${String(
propertyKey,
Expand All @@ -1376,7 +1384,7 @@ export class ModuleMocker {
);
}

if (descriptor.set !== undefined) {
if (descriptor?.set !== undefined) {
throw new Error(
`Cannot mock the ${String(
propertyKey,
Expand All @@ -1386,7 +1394,7 @@ export class ModuleMocker {
);
}

if (typeof descriptor.value === 'function') {
if (typeof descriptor?.value === 'function') {
throw new Error(
`Cannot mock the ${String(
propertyKey,
Expand All @@ -1406,7 +1414,7 @@ export class ModuleMocker {
object,
propertyKey,
);
const originalValue = descriptor.value;
const originalValue = descriptor?.value;

const restore: ReplacedPropertyRestorer<T, K> = () => {
if (isPropertyOwner) {
Expand Down