Understanding Jasmine's Async Testing Model

Callbacks, `done()`, and Async/Await

Jasmine supports asynchronous testing using three main patterns: explicit `done()` callbacks, returning a Promise, or using `async/await`. Improper use of any of these patterns can lead to tests hanging or finishing prematurely.

// Using done() callback
it("fetches data", function(done) {
  fetchData().then(data => {
    expect(data).toBeDefined();
    done();
  });
});

Where Async Tests Go Wrong

Tests may pass locally but fail in CI due to race conditions, unhandled Promise rejections, missing `done()` calls, or misconfigured fakeAsync/test zones (in Angular). Jasmine does not always surface these errors clearly.

Root Causes of Flaky Async Tests

Missing or Misused `done()`

For callback-based async tests, failing to call `done()` (or calling it twice) results in hanging or false-positive behavior. Errors inside Promises can go unnoticed if not caught properly.

Unhandled Promise Rejections

If a Promise is rejected and not handled within the test scope, Jasmine may not detect the failure, especially in older versions that lack native unhandled rejection tracking.

Conflicts with FakeAsync or Zone.js

In Angular projects using Jasmine with TestBed, fakeAsync zones may conflict with native async/await or timer functions, causing unexpected behavior or incomplete task flushing.

Diagnostics and Debugging Techniques

Step 1: Isolate Failing Tests

Use `fit()` or `fdescribe()` to run a single test or suite. This reduces noise and helps identify unintended side effects between tests.

fit("should fetch data reliably", async () => {
  const result = await fetchData();
  expect(result).toBeTruthy();
});

Step 2: Enable Promise Error Traps

Use global error traps to catch unhandled rejections. This helps detect hidden Promise failures that Jasmine may otherwise miss.

process.on("unhandledRejection", reason => {
  fail(reason);
});

Step 3: Use Jasmine Clock for Timer Control

Replace native timers with Jasmine's clock mocking to control async behavior deterministically, especially for `setTimeout` or debounce tests.

beforeEach(() => jasmine.clock().install());
afterEach(() => jasmine.clock().uninstall());

Common Pitfalls

  • Using `done()` and returning a Promise in the same test (causes silent test passes)
  • Forgetting to install/uninstall Jasmine clock in each spec
  • Mixing fakeAsync with native async/await in Angular tests
  • Allowing async test timeouts to default (5s) without tuning for CI
  • Improper teardown of mocks or spies affecting subsequent tests

Step-by-Step Fixes

1. Prefer Async/Await Over `done()`

Use `async` functions and `await` Promises to simplify test control flow and reduce boilerplate.

it("resolves with correct value", async () => {
  const value = await someAsyncFunction();
  expect(value).toBe("expected");
});

2. Wrap All Async Logic in Try/Catch

Catch all async errors explicitly to ensure failures are visible to Jasmine.

it("catches errors", async () => {
  try {
    await asyncFn();
  } catch (e) {
    fail(e);
  }
});

3. Avoid Mixed Async Patterns

Never mix `done()` with async/await or returned Promises. Choose one pattern per test for consistency.

4. Use Custom Timeout per Test

Use Jasmine's `jasmine.DEFAULT_TIMEOUT_INTERVAL` or pass timeout as the third `it()` argument for long-running tests.

it("waits for server", async () => {
  await waitForServer();
}, 10000);

5. Mock All Async Dependencies

Stub network or timer operations to eliminate flaky dependencies and ensure tests run deterministically.

Best Practices

  • Stick to one async handling style (prefer async/await)
  • Log all rejections or uncaught exceptions in CI
  • Use test doubles (spies/mocks) for all external dependencies
  • Reset global state and mocks between tests
  • Run isolated tests in CI to detect order-dependent failures

Conclusion

Jasmine's async support is flexible but requires discipline to use reliably at scale. Flaky or hanging tests usually stem from misused async constructs, race conditions, or poorly mocked dependencies. By standardizing on async/await, enforcing strict error handling, and controlling timers deterministically, teams can write robust async test suites that behave consistently in both local and CI environments. Isolating each test's async scope and mocking time/network dependencies are essential for sustainable test infrastructure.

FAQs

1. Why does my Jasmine async test hang indefinitely?

It may be missing a `done()` callback, returning an unhandled Promise, or stuck on a hanging timer. Check all async paths and ensure test completion signals are used correctly.

2. Can I mix `done()` and async/await?

No. Mixing both can lead to unpredictable behavior or false positives. Use one approach per test.

3. How do I control timers in Jasmine?

Use `jasmine.clock().install()` to mock timers and manually advance time with `tick()`. Always uninstall in `afterEach()` to avoid bleed-over.

4. Why do async tests pass locally but fail in CI?

CI environments are slower and may expose race conditions, unmocked dependencies, or timeouts not apparent in fast local runs.

5. What's the best pattern for async Jasmine tests?

Use `async`/`await` with try/catch blocks and avoid reliance on external timing. Ensure all Promises are awaited or caught explicitly.