Hone logo
Hone
Problems

Mastering Async Operations: Jest Utilities for Asynchronous Code

Testing asynchronous code can be notoriously tricky, often leading to flaky tests and difficult debugging. This challenge focuses on building robust Jest utilities to simplify and streamline the testing of asynchronous functions in TypeScript. You'll create helper functions that leverage Jest's capabilities to manage promises, timeouts, and assertions in a clear and reliable manner.

Problem Description

Your task is to create a set of reusable Jest utility functions in TypeScript designed to simplify the testing of asynchronous operations. These utilities should abstract away common patterns for dealing with promises, including checking for successful resolution, rejection, and handling timeouts.

Key Requirements:

  1. resolvesWith(expectedValue: any):

    • A Jest matcher that asserts a Promise resolves with a specific value.
    • It should work similarly to expect(...).resolves.toBe(...) but could offer additional logging or customization if needed (though basic toBe compatibility is sufficient).
  2. rejectsWith(expectedError: any):

    • A Jest matcher that asserts a Promise rejects with a specific error or error message.
    • Similar to expect(...).rejects.toThrow(...).
  3. waitsForPromise(promise: Promise<any>, timeout: number = 5000):

    • A utility function that takes a Promise and a timeout duration.
    • If the Promise resolves within the timeout, the function should resolve.
    • If the Promise rejects within the timeout, the function should reject with the rejection reason.
    • If the timeout is reached before the Promise settles, the function should reject with a specific timeout error message.
  4. waitsForCondition(condition: () => boolean, timeout: number = 5000, interval: number = 50):

    • A utility function that repeatedly checks a condition function until it returns true or a timeout is reached.
    • The condition function should be polled at a given interval.
    • If the condition becomes true within the timeout, the function should resolve.
    • If the timeout is reached before the condition becomes true, the function should reject with a specific timeout error message.

Expected Behavior:

  • The utilities should be implemented in TypeScript and be type-safe.
  • They should integrate seamlessly with Jest's testing framework.
  • The resolvesWith and rejectsWith utilities should be implemented as Jest custom matchers.
  • The waitsForPromise and waitsForCondition should be standalone async utility functions.

Edge Cases to Consider:

  • Promises that resolve/reject immediately.
  • Promises that take a long time to settle.
  • Very short or very long timeouts.
  • Intervals that are too short or too long for waitsForCondition.
  • The condition function in waitsForCondition might throw an error; this should be handled gracefully.

Examples

Example 1: resolvesWith Custom Matcher

// In your test file:
import { resolvesWith } from './async-utils'; // Assuming your utilities are in this file

describe('resolvesWith', () => {
  it('should resolve with the expected value', async () => {
    const promise = Promise.resolve(42);
    await expect(promise).resolvesWith(42);
  });

  it('should fail if the promise resolves with a different value', async () => {
    const promise = Promise.resolve(42);
    // This expectation should fail, and we might want to catch it in tests for utilities
    try {
      await expect(promise).resolvesWith(100);
    } catch (error: any) {
      expect(error.message).toContain('Expected Promise to resolve with 100, but it resolved with 42');
    }
  });
});

Example 2: rejectsWith Custom Matcher

// In your test file:
import { rejectsWith } from './async-utils';

describe('rejectsWith', () => {
  it('should reject with the expected error message', async () => {
    const promise = Promise.reject(new Error('Something went wrong'));
    await expect(promise).rejectsWith('Something went wrong');
  });

  it('should fail if the promise rejects with a different error', async () => {
    const promise = Promise.reject(new Error('Error A'));
    try {
      await expect(promise).rejectsWith('Error B');
    } catch (error: any) {
      expect(error.message).toContain('Expected Promise to reject with "Error B", but it rejected with Error: Error A');
    }
  });
});

Example 3: waitsForPromise Utility

// In your test file:
import { waitsForPromise } from './async-utils';

function delayedResolve(value: any, delay: number): Promise<any> {
  return new Promise(resolve => setTimeout(() => resolve(value), delay));
}

function delayedReject(error: any, delay: number): Promise<any> {
  return new Promise((_, reject) => setTimeout(() => reject(error), delay));
}

describe('waitsForPromise', () => {
  it('should resolve if the promise resolves within the timeout', async () => {
    const promise = delayedResolve('success', 100);
    await waitsForPromise(promise, 500); // Should resolve
  });

  it('should reject if the promise rejects within the timeout', async () => {
    const promise = delayedReject(new Error('test error'), 100);
    await expect(waitsForPromise(promise, 500)).rejects.toThrow('test error');
  });

  it('should reject with a timeout error if the promise takes too long', async () => {
    const promise = delayedResolve('too long', 1000);
    await expect(waitsForPromise(promise, 200)).rejects.toThrow('Promise timed out after 200ms');
  });
});

Example 4: waitsForCondition Utility

// In your test file:
import { waitsForCondition } from './async-utils';

describe('waitsForCondition', () => {
  it('should resolve when the condition becomes true', async () => {
    let flag = false;
    setTimeout(() => { flag = true; }, 200);
    await waitsForCondition(() => flag, 500); // Should resolve
  });

  it('should reject with a timeout error if the condition never becomes true', async () => {
    let flag = false;
    setTimeout(() => { flag = true; }, 1000);
    await expect(waitsForCondition(() => flag, 300)).rejects.toThrow('Condition timed out after 300ms');
  });

  it('should handle condition errors gracefully', async () => {
    let counter = 0;
    const conditionWithError = () => {
      counter++;
      if (counter === 2) {
        throw new Error('Error in condition');
      }
      return false;
    };
    await expect(waitsForCondition(conditionWithError, 500)).rejects.toThrow('Error in condition');
  });
});

Constraints

  • The solution must be implemented in TypeScript.
  • Your custom matchers (resolvesWith, rejectsWith) should be registered using expect.extend.
  • The waitsForPromise and waitsForCondition functions must return Promises.
  • The timeout for waitsForPromise and waitsForCondition defaults to 5000ms but can be overridden.
  • The polling interval for waitsForCondition defaults to 50ms.
  • Do not use any external libraries for async utilities; rely on Node.js built-in functionalities and Jest APIs.

Notes

  • Consider how to provide clear and informative error messages for failed assertions.
  • For resolvesWith and rejectsWith, think about how to compare the resolved value or rejection reason. A simple toBe or toThrow check is a good starting point.
  • setTimeout and clearTimeout will be your friends for implementing the timeout logic.
  • setInterval can be useful for waitsForCondition, but remember to clear it once the condition is met or the timeout occurs.
  • Remember that Jest provides expect.extend for adding custom matchers.
  • The goal is to make testing async code more declarative and less error-prone.
Loading editor...
typescript