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 all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- `[babel-jest]` Support passing `supportsDynamicImport` and `supportsStaticESM` ([#9766](https://github.com/facebook/jest/pull/9766))
- `[babel-preset-jest]` Enable all syntax plugins not enabled by default that works on current version of Node ([#9774](https://github.com/facebook/jest/pull/9774))
- `[jest-circus]` Enable writing async test event handlers ([#9392](https://github.com/facebook/jest/pull/9392))
- `[jest-runtime, @jest/transformer]` Support passing `supportsDynamicImport` and `supportsStaticESM` ([#9597](https://github.com/facebook/jest/pull/9597))

### Fixes
Expand Down
4 changes: 2 additions & 2 deletions docs/Configuration.md
Original file line number Diff line number Diff line change
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
64 changes: 64 additions & 0 deletions e2e/__tests__/testEnvironmentCircusAsync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* 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.
*/

import {skipSuiteOnJasmine} from '@jest/test-utils';
import runJest from '../runJest';

skipSuiteOnJasmine();

it('calls asynchronous handleTestEvent in testEnvironment', () => {
const result = runJest('test-environment-circus-async');
expect(result.failed).toEqual(true);

const lines = result.stdout.split('\n');
expect(lines).toMatchInlineSnapshot(`
Array [
"setup",
"warning: add_hook is a sync event",
noomorph marked this conversation as resolved.
Show resolved Hide resolved
"warning: start_describe_definition is a sync event",
"warning: add_hook is a sync event",
"warning: add_hook is a sync event",
"warning: add_test is a sync event",
"warning: add_test is a sync event",
"warning: finish_describe_definition is a sync event",
"add_hook",
"start_describe_definition",
"add_hook",
"add_hook",
"add_test",
"add_test",
"finish_describe_definition",
"run_start",
"run_describe_start",
"run_describe_start",
"test_start: passing test",
"hook_start: beforeEach",
"hook_success: beforeEach",
"hook_start: beforeEach",
"hook_success: beforeEach",
"test_fn_start: passing test",
"test_fn_success: passing test",
"hook_start: afterEach",
"hook_failure: afterEach",
"test_done: passing test",
"test_start: failing test",
"hook_start: beforeEach",
"hook_success: beforeEach",
"hook_start: beforeEach",
"hook_success: beforeEach",
"test_fn_start: failing test",
"test_fn_failure: failing test",
"hook_start: afterEach",
"hook_failure: afterEach",
"test_done: failing test",
"run_describe_finish",
"run_describe_finish",
"run_finish",
"teardown",
]
`);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* 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 JSDOMEnvironment = require('jest-environment-jsdom');

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

class TestEnvironment extends JSDOMEnvironment {
async handleTestEvent(event) {
await this.assertRunnerWaitsForHandleTestEvent(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);
}
}

async assertRunnerWaitsForHandleTestEvent(event) {
if (this.pendingEvent) {
console.log(`warning: ${this.pendingEvent.name} is a sync event`);
}

this.pendingEvent = event;
await sleep(0);
this.pendingEvent = null;
}
}

module.exports = TestEnvironment;
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* 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.
*
* @jest-environment ./CircusAsyncHandleTestEventEnvironment.js
*/

describe('suite', () => {
beforeEach(() => {});
afterEach(() => {
throw new Error();
});

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

test('failing test', () => {
expect(true).toBe(false);
});
});
5 changes: 5 additions & 0 deletions e2e/test-environment-circus-async/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"jest": {
"testEnvironment": "node"
}
}
4 changes: 3 additions & 1 deletion packages/jest-circus/README.md
Original file line number Diff line number Diff line change
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
6 changes: 5 additions & 1 deletion packages/jest-circus/src/eventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ import {
restoreGlobalErrorHandlers,
} from './globalErrorHandlers';

const eventHandler: Circus.EventHandler = (event, state): void => {
// TODO: investigate why a shorter (event, state) signature results into TS7006 compiler error
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ 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 +107,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 +220,8 @@ export const runAndTransformResultsToJestFormat = async ({
.join('\n');
}

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

return {
...createEmptyTestResult(),
console: undefined,
Expand Down Expand Up @@ -248,7 +249,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
Loading