Skip to content

[Feature]: Allow listening to an AbortSignal for test timeouts #15804

@dmurvihill

Description

@dmurvihill

🚀 Feature Proposal

Expose an AbortSignal (node, web) that emits when a test times out.

Motivation

When testing asynchronous operations, It's very easy to get errors like, ReferenceError: You are trying to access a property or method of the Jest environment after it has been torn down.. To get clean test results, operations must be interrupted when the test times out.

Example

Naive implementation:

test('MyWritableStream should eventually process all records', async () => {
  const data: Iterable<unknown> = generateTooMuchTestData();  // too much to process
  const myWritableStream = new MyWritableStream();
  await Promise.all([
    pipeline(stream.Readable.from(data), myWritableStream);  
    runFakeTimersUntilStreamCloses(myWritableStream),
  ]);
  expect(new Set(myWritableStream.processedData)).toStrictEqual(new Set(data));
}, 1, /* not enough time */);

If the test times out, the pipeline continues running:

ReferenceError: You are trying to access a property or method of the Jest environment after it has been torn down. From lib/stream.spec.ts.

      270 |               slow: 1,
      271 |             },
    > 272 |           },
          |                      ^
      273 |         },
      274 |       ),
      275 |       1,

      at Timeout._onTimeout (lib/stream.spec.ts:272:22)
  console.warn
    A function to advance timers was called but the timers APIs are not replaced with fake timers. Call `jest.useFakeTimers()` in this test file or enable fake timers for all tests by setting 'fakeTimers': {'enableGlobally': true} in Jest configuration file.
    Stack Trace:
    
          270 |               slow: 1,
          271 |             },
        > 272 |           },
              |                      ^
          273 |         },
          274 |       ),
          275 |       1,
    
          Error
          at FakeTimers._checkFakeTimers (../../node_modules/@jest/fake-timers/build/modernFakeTimers.js:158:13)
          at FakeTimers.runAllTimers (../../node_modules/@jest/fake-timers/build/modernFakeTimers.js:49:14)
          at Timeout._onTimeout (lib/stream.spec.ts:272:22)

      270 |               slow: 1,
      271 |             },
    > 272 |           },
          |                      ^
      273 |         },
      274 |       ),
      275 |       1,

      at FakeTimers._checkFakeTimers (../../node_modules/@jest/fake-timers/build/modernFakeTimers.js:152:28)
      at FakeTimers.runAllTimers (../../node_modules/@jest/fake-timers/build/modernFakeTimers.js:49:14)
      at Timeout._onTimeout (lib/stream.spec.ts:272:22)


ReferenceError: You are trying to access a property or method of the Jest environment after it has been torn down. From lib/stream.spec.ts.

This ought to fix it, but seems like a kludge:

const streamsToCleanUp = [];

function cleanUpStreams() {
  streamsToCleanUp.splice(0, streamsToCleanUp.length).forEach(s => {
    s.destroy();
  });
}

afterEach(cleanUpStreams);

test('MyWritableStream should eventually process all records', async () => {
  const data: Iterable<unknown> = generateTooMuchTestData();  // too much to process
  const source = stream.Readable.from(data);
  const myWritableStream = new MyWritableStream();
  streamsToCleanUp.push(source);
  streamsToCleanUp.push(myWritableStream);
  try {
    await Promise.all([
      pipeline(source, myWritableStream);  
      runFakeTimersUntilStreamCloses(myWritableStream),
    ]);
      expect(new Set(myWritableStream.processedData)).toStrictEqual(new Set(data));
  } finally {
    cleanUpStreams();
  }
}, 1 /* not enough time */);

Better solution:

test('MyWritableStream should eventually process all records', async () => {
  const data: Iterable<unknown> = generateTooMuchTestData();  // too much to process
  const source = stream.Readable.from(data);
  const myWritableStream = new MyWritableStream();
  jest.abortSignal.on('abort', () => {  // use some hypothetical 'jest.abortSignal' API
    source.destroy();
    myWritableStream.destroy();
  });
  await Promise.all([
    pipeline(source, myWritableStream);  
    runFakeTimersUntilStreamCloses(myWritableStream),
  ]);
  expect(new Set(myWritableStream.processedData)).toStrictEqual(new Set(data));
}, 1, /* not enough time */);

The API jest.abortSignal used in this example is illustrative only -- I'm not sure what would actually be the best API design for this.

Please excuse any mistakes in the example code -- it is only meant to illustrate the use case and has not been tested.

Pitch

The current best practice, it seems, is to duplicate the timeout within the test itself, or maintain operations that need to be interrupted in some a broader scope and clean it up in afterEach. It would be cleaner to subscribe to an abort` event from Jest and thread it through to the code under test.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions