Skip to content

Commit 63db50f

Browse files
authored
feat: add support for Explicit Resource Management to mocked functions (#14895)
1 parent 9914dc4 commit 63db50f

File tree

11 files changed

+215
-4
lines changed

11 files changed

+215
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
- `[jest-environment-node]` Update jest environment with dispose symbols `Symbol` ([#14888](https://github.com/jestjs/jest/pull/14888) & [#14909](https://github.com/jestjs/jest/pull/14909))
1919
- `[@jest/fake-timers]` [**BREAKING**] Upgrade `@sinonjs/fake-timers` to v11 ([#14544](https://github.com/jestjs/jest/pull/14544))
2020
- `[@jest/fake-timers]` Exposing new modern timers function `advanceTimersToFrame()` which advances all timers by the needed milliseconds to execute callbacks currently scheduled with `requestAnimationFrame` ([#14598](https://github.com/jestjs/jest/pull/14598))
21+
- `[jest-mock]` Add support for the Explicit Resource Management proposal to use the `using` keyword with `jest.spyOn(object, methodName)` ([#14895](https://github.com/jestjs/jest/pull/14895))
2122
- `[jest-runtime]` Exposing new modern timers function `jest.advanceTimersToFrame()` from `@jest/fake-timers` ([#14598](https://github.com/jestjs/jest/pull/14598))
2223
- `[jest-runtime]` Support `import.meta.filename` and `import.meta.dirname` (available from [Node 20.11](https://nodejs.org/en/blog/release/v20.11.0))
2324
- `[@jest/schemas]` Upgrade `@sinclair/typebox` to v0.31 ([#14072](https://github.com/jestjs/jest/pull/14072))

docs/JestObjectAPI.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,64 @@ test('plays video', () => {
710710
});
711711
```
712712

713+
#### Spied methods and the `using` keyword
714+
715+
If your codebase is set up to transpile the ["explicit resource management"](https://github.com/tc39/proposal-explicit-resource-management) (e.g. if you are using TypeScript >= 5.2 or the `@babel/plugin-proposal-explicit-resource-management` plugin), you can use `spyOn` in combination with the `using` keyword:
716+
717+
```js
718+
test('logs a warning', () => {
719+
using spy = jest.spyOn(console.warn);
720+
doSomeThingWarnWorthy();
721+
expect(spy).toHaveBeenCalled();
722+
});
723+
```
724+
725+
That code is semantically equal to
726+
727+
```js
728+
test('logs a warning', () => {
729+
let spy;
730+
try {
731+
spy = jest.spyOn(console.warn);
732+
doSomeThingWarnWorthy();
733+
expect(spy).toHaveBeenCalled();
734+
} finally {
735+
spy.mockRestore();
736+
}
737+
});
738+
```
739+
740+
That way, your spy will automatically be restored to the original value once the current code block is left.
741+
742+
You can even go a step further and use a code block to restrict your mock to only a part of your test without hurting readability.
743+
744+
```js
745+
test('testing something', () => {
746+
{
747+
using spy = jest.spyOn(console.warn);
748+
setupStepThatWillLogAWarning();
749+
}
750+
// here, console.warn is already restored to the original value
751+
// your test can now continue normally
752+
});
753+
```
754+
755+
:::note
756+
757+
If you get a warning that `Symbol.dispose` does not exist, you might need to polyfill that, e.g. with this code:
758+
759+
```js
760+
if (!Symbol.dispose) {
761+
Object.defineProperty(Symbol, 'dispose', {
762+
get() {
763+
return Symbol.for('nodejs.dispose');
764+
},
765+
});
766+
}
767+
```
768+
769+
:::
770+
713771
### `jest.spyOn(object, methodName, accessType?)`
714772

715773
Since Jest 22.1.0+, the `jest.spyOn` method takes an optional third argument of `accessType` that can be either `'get'` or `'set'`, which proves to be useful when you want to spy on a getter or a setter, respectively.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import {resolve} from 'path';
9+
import {onNodeVersions} from '@jest/test-utils';
10+
import {runYarnInstall} from '../Utils';
11+
import runJest from '../runJest';
12+
13+
const DIR = resolve(__dirname, '../explicit-resource-management');
14+
15+
beforeAll(() => {
16+
runYarnInstall(DIR);
17+
});
18+
19+
onNodeVersions('^18.18.0 || >=20.4.0', () => {
20+
test('Explicit resource management is supported', () => {
21+
const result = runJest(DIR);
22+
expect(result.exitCode).toBe(0);
23+
});
24+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
const TestClass = require('../');
9+
const localClass = new TestClass();
10+
11+
it('restores a mock after a test if it is mocked with a `using` declaration', () => {
12+
using mock = jest.spyOn(localClass, 'test').mockImplementation(() => 'ABCD');
13+
expect(localClass.test()).toBe('ABCD');
14+
expect(localClass.test).toHaveBeenCalledTimes(1);
15+
expect(jest.isMockFunction(localClass.test)).toBeTruthy();
16+
});
17+
18+
it('only sees the unmocked class', () => {
19+
expect(localClass.test()).toBe('12345');
20+
expect(localClass.test.mock).toBeUndefined();
21+
expect(jest.isMockFunction(localClass.test)).toBeFalsy();
22+
});
23+
24+
test('also works just with scoped code blocks', () => {
25+
const scopedInstance = new TestClass();
26+
{
27+
using mock = jest
28+
.spyOn(scopedInstance, 'test')
29+
.mockImplementation(() => 'ABCD');
30+
expect(scopedInstance.test()).toBe('ABCD');
31+
expect(scopedInstance.test).toHaveBeenCalledTimes(1);
32+
expect(jest.isMockFunction(scopedInstance.test)).toBeTruthy();
33+
}
34+
expect(scopedInstance.test()).toBe('12345');
35+
expect(scopedInstance.test.mock).toBeUndefined();
36+
expect(jest.isMockFunction(scopedInstance.test)).toBeFalsy();
37+
});
38+
39+
it('jest.fn state should be restored with the `using` keyword', () => {
40+
const mock = jest.fn();
41+
{
42+
using inScope = mock.mockReturnValue(2);
43+
expect(inScope()).toBe(2);
44+
expect(mock()).toBe(2);
45+
}
46+
expect(mock()).not.toBe(2);
47+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
module.exports = {
9+
plugins: ['@babel/plugin-proposal-explicit-resource-management'],
10+
};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
module.exports = class Test {
9+
test() {
10+
return '12345';
11+
}
12+
};
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"jest": {
3+
"testEnvironment": "node"
4+
},
5+
"dependencies": {
6+
"@babel/plugin-proposal-explicit-resource-management": "^7.23.9"
7+
}
8+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# This file is generated by running "yarn install" inside your project.
2+
# Manual changes might be lost - proceed with caution!
3+
4+
__metadata:
5+
version: 6
6+
cacheKey: 8
7+
8+
"@babel/helper-plugin-utils@npm:^7.22.5":
9+
version: 7.22.5
10+
resolution: "@babel/helper-plugin-utils@npm:7.22.5"
11+
checksum: c0fc7227076b6041acd2f0e818145d2e8c41968cc52fb5ca70eed48e21b8fe6dd88a0a91cbddf4951e33647336eb5ae184747ca706817ca3bef5e9e905151ff5
12+
languageName: node
13+
linkType: hard
14+
15+
"@babel/plugin-proposal-explicit-resource-management@npm:^7.23.9":
16+
version: 7.23.9
17+
resolution: "@babel/plugin-proposal-explicit-resource-management@npm:7.23.9"
18+
dependencies:
19+
"@babel/helper-plugin-utils": ^7.22.5
20+
"@babel/plugin-syntax-explicit-resource-management": ^7.23.3
21+
peerDependencies:
22+
"@babel/core": ^7.0.0-0
23+
checksum: d7a37ea28178e251fe289895cf4a37fee47195122a3e172eb088be9b0a55d16d2b2ac3cd6569e9f94c9f9a7744a812f3eba50ec64e3d8f7a48a4e2b0f2caa959
24+
languageName: node
25+
linkType: hard
26+
27+
"@babel/plugin-syntax-explicit-resource-management@npm:^7.23.3":
28+
version: 7.23.3
29+
resolution: "@babel/plugin-syntax-explicit-resource-management@npm:7.23.3"
30+
dependencies:
31+
"@babel/helper-plugin-utils": ^7.22.5
32+
peerDependencies:
33+
"@babel/core": ^7.0.0-0
34+
checksum: 60306808e4680b180a2945d13d4edc7aba91bbd43b300271b89ebd3d3d0bc60f97c6eb7eaa7b9e2f7b61bb0111c24469846f636766517da5385351957c264eb9
35+
languageName: node
36+
linkType: hard
37+
38+
"root-workspace-0b6124@workspace:.":
39+
version: 0.0.0-use.local
40+
resolution: "root-workspace-0b6124@workspace:."
41+
dependencies:
42+
"@babel/plugin-proposal-explicit-resource-management": ^7.23.9
43+
languageName: unknown
44+
linkType: soft

packages/jest-environment-node/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,9 @@ export default class NodeEnvironment implements JestEnvironment<Timer> {
155155
if ('asyncDispose' in Symbol && !('asyncDispose' in global.Symbol)) {
156156
const globalSymbol = global.Symbol as unknown as SymbolConstructor;
157157
// @ts-expect-error - it's readonly - but we have checked above that it's not there
158-
globalSymbol.asyncDispose = globalSymbol('nodejs.asyncDispose');
158+
globalSymbol.asyncDispose = globalSymbol.for('nodejs.asyncDispose');
159159
// @ts-expect-error - it's readonly - but we have checked above that it's not there
160-
globalSymbol.dispose = globalSymbol('nodejs.dispose');
160+
globalSymbol.dispose = globalSymbol.for('nodejs.dispose');
161161
}
162162

163163
// Node's error-message stack size is limited at 10, but it's pretty useful

packages/jest-mock/src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8+
/// <reference lib="ESNext.Disposable" />
9+
810
/* eslint-disable local/ban-types-eventually, local/prefer-rest-params-eventually */
911

1012
import {isPromise} from 'jest-util';
@@ -131,7 +133,8 @@ type ResolveType<T extends FunctionLike> =
131133
type RejectType<T extends FunctionLike> =
132134
ReturnType<T> extends PromiseLike<any> ? unknown : never;
133135

134-
export interface MockInstance<T extends FunctionLike = UnknownFunction> {
136+
export interface MockInstance<T extends FunctionLike = UnknownFunction>
137+
extends Disposable {
135138
_isMockFunction: true;
136139
_protoImpl: Function;
137140
getMockImplementation(): T | undefined;
@@ -797,6 +800,9 @@ export class ModuleMocker {
797800
};
798801

799802
f.withImplementation = withImplementation.bind(this);
803+
if (Symbol.dispose) {
804+
f[Symbol.dispose] = f.mockRestore;
805+
}
800806

801807
function withImplementation(fn: T, callback: () => void): void;
802808
function withImplementation(

0 commit comments

Comments
 (0)