Skip to content

Commit

Permalink
feat(circus): enable writing async test event handlers (#9397)
Browse files Browse the repository at this point in the history
  • Loading branch information
noomorph committed Apr 8, 2020
1 parent 44a960d commit 222565a
Show file tree
Hide file tree
Showing 16 changed files with 219 additions and 60 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
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
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
@@ -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",
"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",
]
`);
});
@@ -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;
@@ -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
@@ -0,0 +1,5 @@
{
"jest": {
"testEnvironment": "node"
}
}
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.

## Installation

Install `jest-circus` using yarn:
Expand Down
6 changes: 5 additions & 1 deletion packages/jest-circus/src/eventHandler.ts
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,
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 @@ -36,7 +36,7 @@ type Process = NodeJS.Process;

// TODO: hard to type
// 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

0 comments on commit 222565a

Please sign in to comment.