Skip to content

Commit

Permalink
feat: Allow mocking property value in tests (#13496)
Browse files Browse the repository at this point in the history
  • Loading branch information
michal-kocarek committed Jan 4, 2023
1 parent a325f87 commit e77128b
Show file tree
Hide file tree
Showing 15 changed files with 689 additions and 10 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -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))
Expand Down
57 changes: 55 additions & 2 deletions docs/JestObjectAPI.md
Expand Up @@ -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));`

:::

Expand Down Expand Up @@ -713,6 +762,10 @@ test('plays audio', () => {
});
```

### `jest.Replaced<Source>`

See [TypeScript Usage](MockFunctionAPI.md#replacedpropertyreplacevaluevalue) chapter of Mock Functions page for documentation.

### `jest.Spied<Source>`

See [TypeScript Usage](MockFunctionAPI.md#jestspiedsource) chapter of Mock Functions page for documentation.
Expand All @@ -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

Expand Down
47 changes: 47 additions & 0 deletions docs/MockFunctionAPI.md
Expand Up @@ -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

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

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>`.

### `jest.Replaced<Source>`

The `jest.Replaced<Source>` 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<typeof process.env> | 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.
Expand Down
17 changes: 17 additions & 0 deletions 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);
});
3 changes: 3 additions & 0 deletions examples/manual-mocks/utils.js
@@ -0,0 +1,3 @@
export function isLocalhost() {
return process.env.HOSTNAME === 'localhost';
}
24 changes: 24 additions & 0 deletions 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<typeof process.env> | 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);
});
5 changes: 5 additions & 0 deletions 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';
}
12 changes: 10 additions & 2 deletions packages/jest-environment/src/index.ts
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions packages/jest-globals/src/index.ts
Expand Up @@ -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,
Expand Down Expand Up @@ -63,6 +64,10 @@ declare namespace jest {
* Wraps an object type with Jest mock type definitions.
*/
export type MockedObject<T extends object> = JestMockedObject<T>;
/**
* Constructs the type of a replaced property.
*/
export type Replaced<T> = JestReplaced<T>;
/**
* Constructs the type of a spied class or function.
*/
Expand Down
70 changes: 70 additions & 0 deletions packages/jest-mock/__typetests__/mock-functions.test.ts
Expand Up @@ -15,11 +15,13 @@ import {
} from 'tsd-lite';
import {
Mock,
Replaced,
SpiedClass,
SpiedFunction,
SpiedGetter,
SpiedSetter,
fn,
replaceProperty,
spyOn,
} from 'jest-mock';

Expand Down Expand Up @@ -492,3 +494,71 @@ expectError(
(key: string, value: number) => {},
),
);

// replaceProperty + Replaced

const obj = {
fn: () => {},

property: 1,
};

expectType<Replaced<number>>(replaceProperty(obj, 'property', 1));
expectType<void>(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<Replaced<number | undefined>>(
replaceProperty(complexObject, 'numberOrUndefined', undefined),
);
expectType<Replaced<number | undefined>>(
replaceProperty(complexObject, 'numberOrUndefined', 1),
);

expectError(
replaceProperty(
complexObject,
'numberOrUndefined',
'string is not valid TypeScript type',
),
);

expectType<Replaced<string | undefined>>(
replaceProperty(complexObject, 'optionalString', 'foo'),
);
expectType<Replaced<string | undefined>>(
replaceProperty(complexObject, 'optionalString', undefined),
);

expectType<Replaced<boolean>>(
replaceProperty(objectWithDynamicProperties, 'dynamic prop 1', true),
);
expectError(
replaceProperty(objectWithDynamicProperties, 'dynamic prop 1', undefined),
);

expectError(replaceProperty(complexObject, 'not a property', undefined));

expectType<Replaced<ComplexObject['multipleTypes']>>(
replaceProperty(complexObject, 'multipleTypes', 1)
.replaceValue('foo')
.replaceValue({foo: 1})
.replaceValue(null),
);

0 comments on commit e77128b

Please sign in to comment.