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: Allow mocking property value in tests #13496

Merged
merged 22 commits into from Jan 4, 2023
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
15697c7
feat(jest-mock): Add mockProperty() method
Oct 23, 2022
9c270ca
feat(jest-environment): Add mockProperty() method type to global Jest…
Oct 23, 2022
20b881b
refactor(jest-mock): Be more strict for MockedPropertyRestorer generi…
Dec 19, 2022
930036d
refactor(jest-mock): Allow mocking property named '0' or ""
Dec 19, 2022
c739864
refactor(jest-mock): Throw more descriptive error messages when tryin…
Dec 19, 2022
0f227c2
refactor(jest-mock): Allow mocking already mocked property with diffe…
Dec 19, 2022
1c4535b
refactor(jest-mock): Add type tests for mockProperty
Dec 19, 2022
4a1aeb6
refactor(jest-runtime): Fix missing mockProperty export
Dec 19, 2022
c79837d
Merge remote-tracking branch 'upstream/main'
Dec 19, 2022
cceffa0
refactor(jest-mock): Fix typing and interface of mockProperty methods
Dec 20, 2022
6c17cc0
refactor(jest-mock, docs): Document replaceProperty method and its im…
Dec 20, 2022
d828f11
refactor(docs): Remove forgotten TODO
Dec 20, 2022
4f9ac47
refactor(jest-mock, jest-types): Add additional tests for replaced pr…
Dec 25, 2022
b3fb383
refactor(jest-environment, jest-globals): Fix JSDoc comments for repl…
Dec 25, 2022
208df4d
refactor(docs): Improve style of replaced property sections and apply…
Dec 25, 2022
2ed2ca8
Merge branch 'main' into main
SimenB Jan 3, 2023
ba36a3b
Update docs/MockFunctionAPI.md
SimenB Jan 3, 2023
25d1b24
Merge remote-tracking branch 'upstream/main'
Jan 3, 2023
472841c
refactor(jest-mock): Fix type tests compatibility with TS 4.3
Jan 3, 2023
2f9c9c9
refactor(jest-runtime): Fix forgotten rename of replaceProperty from …
Jan 3, 2023
33c30b9
refactor(jest-mock): Hint to use replaceProperty when trying to mock …
Jan 3, 2023
f0ffae1
refactor(docs): Relate two files in examples by providing correct path
Jan 3, 2023
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 @@ -2,6 +2,7 @@

### Features

- `[@jest/globals, jest-mock]` Add `jest.replaceProperty()` that replaces property value ([#13496](https://github.com/facebook/jest/pull/13496))
- `[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))
- `[jest-runtime]` Add `jest.isolateModulesAsync` for scoped module initialization of asynchronous functions ([#13680](https://github.com/facebook/jest/pull/13680))
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', () => {
SimenB marked this conversation as resolved.
Show resolved Hide resolved
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)`
michal-kocarek marked this conversation as resolved.
Show resolved Hide resolved

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
import {afterEach, expect, it, jest} from '@jest/globals';
import {isLocalhost} from '../utils';
michal-kocarek marked this conversation as resolved.
Show resolved Hide resolved
michal-kocarek marked this conversation as resolved.
Show resolved Hide resolved

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'});
Copy link
Contributor

Choose a reason for hiding this comment

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

question: Why do we replace all of process.env instead of replacing only the value we're interested in i.e. why not

Suggested change
replacedEnv = jest.replaceProperty(process, 'env', {HOSTNAME: 'localhost'});
replacedEnv = jest.replaceProperty(process.env, 'HOSTNAME', 'localhost');

instead?

Copy link
Member

Choose a reason for hiding this comment

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

yeah, that seems better, although then replacedEnv seems wrong?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This opens a design question. One cannot right now replace property that does not exist on an object. Assuming 'HOSTNAME' might or might not be in the env... What should we do in this case?

The Sinon.js implementation forbids creation of new properties. It must exist on an object (or in the prototype chain). I applied same restriction here.

What do you think, @SimenB , @mrazauskas and @eps1lon ? Should we allow adding undefined properties to the object (which is actually quite simple to do), or should be more restrictive (and possibly open this behavior in future). I do not have strong thoughts here...

Copy link
Contributor

@eps1lon eps1lon Jan 3, 2023

Choose a reason for hiding this comment

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

One cannot right now replace property that does not exist on an object. Assuming 'HOSTNAME' might or might not be in the env... What should we do in this case?

Hm good point. I would expect that we can mock non-existing properties. But can see how replaceProperty might be misleading. But again, from my intuition, we should be able to mock non-existing properties.

Copy link
Member

Choose a reason for hiding this comment

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

we could have a separate options bag ({mustExist: true} by default or something) that allows customization? But I think by default we should not allow it - if nothing else to catch typos (TS might help of course).

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah that works for our use case. And the process.env use case probably also works with a process.env.HOST_NAME = undefined during setup since I haven't seen any use case where you differentiate between unknown property and undefined in process.env

Copy link
Contributor Author

Choose a reason for hiding this comment

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

But again, from my intuition, we should be able to mock non-existing properties.

@eps1lon: I am a bit afraid of typos or invalid usages. People might be mocking stuff:

  • that is not used due to typo,
  • that exists now, but after upgrade of a library disappears (e.g. mocking private handle of some database client instance),
  • due to error - misinterpreting API documentation and trying to mock property for different class.

And if one thinks about it... How common/rare is the use case when we need to mock something that does not exist? I can think of a case when people might want to mock either a new key of "dictionary" or some new array element. In that case, they should mock the parent anyway I guess... In same manner like the process.env. E.g.:

// Instead of
jest.replaceProperty(httpAgent.defaultHeaders, 'X-API-Key', mockKey);

// Use
jest.replaceProperty(httpAgent, 'defaultHeaders', {...httpAgent.defaultHeaders, 'X-API-Key': mockKey});
  • ❓ If we keep current behavior, should I mention in documentation how to work with undefined propeties - e.g. in process.env? This might be quite common case.

...separate options bag ({mustExist: true} by default... by default we should not allow it - if nothing else to catch typos (TS might help of course).

@SimenB This makes sense. How should I proceed?

  • ❓ Should I implement the optional ability to replace undeclared properties?
  • If yes, what is best name for the option inside property bag? (mustExist: true / allowUndefined: false / allowNonexistent: false / ...? I am fine with mustExist)

Copy link
Member

@SimenB SimenB Jan 4, 2023

Choose a reason for hiding this comment

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

We can do that in a followup to allow mocking non-existing props. But if you wanna get started right away that'd be awesome 😀

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, this makes sense. I will implement it as separate PR, as this is already growing quite a lot. :)


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
64 changes: 64 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,65 @@ expectError(
(key: string, value: number) => {},
),
);

// replaceProperty + Replaced
michal-kocarek marked this conversation as resolved.
Show resolved Hide resolved

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;
[key: `dynamic prop ${number}`]: boolean;
multipleTypes: number | string | {foo: number} | null;
}
declare const complexObject: ComplexObject;

// 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(complexObject, 'dynamic prop 1', true),
);
expectError(replaceProperty(complexObject, '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),
);