Skip to content

Commit 35e8b6a

Browse files
authored
feat(@jest/mock): Add withImplementation (#13281)
1 parent 2e608c1 commit 35e8b6a

File tree

9 files changed

+144
-2
lines changed

9 files changed

+144
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- `[@jest/environment, jest-runtime]` Allow `jest.requireActual` and `jest.requireMock` to take a type argument ([#13253](https://github.com/facebook/jest/pull/13253))
88
- `[@jest/environment]` Allow `jest.mock` and `jest.doMock` to take a type argument ([#13254](https://github.com/facebook/jest/pull/13254))
99
- `[@jest/fake-timers]` Add `jest.now()` to return the current fake clock time ([#13244](https://github.com/facebook/jest/pull/13244), [13246](https://github.com/facebook/jest/pull/13246))
10+
- `[@jest/mock]` Add `withImplementation` method for temporarily overriding a mock.
1011

1112
### Fixes
1213

docs/MockFunctionAPI.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,43 @@ test('async test', async () => {
478478
});
479479
```
480480

481+
### `mockFn.withImplementation(fn, callback)`
482+
483+
Accepts a function which should be temporarily used as the implementation of the mock while the callback is being executed.
484+
485+
```js
486+
test('test', () => {
487+
const mock = jest.fn(() => 'outside callback');
488+
489+
mock.withImplementation(
490+
() => 'inside callback',
491+
() => {
492+
mock(); // 'inside callback'
493+
},
494+
);
495+
496+
mock(); // 'outside callback'
497+
});
498+
```
499+
500+
`mockFn.withImplementation` can be used regardless of whether or not the callback is asynchronous (returns a `thenable`). If the callback is asynchronous a promise will be returned. Awaiting the promise will await the callback and reset the implementation.
501+
502+
```js
503+
test('async test', async () => {
504+
const mock = jest.fn(() => 'outside callback');
505+
506+
// We await this call since the callback is async
507+
await mock.withImplementation(
508+
() => 'inside callback',
509+
async () => {
510+
mock(); // 'inside callback'
511+
},
512+
);
513+
514+
mock(); // 'outside callback'
515+
});
516+
```
517+
481518
## TypeScript Usage
482519

483520
:::tip

packages/jest-mock/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,9 @@ In case both `.mockImplementationOnce()` / `.mockImplementation()` and `.mockRet
9898

9999
- if the last call is `.mockReturnValueOnce()` or `.mockReturnValue()`, use the specific return value or default return value. If specific return values are used up or no default return value is set, fall back to try `.mockImplementation()`;
100100
- if the last call is `.mockImplementationOnce()` or `.mockImplementation()`, run the specific implementation and return the result or run default implementation and return the result.
101+
102+
##### `.withImplementation(function, callback)`
103+
104+
Temporarily overrides the default mock implementation within the callback, then restores it's previous implementation.
105+
106+
If the callback is async or returns a `thenable`, `withImplementation` will return a promise. Awaiting the promise will await the callback and reset the implementation.

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,13 @@ expectType<Mock<() => Promise<string>>>(
250250
);
251251
expectError(fn(() => Promise.resolve('')).mockRejectedValueOnce());
252252

253+
expectType<void>(mockFn.withImplementation(mockFnImpl, () => {}));
254+
expectType<Promise<void>>(
255+
mockFn.withImplementation(mockFnImpl, async () => {}),
256+
);
257+
258+
expectError(mockFn.withImplementation(mockFnImpl));
259+
253260
// jest.spyOn()
254261

255262
const spiedArray = ['a', 'b'];

packages/jest-mock/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
},
1919
"dependencies": {
2020
"@jest/types": "workspace:^",
21-
"@types/node": "*"
21+
"@types/node": "*",
22+
"jest-util": "workspace:^"
2223
},
2324
"devDependencies": {
2425
"@tsd/typescript": "~4.8.2",

packages/jest-mock/src/__tests__/index.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
/* eslint-disable local/ban-types-eventually, local/prefer-rest-params-eventually */
1010

11+
import * as util from 'util';
1112
import vm, {Context} from 'vm';
1213
import {ModuleMocker, fn, mocked, spyOn} from '../';
1314

@@ -1073,6 +1074,62 @@ describe('moduleMocker', () => {
10731074
});
10741075
});
10751076

1077+
describe('withImplementation', () => {
1078+
it('sets an implementation which is available within the callback', () => {
1079+
const mock1 = jest.fn();
1080+
const mock2 = jest.fn();
1081+
1082+
const Module = jest.fn(() => ({someFn: mock1}));
1083+
const testFn = function () {
1084+
const m = new Module();
1085+
m.someFn();
1086+
};
1087+
1088+
Module.withImplementation(
1089+
() => ({someFn: mock2}),
1090+
() => {
1091+
testFn();
1092+
expect(mock2).toHaveBeenCalled();
1093+
expect(mock1).not.toHaveBeenCalled();
1094+
},
1095+
);
1096+
1097+
testFn();
1098+
expect(mock1).toHaveBeenCalled();
1099+
1100+
expect.assertions(3);
1101+
});
1102+
1103+
it('returns a promise if the provided callback is asynchronous', async () => {
1104+
const mock1 = jest.fn();
1105+
const mock2 = jest.fn();
1106+
1107+
const Module = jest.fn(() => ({someFn: mock1}));
1108+
const testFn = function () {
1109+
const m = new Module();
1110+
m.someFn();
1111+
};
1112+
1113+
const promise = Module.withImplementation(
1114+
() => ({someFn: mock2}),
1115+
async () => {
1116+
testFn();
1117+
expect(mock2).toHaveBeenCalled();
1118+
expect(mock1).not.toHaveBeenCalled();
1119+
},
1120+
);
1121+
1122+
expect(util.types.isPromise(promise)).toBe(true);
1123+
1124+
await promise;
1125+
1126+
testFn();
1127+
expect(mock1).toHaveBeenCalled();
1128+
1129+
expect.assertions(4);
1130+
});
1131+
});
1132+
10761133
test('mockReturnValue does not override mockImplementationOnce', () => {
10771134
const mockFn = jest
10781135
.fn()

packages/jest-mock/src/index.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
/* eslint-disable local/ban-types-eventually, local/prefer-rest-params-eventually */
99

10+
import {isPromise} from 'jest-util';
11+
1012
export type MockMetadataType =
1113
| 'object'
1214
| 'array'
@@ -135,6 +137,8 @@ export interface MockInstance<T extends FunctionLike = UnknownFunction> {
135137
mockRestore(): void;
136138
mockImplementation(fn: T): this;
137139
mockImplementationOnce(fn: T): this;
140+
withImplementation(fn: T, callback: () => Promise<unknown>): Promise<void>;
141+
withImplementation(fn: T, callback: () => void): void;
138142
mockName(name: string): this;
139143
mockReturnThis(): this;
140144
mockReturnValue(value: ReturnType<T>): this;
@@ -768,6 +772,34 @@ export class ModuleMocker {
768772
return f;
769773
};
770774

775+
f.withImplementation = withImplementation.bind(this);
776+
777+
function withImplementation(fn: T, callback: () => void): void;
778+
function withImplementation(
779+
fn: T,
780+
callback: () => Promise<unknown>,
781+
): Promise<void>;
782+
function withImplementation(
783+
this: ModuleMocker,
784+
fn: T,
785+
callback: (() => void) | (() => Promise<unknown>),
786+
): void | Promise<void> {
787+
// Remember previous mock implementation, then set new one
788+
const mockConfig = this._ensureMockConfig(f);
789+
const previousImplementation = mockConfig.mockImpl;
790+
mockConfig.mockImpl = fn;
791+
792+
const returnedValue = callback();
793+
794+
if (isPromise(returnedValue)) {
795+
return returnedValue.then(() => {
796+
mockConfig.mockImpl = previousImplementation;
797+
});
798+
} else {
799+
mockConfig.mockImpl = previousImplementation;
800+
}
801+
}
802+
771803
f.mockImplementation = (fn: UnknownFunction) => {
772804
// next function call will use mock implementation return value
773805
const mockConfig = this._ensureMockConfig(f);

packages/jest-mock/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@
66
},
77
"include": ["./src/**/*"],
88
"exclude": ["./**/__tests__/**/*"],
9-
"references": [{"path": "../jest-types"}]
9+
"references": [{"path": "../jest-types"}, {"path": "../jest-util"}]
1010
}

yarn.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12578,6 +12578,7 @@ __metadata:
1257812578
"@jest/types": "workspace:^"
1257912579
"@tsd/typescript": ~4.8.2
1258012580
"@types/node": "*"
12581+
jest-util: "workspace:^"
1258112582
tsd-lite: ^0.6.0
1258212583
languageName: unknown
1258312584
linkType: soft

0 commit comments

Comments
 (0)