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(circus): enable writing async test event handlers #9397

Merged
merged 15 commits into from Apr 8, 2020
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
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 @@ -49,6 +49,7 @@
- `[jest-reporters]` Adds ability to pass options to the istanbul-reporter through `coverageReporters` ([#9572](https://github.com/facebook/jest/pull/9572))
- `[jest-runtime]` Require stack when a module cannot be resolved ([#9681](https://github.com/facebook/jest/pull/9681))
- `[jest-transform]` `writeCacheFile` no longer calls `fsync` ([#9695](https://github.com/facebook/jest/pull/9695))
- `[jest-circus]` Enable writing async test event handlers ([#9392](https://github.com/facebook/jest/pull/9392))
noomorph marked this conversation as resolved.
Show resolved Hide resolved

### Fixes

Expand Down
4 changes: 2 additions & 2 deletions docs/Configuration.md
Expand Up @@ -900,7 +900,7 @@ test('use jsdom in this test file', () => {

You can create your own module that will be used for setting up the test environment. The module must export a class with `setup`, `teardown` and `runScript` methods. You can also pass variables from this module to your test suites by assigning them to `this.global` object – this will make them available in your test suites as global variables.

The class may optionally expose a `handleTestEvent` method to bind to events fired by [`jest-circus`](https://github.com/facebook/jest/tree/master/packages/jest-circus).
The class may optionally expose an asynchronous `handleTestEvent` method to bind to events fired by [`jest-circus`](https://github.com/facebook/jest/tree/master/packages/jest-circus). Normally, `jest-circus` test runner would pause until a promise returned from `handleTestEvent` gets fulfilled, **except for the next events**: `start_describe_definition`, `finish_describe_definition`, `add_hook`, `add_test` or `error` (for the up-to-date list you can look at [SyncEvent type in the types definitions](https://github.com/facebook/jest/tree/master/packages/jest-types/src/Circus.ts)). That is caused by backward compatibility reasons and `process.on('unhandledRejection', callback)` signature, but that usually should not be a problem for most of the use cases.

Any docblock pragmas in test files will be passed to the environment constructor and can be used for per-test configuration. If the pragma does not have a value, it will be present in the object with it's value set to an empty string. If the pragma is not present, it will not be present in the object.

Expand Down Expand Up @@ -940,7 +940,7 @@ class CustomEnvironment extends NodeEnvironment {
return super.runScript(script);
}

handleTestEvent(event, state) {
async handleTestEvent(event, state) {
if (event.name === 'test_start') {
// ...
}
Expand Down
75 changes: 64 additions & 11 deletions e2e/__tests__/testEnvironmentCircus.test.ts
Expand Up @@ -11,32 +11,85 @@ import runJest from '../runJest';
skipSuiteOnJasmine();

it('calls testEnvironment handleTestEvent', () => {
noomorph marked this conversation as resolved.
Show resolved Hide resolved
process.env.ASYNC_HANDLE_TEST_EVENT = '';

const result = runJest('test-environment-circus');
expect(result.failed).toEqual(false);
expect(result.stdout.split('\n')).toMatchInlineSnapshot(`
expect(result.failed).toEqual(true);

const lines = result.stdout.split('\n');
expect(lines).toMatchInlineSnapshot(`
Array [
"setup",
"add_hook",
"add_hook",
"add_hook",
"add_test",
"add_test",
"run_start",
"run_describe_start",
"test_start: test name here",
"hook_start: beforeEach",
"hook_success: beforeEach",
"hook_start: beforeEach",
"hook_success: beforeEach",
"test_fn_start: test name here",
"test_fn_success: test name here",
"hook_start: afterEach",
"hook_success: afterEach",
"test_done: test name here",
"test_start: second test name here",
"hook_start: beforeEach",
"hook_success: beforeEach",
"hook_start: beforeEach",
"hook_success: beforeEach",
"test_fn_start: second test name here",
"test_fn_failure: second test name here",
"hook_start: afterEach",
"hook_success: afterEach",
"test_done: second test name here",
"run_describe_finish",
"run_finish",
"teardown",
]
`);
});

it('calls testEnvironment handleTestEvent (async)', () => {
process.env.ASYNC_HANDLE_TEST_EVENT = '1';

const result = runJest('test-environment-circus');
expect(result.failed).toEqual(true);

const lines = result.stdout.split('\n');
expect(lines).toMatchInlineSnapshot(`
Array [
"setup",
"add_hook",
"add_hook",
"add_hook",
"add_test",
"add_test",
"run_start",
"run_describe_start",
"test_start: test name here",
"hook_start",
"hook_success: test name here",
"hook_start",
"hook_success: test name here",
"hook_start: beforeEach",
"hook_success: beforeEach",
"hook_start: beforeEach",
"hook_success: beforeEach",
"test_fn_start: test name here",
"test_fn_success: test name here",
"hook_start: afterEach",
"hook_success: afterEach",
"test_done: test name here",
"test_start: second test name here",
"hook_start",
"hook_success: second test name here",
"hook_start",
"hook_success: second test name here",
"hook_start: beforeEach",
"hook_success: beforeEach",
"hook_start: beforeEach",
"hook_success: beforeEach",
"test_fn_start: second test name here",
"test_fn_success: second test name here",
"test_fn_failure: second test name here",
"hook_start: afterEach",
"hook_success: afterEach",
"test_done: second test name here",
"run_describe_finish",
"run_finish",
Expand Down
@@ -0,0 +1,39 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

'use strict';

const BaseTestEnvironment = require('./CircusHandleTestEventEnvironment');

const SYNC_EVENTS = [
'start_describe_definition',
'finish_describe_definition',
'add_hook',
'add_test',
'error',
];

const isAsyncEvent = e => SYNC_EVENTS.indexOf(e.name) === -1;
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

class TestEnvironment extends BaseTestEnvironment {
async handleTestEvent(event) {
this.pendingEvents = this.pendingEvents || new Set();
if (this.pendingEvents.size > 0) {
console.log('async handleTestEvent is not respected');
}

if (isAsyncEvent(event)) {
this.pendingEvents.add(event);
await sleep(0).then(() => this.pendingEvents.delete(event));
noomorph marked this conversation as resolved.
Show resolved Hide resolved
}

super.handleTestEvent(event);
noomorph marked this conversation as resolved.
Show resolved Hide resolved
}
}

module.exports = TestEnvironment;
10 changes: 8 additions & 2 deletions e2e/test-environment-circus/CircusHandleTestEventEnvironment.js
Expand Up @@ -10,8 +10,14 @@
const JSDOMEnvironment = require('jest-environment-jsdom');

class TestEnvironment extends JSDOMEnvironment {
handleTestEvent(event) {
console.log(event.name + (event.test ? ': ' + event.test.name : ''));
async handleTestEvent(event) {
if (event.hook) {
console.log(event.name + ': ' + event.hook.type);
} else if (event.test) {
console.log(event.name + ': ' + event.test.name);
} else {
console.log(event.name);
}
}
}

Expand Down
Expand Up @@ -4,15 +4,16 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @jest-environment ./CircusHandleTestEventEnvironment.js
* @jest-environment ./testEnvironment.js
*/

beforeEach(() => {});
afterEach(() => {});

test('test name here', () => {
expect(true).toBe(true);
});

test('second test name here', () => {
expect(true).toBe(true);
expect(true).toBe(false);
});
12 changes: 12 additions & 0 deletions e2e/test-environment-circus/testEnvironment.js
@@ -0,0 +1,12 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

'use strict';

module.exports = process.env.ASYNC_HANDLE_TEST_EVENT
noomorph marked this conversation as resolved.
Show resolved Hide resolved
? require('./CircusAsyncHandleTestEventEnvironment')
: require('./CircusHandleTestEventEnvironment');
4 changes: 3 additions & 1 deletion packages/jest-circus/README.md
Expand Up @@ -18,7 +18,7 @@ import {Event, State} from 'jest-circus';
class MyCustomEnvironment extends NodeEnvironment {
//...

handleTestEvent(event: Event, state: State) {
async handleTestEvent(event: Event, state: State) {
if (event.name === 'test_start') {
// ...
}
Expand All @@ -28,6 +28,8 @@ class MyCustomEnvironment extends NodeEnvironment {

Mutating event or state data is currently unsupported and may cause unexpected behavior or break in a future release without warning. New events, event data, and/or state data will not be considered a breaking change and may be added in any minor release.

Note, that `jest-circus` test runner would pause until a promise returned from `handleTestEvent` gets fulfilled. **However, there are a few events that do not conform to this rule, namely**: `start_describe_definition`, `finish_describe_definition`, `add_hook`, `add_test` or `error` (for the up-to-date list you can look at [SyncEvent type in the types definitions](https://github.com/facebook/jest/tree/master/packages/jest-types/src/Circus.ts)). That is caused by backward compatibility reasons and `process.on('unhandledRejection', callback)` signature, but that usually should not be a problem for most of the use cases.
noomorph marked this conversation as resolved.
Show resolved Hide resolved

## Installation

Install `jest-circus` using yarn:
Expand Down
5 changes: 4 additions & 1 deletion packages/jest-circus/src/__mocks__/testEventHandler.ts
Expand Up @@ -7,7 +7,10 @@

import {Circus} from '@jest/types';

const testEventHandler: Circus.EventHandler = (event, state) => {
const testEventHandler: Circus.EventHandler = (
event: Circus.Event,
noomorph marked this conversation as resolved.
Show resolved Hide resolved
state: Circus.State,
) => {
switch (event.name) {
case 'start_describe_definition':
case 'finish_describe_definition': {
Expand Down
5 changes: 4 additions & 1 deletion packages/jest-circus/src/eventHandler.ts
Expand Up @@ -21,7 +21,10 @@ import {
restoreGlobalErrorHandlers,
} from './globalErrorHandlers';

const eventHandler: Circus.EventHandler = (event, state): void => {
const eventHandler: Circus.EventHandler = (
event: Circus.Event,
noomorph marked this conversation as resolved.
Show resolved Hide resolved
state: Circus.State,
): void => {
switch (event.name) {
case 'include_test_location_in_result': {
state.includeTestLocationInResult = true;
Expand Down
4 changes: 2 additions & 2 deletions packages/jest-circus/src/globalErrorHandlers.ts
Expand Up @@ -6,11 +6,11 @@
*/

import type {Circus} from '@jest/types';
import {dispatch} from './state';
import {dispatchSync} from './state';

const uncaught: NodeJS.UncaughtExceptionListener &
NodeJS.UnhandledRejectionListener = (error: unknown) => {
dispatch({error, name: 'error'});
dispatchSync({error, name: 'error'});
};

export const injectGlobalErrorHandlers = (
Expand Down
10 changes: 5 additions & 5 deletions packages/jest-circus/src/index.ts
Expand Up @@ -10,7 +10,7 @@ import {bind as bindEach} from 'jest-each';
import {formatExecError} from 'jest-message-util';
import {ErrorWithStack, isPromise} from 'jest-util';
import type {Circus, Global} from '@jest/types';
import {dispatch} from './state';
import {dispatchSync} from './state';

type THook = (fn: Circus.HookFn, timeout?: number) => void;
type DescribeFn = (
Expand Down Expand Up @@ -52,7 +52,7 @@ const _dispatchDescribe = (
asyncError.message = `Invalid second argument, ${blockFn}. It must be a callback function.`;
throw asyncError;
}
dispatch({
dispatchSync({
asyncError,
blockName,
mode,
Expand Down Expand Up @@ -91,7 +91,7 @@ const _dispatchDescribe = (
);
}

dispatch({blockName, mode, name: 'finish_describe_definition'});
dispatchSync({blockName, mode, name: 'finish_describe_definition'});
};

const _addHook = (
Expand All @@ -109,7 +109,7 @@ const _addHook = (
throw asyncError;
}

dispatch({asyncError, fn, hookType, name: 'add_hook', timeout});
dispatchSync({asyncError, fn, hookType, name: 'add_hook', timeout});
};

// Hooks have to pass themselves to the HOF in order for us to trim stack traces.
Expand Down Expand Up @@ -179,7 +179,7 @@ const test: Global.It = (() => {
throw asyncError;
}

return dispatch({
return dispatchSync({
asyncError,
fn,
mode,
Expand Down
Expand Up @@ -38,7 +38,7 @@ const jestAdapter = async (
config.prettierPath ? require(config.prettierPath) : null;
const getBabelTraverse = () => require('@babel/traverse').default;

const {globals, snapshotState} = initialize({
const {globals, snapshotState} = await initialize({
config,
environment,
getBabelTraverse,
Expand Down
Expand Up @@ -34,9 +34,8 @@ import globals from '..';

type Process = NodeJS.Process;

// TODO: hard to type
noomorph marked this conversation as resolved.
Show resolved Hide resolved
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const initialize = ({
export const initialize = async ({
config,
environment,
getPrettier,
Expand Down Expand Up @@ -107,14 +106,14 @@ export const initialize = ({
addEventHandler(environment.handleTestEvent.bind(environment));
}

dispatch({
await dispatch({
name: 'setup',
parentProcess,
testNamePattern: globalConfig.testNamePattern,
});

if (config.testLocationInResults) {
dispatch({
await dispatch({
name: 'include_test_location_in_result',
});
}
Expand Down Expand Up @@ -220,7 +219,8 @@ export const runAndTransformResultsToJestFormat = async ({
.join('\n');
}

dispatch({name: 'teardown'});
await dispatch({name: 'teardown'});

return {
...createEmptyTestResult(),
console: undefined,
Expand Down Expand Up @@ -248,7 +248,7 @@ const handleSnapshotStateAfterRetry = (snapshotState: SnapshotStateType) => (
}
};

const eventHandler = (event: Circus.Event) => {
const eventHandler = async (event: Circus.Event) => {
switch (event.name) {
case 'test_start': {
setState({currentTestName: getTestID(event.test)});
Expand Down