Skip to content

Commit 73acd08

Browse files
authored
feat(jest-circus): Fail tests if a test takes a done callback and have return values (#9129)
1 parent 295aedc commit 73acd08

File tree

8 files changed

+164
-24
lines changed

8 files changed

+164
-24
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Fixes
66

7+
- `[jest-circus]` [**BREAKING**] Fail tests if a test takes a done callback and have return values ([#9129](https://github.com/facebook/jest/pull/9129))
78
- `[jest-config, jest-resolve]` [**BREAKING**] Remove support for `browser` field ([#9943](https://github.com/facebook/jest/pull/9943))
89

910
### Chore & Maintenance
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`errors when a test both returns a promise and takes a callback 1`] = `
4+
FAIL __tests__/promise-and-callback.test.js
5+
✕ promise-returning test with callback
6+
✕ async test with callback
7+
✕ test done before return value
8+
9+
● promise-returning test with callback
10+
11+
Test functions cannot both take a 'done' callback and return something. Either use a 'done' callback, or return a promise.
12+
Returned value: Promise {}
13+
14+
8 | 'use strict';
15+
9 |
16+
> 10 | it('promise-returning test with callback', done => {
17+
| ^
18+
11 | done();
19+
12 |
20+
13 | return Promise.resolve();
21+
22+
at Object.it (__tests__/promise-and-callback.test.js:10:1)
23+
24+
async test with callback
25+
26+
Test functions cannot both take a 'done' callback and return something. Either use a 'done' callback, or return a promise.
27+
Returned value: Promise {}
28+
29+
14 | });
30+
15 |
31+
> 16 | it('async test with callback', async done => {
32+
| ^
33+
17 | done();
34+
18 | });
35+
19 |
36+
37+
at Object.it (__tests__/promise-and-callback.test.js:16:1)
38+
39+
● test done before return value
40+
41+
Test functions cannot both take a 'done' callback and return something. Either use a 'done' callback, or return a promise.
42+
Returned value: "foobar"
43+
44+
18 | });
45+
19 |
46+
> 20 | it('test done before return value', done => {
47+
| ^
48+
21 | done();
49+
22 |
50+
23 | return 'foobar';
51+
52+
at Object.it (__tests__/promise-and-callback.test.js:20:1)
53+
`;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
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 {skipSuiteOnJasmine} from '@jest/test-utils';
9+
import wrap from 'jest-snapshot-serializer-raw';
10+
import runJest from '../runJest';
11+
import {extractSummary} from '../Utils';
12+
13+
skipSuiteOnJasmine();
14+
15+
test('errors when a test both returns a promise and takes a callback', () => {
16+
const result = runJest('promise-and-callback');
17+
18+
const {rest} = extractSummary(result.stderr);
19+
expect(wrap(rest)).toMatchSnapshot();
20+
expect(result.exitCode).toBe(1);
21+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
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+
'use strict';
9+
10+
it('promise-returning test with callback', done => {
11+
done();
12+
13+
return Promise.resolve();
14+
});
15+
16+
it('async test with callback', async done => {
17+
done();
18+
});
19+
20+
it('test done before return value', done => {
21+
done();
22+
23+
return 'foobar';
24+
});

e2e/promise-and-callback/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"jest": {
3+
"testEnvironment": "node"
4+
}
5+
}

packages/jest-circus/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"@jest/types": "^25.5.0",
1717
"chalk": "^3.0.0",
1818
"co": "^4.6.0",
19+
"dedent": "^0.7.0",
1920
"expect": "^25.5.0",
2021
"is-generator-fn": "^2.0.0",
2122
"jest-each": "^25.5.0",
@@ -34,6 +35,7 @@
3435
"@jest/test-utils": "^25.5.0",
3536
"@types/babel__traverse": "^7.0.4",
3637
"@types/co": "^4.6.0",
38+
"@types/dedent": "^0.7.0",
3739
"@types/graceful-fs": "^4.1.3",
3840
"@types/stack-utils": "^1.0.1",
3941
"execa": "^3.2.0",

packages/jest-circus/src/run.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,10 @@ const _callCircusHook = async ({
138138
const timeout = hook.timeout || getState().testTimeout;
139139

140140
try {
141-
await callAsyncCircusFn(hook.fn, testContext, {isHook: true, timeout});
141+
await callAsyncCircusFn(hook.fn, testContext, hook.asyncError, {
142+
isHook: true,
143+
timeout,
144+
});
142145
await dispatch({describeBlock, hook, name: 'hook_success', test});
143146
} catch (error) {
144147
await dispatch({describeBlock, error, hook, name: 'hook_failure', test});
@@ -158,7 +161,10 @@ const _callCircusTest = async (
158161
}
159162

160163
try {
161-
await callAsyncCircusFn(test.fn, testContext, {isHook: false, timeout});
164+
await callAsyncCircusFn(test.fn, testContext, test.asyncError, {
165+
isHook: false,
166+
timeout,
167+
});
162168
await dispatch({name: 'test_fn_success', test});
163169
} catch (error) {
164170
await dispatch({error, name: 'test_fn_failure', test});

packages/jest-circus/src/utils.ts

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {Circus} from '@jest/types';
99
import {convertDescriptorToString} from 'jest-util';
1010
import isGeneratorFn from 'is-generator-fn';
1111
import co from 'co';
12+
import dedent = require('dedent');
1213
import StackUtils = require('stack-utils');
1314
import prettyFormat = require('pretty-format');
1415
import {getState} from './state';
@@ -153,6 +154,7 @@ function checkIsError(error: any): error is Error {
153154
export const callAsyncCircusFn = (
154155
fn: Circus.AsyncFn,
155156
testContext: Circus.TestContext | undefined,
157+
asyncError: Circus.Exception,
156158
{isHook, timeout}: {isHook?: boolean | null; timeout: number},
157159
): Promise<any> => {
158160
let timeoutID: NodeJS.Timeout;
@@ -167,24 +169,47 @@ export const callAsyncCircusFn = (
167169
// If this fn accepts `done` callback we return a promise that fulfills as
168170
// soon as `done` called.
169171
if (fn.length) {
172+
let returnedValue: unknown = undefined;
170173
const done = (reason?: Error | string): void => {
171-
const errorAsErrorObject = checkIsError(reason)
172-
? reason
173-
: new Error(`Failed: ${prettyFormat(reason, {maxDepth: 3})}`);
174-
175-
// Consider always throwing, regardless if `reason` is set or not
176-
if (completed && reason) {
177-
errorAsErrorObject.message =
178-
'Caught error after test environment was torn down\n\n' +
179-
errorAsErrorObject.message;
180-
181-
throw errorAsErrorObject;
182-
}
183-
184-
return reason ? reject(errorAsErrorObject) : resolve();
174+
// We need to keep a stack here before the promise tick
175+
const errorAtDone = new Error();
176+
// Use `Promise.resolve` to allow the event loop to go a single tick in case `done` is called synchronously
177+
Promise.resolve().then(() => {
178+
if (returnedValue !== undefined) {
179+
asyncError.message = dedent`
180+
Test functions cannot both take a 'done' callback and return something. Either use a 'done' callback, or return a promise.
181+
Returned value: ${prettyFormat(returnedValue, {maxDepth: 3})}
182+
`;
183+
return reject(asyncError);
184+
}
185+
186+
let errorAsErrorObject: Error;
187+
188+
if (checkIsError(reason)) {
189+
errorAsErrorObject = reason;
190+
} else {
191+
errorAsErrorObject = errorAtDone;
192+
errorAtDone.message = `Failed: ${prettyFormat(reason, {
193+
maxDepth: 3,
194+
})}`;
195+
}
196+
197+
// Consider always throwing, regardless if `reason` is set or not
198+
if (completed && reason) {
199+
errorAsErrorObject.message =
200+
'Caught error after test environment was torn down\n\n' +
201+
errorAsErrorObject.message;
202+
203+
throw errorAsErrorObject;
204+
}
205+
206+
return reason ? reject(errorAsErrorObject) : resolve();
207+
});
185208
};
186209

187-
return fn.call(testContext, done);
210+
returnedValue = fn.call(testContext, done);
211+
212+
return;
188213
}
189214

190215
let returnedValue;
@@ -194,7 +219,8 @@ export const callAsyncCircusFn = (
194219
try {
195220
returnedValue = fn.call(testContext);
196221
} catch (error) {
197-
return reject(error);
222+
reject(error);
223+
return;
198224
}
199225
}
200226

@@ -205,23 +231,25 @@ export const callAsyncCircusFn = (
205231
returnedValue !== null &&
206232
typeof returnedValue.then === 'function'
207233
) {
208-
return returnedValue.then(resolve, reject);
234+
returnedValue.then(resolve, reject);
235+
return;
209236
}
210237

211-
if (!isHook && returnedValue !== void 0) {
212-
return reject(
238+
if (!isHook && returnedValue !== undefined) {
239+
reject(
213240
new Error(
214-
`
241+
dedent`
215242
test functions can only return Promise or undefined.
216-
Returned value: ${String(returnedValue)}
243+
Returned value: ${prettyFormat(returnedValue, {maxDepth: 3})}
217244
`,
218245
),
219246
);
247+
return;
220248
}
221249

222250
// Otherwise this test is synchronous, and if it didn't throw it means
223251
// it passed.
224-
return resolve();
252+
resolve();
225253
})
226254
.then(() => {
227255
completed = true;

0 commit comments

Comments
 (0)