Skip to content

Commit 692c748

Browse files
feat(valid-expect-with-promise): add checkThenables option for custom promises (#1996)
* fix(valid-expect-with-promise): support promise types not declared in default libs * refactor(valid-expect-with-promise): update rule to align with no-floating-promises
1 parent 4d690c3 commit 692c748

4 files changed

Lines changed: 257 additions & 11 deletions

File tree

docs/rules/valid-expect-with-promise.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,65 @@ expect('hello world').toBe('hello sunshine');
4141

4242
expect(new Promise(r => r(0))).rejects.toThrow('oh noes!');
4343
```
44+
45+
## Options
46+
47+
```json
48+
{
49+
"jest/valid-expect-with-promise": [
50+
"error",
51+
{
52+
"checkThenables": false
53+
}
54+
]
55+
}
56+
```
57+
58+
### `checkThenables`
59+
60+
Default: `false`
61+
62+
A
63+
["Thenable"](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#thenables)
64+
value is an object which has a `then` method, such as a `Promise`. Other
65+
Thenables include TypeScript's built-in `PromiseLike` interface and any custom
66+
object that happens to have a `.then()`.
67+
68+
The `checkThenables` option triggers `valid-expect-with-promise` to also
69+
consider all values that satisfy the Thenable shape (a `.then()` method that
70+
takes two callback parameters), not just Promises. This can be useful if your
71+
code works with older `Promise` polyfills instead of the native `Promise` class,
72+
or in environments where the global `Promise` is not declared by TypeScript's
73+
default libraries (such as with `noLib`).
74+
75+
Examples of **incorrect** code when `checkThenables` is `true`:
76+
77+
```ts
78+
declare function createPromiseLike(): PromiseLike<string>;
79+
80+
expect(createPromiseLike()).toBe('hello sunshine');
81+
82+
interface MyThenable {
83+
then(onFulfilled: () => void, onRejected: () => void): MyThenable;
84+
}
85+
86+
declare function createMyThenable(): MyThenable;
87+
88+
expect(createMyThenable()).toBe('hello sunshine');
89+
```
90+
91+
Examples of **correct** code when `checkThenables` is `true`:
92+
93+
```ts
94+
declare function createPromiseLike(): PromiseLike<string>;
95+
96+
await expect(createPromiseLike()).resolves.toBe('hello sunshine');
97+
98+
interface MyThenable {
99+
then(onFulfilled: () => void, onRejected: () => void): MyThenable;
100+
}
101+
102+
declare function createMyThenable(): MyThenable;
103+
104+
await expect(createMyThenable()).resolves.toBe('hello sunshine');
105+
```

src/rules/__tests__/valid-expect-with-promise.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ describe('error handling', () => {
3636
});
3737
});
3838

39+
const customPromiseDeclaration = dedent`
40+
declare class CustomPromise<T> {
41+
then<R>(
42+
onFulfilled?: (value: T) => R,
43+
onRejected?: (error: unknown) => R,
44+
): CustomPromise<R>;
45+
}
46+
47+
declare const promised: CustomPromise<string>;
48+
`;
49+
3950
ruleTester.run('valid-expect-with-promise', requireRule(false), {
4051
valid: withFixtureFilename([
4152
'expect',
@@ -74,6 +85,67 @@ ruleTester.run('valid-expect-with-promise', requireRule(false), {
7485
`,
7586
'<T extends Promise<unknown> = Promise<string>>(v: T) => expect(v).resolves.toThrow()',
7687
'<T = string>(v: T) => expect(v).toBe(1)',
88+
{
89+
code: dedent`
90+
${customPromiseDeclaration}
91+
92+
it('works', async () => {
93+
await expect(promised).resolves.toBe('value');
94+
});
95+
`,
96+
options: [{ checkThenables: true }],
97+
},
98+
// thenables are not treated as promises unless checkThenables is enabled
99+
dedent`
100+
${customPromiseDeclaration}
101+
102+
expect(promised).toEqual(1);
103+
`,
104+
'expect({ then: 1 }).toBe(1)',
105+
{
106+
code: 'expect({ then: 1 }).toBe(1)',
107+
options: [{ checkThenables: true }],
108+
},
109+
{
110+
code: 'expect(1).toBe(1)',
111+
options: [{ checkThenables: true }],
112+
},
113+
'expect().toBe(1)',
114+
dedent`
115+
declare class Chainable {
116+
then(next: string): this;
117+
}
118+
119+
declare const chain: Chainable;
120+
121+
expect(chain).toEqual(chain);
122+
`,
123+
{
124+
code: dedent`
125+
declare class Chainable {
126+
then(next: string): this;
127+
}
128+
129+
declare const chain: Chainable;
130+
131+
expect(chain).toEqual(chain);
132+
`,
133+
options: [{ checkThenables: true }],
134+
},
135+
// a `then` accepting only a fulfillment callback (like Cypress) is not
136+
// enough to be considered thenable - a rejection callback is required too
137+
{
138+
code: dedent`
139+
declare class Thenish<T> {
140+
then<R>(onFulfilled?: (value: T) => R): Thenish<R>;
141+
}
142+
143+
declare const thenish: Thenish<string>;
144+
145+
expect(thenish).toEqual(1);
146+
`,
147+
options: [{ checkThenables: true }],
148+
},
77149
]),
78150
invalid: withFixtureFilename([
79151
{
@@ -181,6 +253,38 @@ ruleTester.run('valid-expect-with-promise', requireRule(false), {
181253
},
182254
],
183255
},
256+
{
257+
code: dedent`
258+
${customPromiseDeclaration}
259+
260+
expect(promised).toEqual(1);
261+
`,
262+
options: [{ checkThenables: true }],
263+
errors: [
264+
{
265+
messageId: 'poorlyExpectedPromise',
266+
line: 10,
267+
},
268+
],
269+
},
270+
// without checkThenables, a thenable is not considered a promise, so
271+
// using resolves or rejects on one is reported as unneeded
272+
{
273+
code: dedent`
274+
${customPromiseDeclaration}
275+
276+
it('works', async () => {
277+
await expect(promised).resolves.toBe('value');
278+
});
279+
`,
280+
errors: [
281+
{
282+
messageId: 'unneededRejectResolve',
283+
data: { modifier: 'resolves' },
284+
line: 11,
285+
},
286+
],
287+
},
184288
// technically this is valid, but we choose not to give it special treatment
185289
// as it should be very rare and doing so could give a lot of false negatives
186290
{

src/rules/utils/ts.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import type ts from 'typescript';
33
let SymbolFlags: typeof ts.SymbolFlags;
44
let TypeFlags: typeof ts.TypeFlags;
55

6+
function loadTypeScriptFlags(): void {
7+
// eslint-disable-next-line @typescript-eslint/no-require-imports
8+
({ TypeFlags, SymbolFlags } = require('typescript'));
9+
}
10+
611
function isSymbolFromDefaultLibrary(
712
program: ts.Program,
813
symbol: ts.Symbol,
@@ -28,8 +33,7 @@ export function isBuiltinSymbolLike(
2833
type: ts.Type,
2934
symbolName: string,
3035
): boolean {
31-
// eslint-disable-next-line @typescript-eslint/no-require-imports
32-
({ TypeFlags, SymbolFlags } = require('typescript'));
36+
loadTypeScriptFlags();
3337

3438
return isBuiltinSymbolLikeRecurser(program, type, subType => {
3539
const symbol = subType.getSymbol();
@@ -51,6 +55,61 @@ export function isBuiltinSymbolLike(
5155
});
5256
}
5357

58+
/**
59+
* Checks if the given type is thenable: it has a `then` method that accepts
60+
* both a fulfillment and a rejection callback, mirroring the check performed
61+
* by typescript-eslint's `no-floating-promises` rule.
62+
*/
63+
export function isThenableType(
64+
program: ts.Program,
65+
node: ts.Node,
66+
type: ts.Type,
67+
): boolean {
68+
loadTypeScriptFlags();
69+
70+
const checker = program.getTypeChecker();
71+
72+
return isBuiltinSymbolLikeRecurser(program, type, subType => {
73+
const then = subType.getProperty('then');
74+
75+
if (!then) {
76+
return false;
77+
}
78+
79+
return unionParts(checker.getTypeOfSymbolAtLocation(then, node)).some(
80+
thenType =>
81+
thenType.getCallSignatures().some(signature => {
82+
const [onFulfilled, onRejected] = signature.getParameters();
83+
84+
return (
85+
onFulfilled !== undefined &&
86+
onRejected !== undefined &&
87+
isFunctionParam(checker, node, onFulfilled) &&
88+
isFunctionParam(checker, node, onRejected)
89+
);
90+
}),
91+
);
92+
});
93+
}
94+
95+
function isFunctionParam(
96+
checker: ts.TypeChecker,
97+
node: ts.Node,
98+
param: ts.Symbol,
99+
): boolean {
100+
const paramType = checker.getApparentType(
101+
checker.getTypeOfSymbolAtLocation(param, node),
102+
);
103+
104+
return unionParts(paramType).some(
105+
subType => subType.getCallSignatures().length > 0,
106+
);
107+
}
108+
109+
function unionParts(type: ts.Type): ts.Type[] {
110+
return type.isUnion() ? type.types : [type];
111+
}
112+
54113
function isBuiltinSymbolLikeRecurser(
55114
program: ts.Program,
56115
type: ts.Type,

src/rules/valid-expect-with-promise.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@ import {
33
createRule,
44
getAccessorValue,
55
isBuiltinSymbolLike,
6+
isThenableType,
67
parseJestFnCall,
78
} from './utils';
89

910
export type MessageIds = 'poorlyExpectedPromise' | 'unneededRejectResolve';
1011

11-
export type Options = [];
12+
export type Options = [
13+
{
14+
checkThenables?: boolean;
15+
},
16+
];
1217

1318
export default createRule<Options, MessageIds>({
1419
name: __filename,
@@ -25,10 +30,18 @@ export default createRule<Options, MessageIds>({
2530
'Subject is not a promise so {{ modifier }} is not needed',
2631
},
2732
type: 'suggestion',
28-
schema: [],
33+
schema: [
34+
{
35+
type: 'object',
36+
properties: {
37+
checkThenables: { type: 'boolean' },
38+
},
39+
additionalProperties: false,
40+
},
41+
],
2942
},
30-
defaultOptions: [],
31-
create(context) {
43+
defaultOptions: [{ checkThenables: false }],
44+
create(context, [{ checkThenables }]) {
3245
const services = ESLintUtils.getParserServices(context);
3346

3447
return {
@@ -44,11 +57,19 @@ export default createRule<Options, MessageIds>({
4457

4558
const [argument] = jestFnCall.head.node.parent.arguments;
4659

47-
const isPromiseLike = isBuiltinSymbolLike(
48-
services.program,
49-
services.getTypeAtLocation(argument),
50-
'Promise',
51-
);
60+
const argumentType = services.getTypeAtLocation(argument);
61+
62+
// `isBuiltinSymbolLike` only recognises the `Promise` declared in
63+
// TypeScript's default libraries, so optionally check for any thenable
64+
// (e.g. custom promise types in `noLib` environments, or polyfills)
65+
const isPromiseLike =
66+
isBuiltinSymbolLike(services.program, argumentType, 'Promise') ||
67+
(checkThenables === true &&
68+
isThenableType(
69+
services.program,
70+
services.esTreeNodeToTSNodeMap.get(argument),
71+
argumentType,
72+
));
5273

5374
const promiseModifier = jestFnCall.modifiers.find(
5475
nod => getAccessorValue(nod) !== 'not',

0 commit comments

Comments
 (0)