Skip to content

Commit e77128b

Browse files
feat: Allow mocking property value in tests (#13496)
1 parent a325f87 commit e77128b

File tree

15 files changed

+689
-10
lines changed

15 files changed

+689
-10
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
### Features
44

5+
- `[@jest/globals, jest-mock]` Add `jest.replaceProperty()` that replaces property value ([#13496](https://github.com/facebook/jest/pull/13496))
56
- `[expect, @jest/expect-utils]` Support custom equality testers ([#13654](https://github.com/facebook/jest/pull/13654))
67
- `[jest-haste-map]` ignore Sapling vcs directories (`.sl/`) ([#13674](https://github.com/facebook/jest/pull/13674))
78
- `[jest-resolve]` Support subpath imports ([#13705](https://github.com/facebook/jest/pull/13705))

docs/JestObjectAPI.md

+55-2
Original file line numberDiff line numberDiff line change
@@ -608,13 +608,62 @@ See [Mock Functions](MockFunctionAPI.md#jestfnimplementation) page for details o
608608

609609
Determines if the given function is a mocked function.
610610

611+
### `jest.replaceProperty(object, propertyKey, value)`
612+
613+
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).
614+
615+
:::note
616+
617+
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.
618+
619+
:::
620+
621+
:::tip
622+
623+
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.
624+
625+
:::
626+
627+
Example:
628+
629+
```js
630+
const utils = {
631+
isLocalhost() {
632+
return process.env.HOSTNAME === 'localhost';
633+
},
634+
};
635+
636+
module.exports = utils;
637+
```
638+
639+
Example test:
640+
641+
```js
642+
const utils = require('./utils');
643+
644+
afterEach(() => {
645+
// restore replaced property
646+
jest.restoreAllMocks();
647+
});
648+
649+
test('isLocalhost returns true when HOSTNAME is localhost', () => {
650+
jest.replaceProperty(process, 'env', {HOSTNAME: 'localhost'});
651+
expect(utils.isLocalhost()).toBe(true);
652+
});
653+
654+
test('isLocalhost returns false when HOSTNAME is not localhost', () => {
655+
jest.replaceProperty(process, 'env', {HOSTNAME: 'not-localhost'});
656+
expect(utils.isLocalhost()).toBe(false);
657+
});
658+
```
659+
611660
### `jest.spyOn(object, methodName)`
612661

613662
Creates a mock function similar to `jest.fn` but also tracks calls to `object[methodName]`. Returns a Jest [mock function](MockFunctionAPI.md).
614663

615664
:::note
616665

617-
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);`
666+
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));`
618667

619668
:::
620669

@@ -713,6 +762,10 @@ test('plays audio', () => {
713762
});
714763
```
715764

765+
### `jest.Replaced<Source>`
766+
767+
See [TypeScript Usage](MockFunctionAPI.md#replacedpropertyreplacevaluevalue) chapter of Mock Functions page for documentation.
768+
716769
### `jest.Spied<Source>`
717770

718771
See [TypeScript Usage](MockFunctionAPI.md#jestspiedsource) chapter of Mock Functions page for documentation.
@@ -731,7 +784,7 @@ Returns the `jest` object for chaining.
731784

732785
### `jest.restoreAllMocks()`
733786

734-
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.
787+
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.
735788

736789
## Fake Timers
737790

docs/MockFunctionAPI.md

+47
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,20 @@ test('async test', async () => {
515515
});
516516
```
517517

518+
## Replaced Properties
519+
520+
### `replacedProperty.replaceValue(value)`
521+
522+
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.
523+
524+
### `replacedProperty.restore()`
525+
526+
Restores object's property to the original value.
527+
528+
Beware that `replacedProperty.restore()` only works when the property value was replaced with [`jest.replaceProperty()`](JestObjectAPI.md#jestreplacepropertyobject-propertykey-value).
529+
530+
The [`restoreMocks`](configuration#restoremocks-boolean) configuration option is available to restore replaced properties automatically before each test.
531+
518532
## TypeScript Usage
519533

520534
<TypeScriptExamplesNote />
@@ -594,6 +608,39 @@ test('returns correct data', () => {
594608

595609
Types of classes, functions or objects can be passed as type argument to `jest.Mocked<Source>`. If you prefer to constrain the input type, use: `jest.MockedClass<Source>`, `jest.MockedFunction<Source>` or `jest.MockedObject<Source>`.
596610

611+
### `jest.Replaced<Source>`
612+
613+
The `jest.Replaced<Source>` utility type returns the `Source` type wrapped with type definitions of Jest [replaced property](#replaced-properties).
614+
615+
```ts title="src/utils.ts"
616+
export function isLocalhost(): boolean {
617+
return process.env['HOSTNAME'] === 'localhost';
618+
}
619+
```
620+
621+
```ts title="src/__tests__/utils.test.ts"
622+
import {afterEach, expect, it, jest} from '@jest/globals';
623+
import {isLocalhost} from '../utils';
624+
625+
let replacedEnv: jest.Replaced<typeof process.env> | undefined = undefined;
626+
627+
afterEach(() => {
628+
replacedEnv?.restore();
629+
});
630+
631+
it('isLocalhost should detect localhost environment', () => {
632+
replacedEnv = jest.replaceProperty(process, 'env', {HOSTNAME: 'localhost'});
633+
634+
expect(isLocalhost()).toBe(true);
635+
});
636+
637+
it('isLocalhost should detect non-localhost environment', () => {
638+
replacedEnv = jest.replaceProperty(process, 'env', {HOSTNAME: 'example.com'});
639+
640+
expect(isLocalhost()).toBe(false);
641+
});
642+
```
643+
597644
### `jest.mocked(source, options?)`
598645

599646
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.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import {isLocalhost} from '../utils';
2+
3+
afterEach(() => {
4+
jest.restoreAllMocks();
5+
});
6+
7+
it('isLocalhost should detect localhost environment', () => {
8+
jest.replaceProperty(process, 'env', {HOSTNAME: 'localhost'});
9+
10+
expect(isLocalhost()).toBe(true);
11+
});
12+
13+
it('isLocalhost should detect non-localhost environment', () => {
14+
jest.replaceProperty(process, 'env', {HOSTNAME: 'example.com'});
15+
16+
expect(isLocalhost()).toBe(false);
17+
});

examples/manual-mocks/utils.js

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function isLocalhost() {
2+
return process.env.HOSTNAME === 'localhost';
3+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright (c) 2014-present, Facebook, Inc. All rights reserved.
2+
3+
import {afterEach, beforeEach, expect, it, jest} from '@jest/globals';
4+
import {isLocalhost} from '../utils';
5+
6+
let replacedEnv: jest.Replaced<typeof process.env> | undefined = undefined;
7+
8+
beforeEach(() => {
9+
replacedEnv = jest.replaceProperty(process, 'env', {});
10+
});
11+
12+
afterEach(() => {
13+
replacedEnv?.restore();
14+
});
15+
16+
it('isLocalhost should detect localhost environment', () => {
17+
replacedEnv.replaceValue({HOSTNAME: 'localhost'});
18+
19+
expect(isLocalhost()).toBe(true);
20+
});
21+
22+
it('isLocalhost should detect non-localhost environment', () => {
23+
expect(isLocalhost()).toBe(false);
24+
});

examples/typescript/utils.ts

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Copyright (c) 2014-present, Facebook, Inc. All rights reserved.
2+
3+
export function isLocalhost() {
4+
return process.env.HOSTNAME === 'localhost';
5+
}

packages/jest-environment/src/index.ts

+10-2
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,13 @@ export interface Jest {
223223
* mocked behavior.
224224
*/
225225
mocked: ModuleMocker['mocked'];
226+
/**
227+
* Replaces property on an object with another value.
228+
*
229+
* @remarks
230+
* For mocking functions or 'get' or 'set' accessors, use `jest.spyOn()` instead.
231+
*/
232+
replaceProperty: ModuleMocker['replaceProperty'];
226233
/**
227234
* Returns a mock module instead of the actual module, bypassing all checks
228235
* on whether the module should be required normally or not.
@@ -239,8 +246,9 @@ export interface Jest {
239246
*/
240247
resetModules(): Jest;
241248
/**
242-
* Restores all mocks back to their original value. Equivalent to calling
243-
* `.mockRestore()` on every mocked function.
249+
* Restores all mocks and replaced properties back to their original value.
250+
* Equivalent to calling `.mockRestore()` on every mocked function
251+
* and `.restore()` on every replaced property.
244252
*
245253
* Beware that `jest.restoreAllMocks()` only works when the mock was created
246254
* with `jest.spyOn()`; other mocks will require you to manually restore them.

packages/jest-globals/src/index.ts

+5
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
MockedClass as JestMockedClass,
1717
MockedFunction as JestMockedFunction,
1818
MockedObject as JestMockedObject,
19+
Replaced as JestReplaced,
1920
Spied as JestSpied,
2021
SpiedClass as JestSpiedClass,
2122
SpiedFunction as JestSpiedFunction,
@@ -63,6 +64,10 @@ declare namespace jest {
6364
* Wraps an object type with Jest mock type definitions.
6465
*/
6566
export type MockedObject<T extends object> = JestMockedObject<T>;
67+
/**
68+
* Constructs the type of a replaced property.
69+
*/
70+
export type Replaced<T> = JestReplaced<T>;
6671
/**
6772
* Constructs the type of a spied class or function.
6873
*/

packages/jest-mock/__typetests__/mock-functions.test.ts

+70
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ import {
1515
} from 'tsd-lite';
1616
import {
1717
Mock,
18+
Replaced,
1819
SpiedClass,
1920
SpiedFunction,
2021
SpiedGetter,
2122
SpiedSetter,
2223
fn,
24+
replaceProperty,
2325
spyOn,
2426
} from 'jest-mock';
2527

@@ -492,3 +494,71 @@ expectError(
492494
(key: string, value: number) => {},
493495
),
494496
);
497+
498+
// replaceProperty + Replaced
499+
500+
const obj = {
501+
fn: () => {},
502+
503+
property: 1,
504+
};
505+
506+
expectType<Replaced<number>>(replaceProperty(obj, 'property', 1));
507+
expectType<void>(replaceProperty(obj, 'property', 1).replaceValue(1).restore());
508+
509+
expectError(replaceProperty(obj, 'invalid', 1));
510+
expectError(replaceProperty(obj, 'property', 'not a number'));
511+
expectError(replaceProperty(obj, 'fn', () => {}));
512+
513+
expectError(replaceProperty(obj, 'property', 1).replaceValue('not a number'));
514+
515+
interface ComplexObject {
516+
numberOrUndefined: number | undefined;
517+
optionalString?: string;
518+
multipleTypes: number | string | {foo: number} | null;
519+
}
520+
declare const complexObject: ComplexObject;
521+
522+
interface ObjectWithDynamicProperties {
523+
[key: string]: boolean;
524+
}
525+
declare const objectWithDynamicProperties: ObjectWithDynamicProperties;
526+
527+
// Resulting type should retain the original property type
528+
expectType<Replaced<number | undefined>>(
529+
replaceProperty(complexObject, 'numberOrUndefined', undefined),
530+
);
531+
expectType<Replaced<number | undefined>>(
532+
replaceProperty(complexObject, 'numberOrUndefined', 1),
533+
);
534+
535+
expectError(
536+
replaceProperty(
537+
complexObject,
538+
'numberOrUndefined',
539+
'string is not valid TypeScript type',
540+
),
541+
);
542+
543+
expectType<Replaced<string | undefined>>(
544+
replaceProperty(complexObject, 'optionalString', 'foo'),
545+
);
546+
expectType<Replaced<string | undefined>>(
547+
replaceProperty(complexObject, 'optionalString', undefined),
548+
);
549+
550+
expectType<Replaced<boolean>>(
551+
replaceProperty(objectWithDynamicProperties, 'dynamic prop 1', true),
552+
);
553+
expectError(
554+
replaceProperty(objectWithDynamicProperties, 'dynamic prop 1', undefined),
555+
);
556+
557+
expectError(replaceProperty(complexObject, 'not a property', undefined));
558+
559+
expectType<Replaced<ComplexObject['multipleTypes']>>(
560+
replaceProperty(complexObject, 'multipleTypes', 1)
561+
.replaceValue('foo')
562+
.replaceValue({foo: 1})
563+
.replaceValue(null),
564+
);

0 commit comments

Comments
 (0)