PRACTICAL GUIDE / JavaScript async test false positives

Prevent Async JavaScript Tests from Passing Falsely

JavaScript async test false positives happen when work escapes the test lifecycle. Learn to return promises, await assertions, and guard callbacks.

By The Testing AcademyUpdated July 25, 202617 min read
All field guides
In this guide10 sections
  1. Why Can an Async JavaScript Test Pass Falsely?
  2. Which Async Work Must the Runner Observe?
  3. What Are the Common False-Green Patterns?
  4. How Should You Test Promise Resolution and Rejection?
  5. When Should You Count Assertions or Own Callbacks?
  6. Follow a Numbered False-Positive Repair Workflow
  7. Apply the Workflow to the Repo Async Stream Tests
  8. How Do Jest and Vitest Differ on Async Assertions?
  9. Frequently Asked Questions About Async Test False Positives
  10. Why does a Jest test pass when an assertion inside then fails?
  11. Is returning a promise the same as awaiting it?
  12. Do resolves and rejects need to be awaited?
  13. When should I use expect.assertions?
  14. Can an async test use done?
  15. How can I prove the repair works?
  16. Conclusion: Give Every Async Assertion One Owner

What you will learn

  • Why Can an Async JavaScript Test Pass Falsely?
  • Which Async Work Must the Runner Observe?
  • What Are the Common False-Green Patterns?
  • How Should You Test Promise Resolution and Rejection?

JavaScript async test false positives happen when the test runner finishes before the asynchronous work that carries the assertion has settled. A promise that is neither returned nor awaited, an unawaited .resolves or .rejects matcher, or a callback path that runs zero assertions can all produce a green result without proving the intended behavior. The repair is ownership: the runner must receive one completion signal that covers every required assertion.

This guide turns that rule into a diagnostic process for Jest and Vitest. It uses the repository challenges javascript-testing-async-await-basics-quiz and javascript-testing-async-false-pass-scenario from src/db/seed-data/challenges/expansion-js-ts.ts, then connects them to the awaited promises and async iterator tests in src/lib/companion/openrouter-core.test.ts. Current behavior is checked against the official Jest asynchronous testing guide and Vitest asynchronous testing guide.

Why Can an Async JavaScript Test Pass Falsely?

Most JavaScript test runners consider a synchronous test complete when its function returns. They consider an async test complete when the returned promise settles. That sounds simple, but a test can start additional work that is not connected to its return value. The runner cannot wait for a promise it never receives.

The smallest example is an unreturned .then chain:

JavaScript
test('saves the order', () => {
  saveOrder().then((result) => {
    expect(result.saved).toBe(true);
  });
});

The callback contains an assertion, but the outer test function returns undefined immediately. If saveOrder resolves later with { saved: false }, the failing assertion occurs after the test has already completed. Depending on the runner and environment, the failure may appear as an unhandled rejection, a process warning, a later-test failure, or a swallowed event. None of those outcomes gives the assertion reliable ownership.

Two correct rewrites are possible. Return the chain:

JavaScript
test('saves the order', () => {
  return saveOrder().then((result) => {
    expect(result.saved).toBe(true);
  });
});

Or use async and await:

JavaScript
test('saves the order', async () => {
  const result = await saveOrder();
  expect(result.saved).toBe(true);
});

The styles are not competing features. Both return a promise to the runner. The important property is that the assertion runs before that owned promise settles. For deeper language review, JavaScript async and await interview questions cover promise syntax and error flow. This article remains focused on test verdicts.

False positives also arise without a floating promise. A test may await its operation correctly but put the only assertion in a branch that never runs. An expected-rejection test using try and catch can pass when the operation unexpectedly fulfills because the catch block is skipped. The test was fully awaited, yet it still proved nothing. Ownership must include both completion and assertion execution.

Which Async Work Must the Runner Observe?

A useful audit starts by listing every asynchronous boundary the test creates or calls. The runner must observe all work required to establish the verdict, including cleanup whose failure should fail the test.

The common boundaries are:

  • Promises returned directly by the system under test.
  • Promise-returning assertion chains such as .resolves and .rejects.
  • Async helper functions called from tests and hooks.
  • Callback APIs wrapped in a completion promise or handled through done.
  • Async iterators consumed with for await.
  • Event listeners whose event occurrence is part of the expected behavior.
  • Timers that schedule promise work.
  • beforeEach, afterEach, beforeAll, and afterAll hooks.
  • Resource cleanup such as closing a server, stream, database, or browser.

An async keyword on the outer test does not automatically adopt every promise created inside it. The following still detaches work:

JavaScript
test('loads settings', async () => {
  loadSettings().then((settings) => {
    expect(settings.theme).toBe('dark');
  });
});

The outer async function contains no await and reaches its end. It returns an already fulfilled promise, while the chain continues separately. The repair is await loadSettings() or return loadSettings().then(...).

Helpers can hide the same defect:

JavaScript
function assertSaved() {
  saveOrder().then((result) => {
    expect(result.saved).toBe(true);
  });
}

test('saves', async () => {
  await assertSaved();
});

assertSaved returns undefined, so awaiting it does nothing useful. The helper must be async and await the operation, or return the assertion promise. This is why async boundaries are an architecture concern as well as a syntax concern. Async orchestration architecture for JavaScript test runners examines the wider runner model.

Unhandled rejection reporting is evidence that a promise escaped an owner. It is not a substitute for test ownership. The focused guide on debugging unhandled promise rejections in tests helps trace that signal after it appears, while the patterns here prevent the assertion from becoming detached.

What Are the Common False-Green Patterns?

The same small set of ownership failures appears under many application symptoms. A diagnostic table helps separate them from ordinary timeout or assertion problems.

Code smellWhy the test can finishTypical symptomReliable rewriteAdditional guard
Unreturned .then or .catchOuter function returns undefinedGreen test plus later rejectionReturn the chain or await the promiseNegative control
Unawaited .resolves or .rejectsMatcher promise is detachedExpected failure is missedAwait or return the assertionAssertion count where useful
Assertion only in catchFulfillment skips the catchGreen test with zero assertionsUse awaited .rejectsexpect.assertions(1)
Async helper returns nothingCaller cannot own nested workCleanup or assertion runs lateReturn or await inside helperType helper as Promise<void>
Callback assertion without completionTest ends before callbackIntermittent pass or warningWrap callback in a promiseexpect.hasAssertions()
Async forEach callbackforEach ignores callback promisesLoop assertions finish lateUse for...of or await Promise.allExact assertion count
Event test starts listener onlyNo promise represents eventTest passes if event never firesResolve or reject an event promiseTimeout at owned boundary
Cleanup not awaitedTest verdict precedes release failureLeaks affect later testsAwait cleanup in a hook or finallyFail on cleanup error

Do not fix these patterns with sleeps. A delay changes when detached work happens, but it does not connect that work to the runner. It can make one machine appear stable while preserving the false-positive path.

Async forEach deserves special attention because the code appears to use await correctly:

JavaScript
items.forEach(async (item) => {
  const result = await validate(item);
  expect(result.valid).toBe(true);
});

forEach does not aggregate the promises returned by its callback. Use a sequential for...of loop when order or shared state matters, or use await Promise.all(items.map(async ...)) when work is genuinely independent. JavaScript promise combinators for parallel test setup covers that choice in detail.

The event loop explains when detached callbacks run, but knowing task ordering does not repair ownership. JavaScript event-loop ordering in browser tests is useful when the remaining failure depends on tasks and microtasks after every promise is correctly attached.

How Should You Test Promise Resolution and Rejection?

For a resolved promise, await the value and assert it, or await the matcher:

JavaScript
test('loads the paid plan', async () => {
  const plan = await fetchPlan('pro');
  expect(plan).toMatchObject({ seats: 5 });
});

test('loads the paid plan with resolves', async () => {
  await expect(fetchPlan('pro')).resolves.toMatchObject({ seats: 5 });
});

For a rejected promise, prefer an awaited .rejects assertion when the rejection itself is the contract:

JavaScript
test('rejects an empty account id', async () => {
  await expect(deleteAccount('')).rejects.toThrow('id required');
});

This form fails if the promise fulfills, so it removes the zero-assertion path found in a casual try and catch. It also keeps the expected error and its matcher on one line of ownership. A returned assertion is equally owned:

JavaScript
test('rejects an empty account id', () => {
  return expect(deleteAccount('')).rejects.toThrow('id required');
});

Use try and catch when the test must inspect several error properties, compare partial work before failure, or distinguish multiple failure classes. Add an assertion count or an explicit unreachable assertion so unexpected fulfillment fails:

JavaScript
test('reports a typed validation failure', async () => {
  expect.assertions(3);

  try {
    await deleteAccount('');
  } catch (error) {
    expect(error).toBeInstanceOf(ValidationError);
    expect(error.code).toBe('ACCOUNT_ID_REQUIRED');
    expect(error.message).toContain('id required');
  }
});

The exact count proves the catch branch executed all three assertions. An alternative is to place an explicit failure after the awaited call and before the catch. Runner-specific helpers such as expect.unreachable must be sourced to the runner version before use.

Promise assertions themselves are asynchronous. The current Jest Expect API requires the assertion promise to be returned or awaited. The current Vitest Expect API also requires awaiting async matchers and documents how enforcement changed across recent Vitest versions. Do not depend on a warning to save a broken test. Write ownership explicitly.

When Should You Count Assertions or Own Callbacks?

expect.assertions(number) verifies that the specified number of assertions ran before the test completed. expect.hasAssertions() verifies that at least one ran. These tools are valuable when control flow can skip the assertion even though the outer promise settles normally.

Good use cases include:

  • Assertions inside a catch block for an expected failure.
  • Several callbacks that must each be invoked.
  • A collection traversal that must produce at least one tested item.
  • Event handlers where the event is required.
  • Branch-specific assertions selected from known input.

Assertion counts do not attach a detached promise. If a test reaches its end before a callback and also declares expect.assertions(1), the count causes a failure, which is useful, but the underlying operation still needs an owned completion channel. Use the count and the promise together.

Counts should reflect a stable contract, not incidental implementation. A test that loops over a response and asserts every element may receive a legitimate variable number of items. Requiring an exact count can make the assertion fragile unless cardinality is itself part of the result. In that case, assert the collection length first and then the per-item rule. If the only requirement is that a callback path performs at least one check, hasAssertions states the narrower guarantee.

An assertion count also cannot prove that the right callback ran if another branch happens to execute the same number of assertions. Name callbacks by their role, assert their arguments, and connect their returned work to the owned promise. For APIs with success and failure callbacks, a promise wrapper should reject when the unexpected callback fires so the test does not merely wait for its preferred branch.

For a callback-only API, wrapping the callback in a promise usually gives modern test code one completion model:

JavaScript
function readLegacyValue() {
  return new Promise((resolve, reject) => {
    legacyRead((error, value) => {
      if (error) {
        reject(error);
        return;
      }
      resolve(value);
    });
  });
}

test('reads the legacy value', async () => {
  expect.assertions(1);
  await expect(readLegacyValue()).resolves.toBe('ready');
});

Jest also supports a done callback for callback-based tests. If an assertion inside the callback throws, catch that error and pass it to done(error) so the runner receives the real failure rather than waiting for a timeout. Do not mark the test function async or return a promise while also accepting done; the official Jest guide states that Jest rejects this dual completion contract.

Vitest's current guide presents promise wrapping for callback APIs. Keep runner examples separate. A pattern that is accepted by Jest is not automatically a Vitest compatibility promise, and an article should not erase that difference for the sake of symmetric examples.

Follow a Numbered False-Positive Repair Workflow

When a suspiciously green test contains asynchronous work, use a repeatable audit rather than adding logging until the timing changes.

  1. Create a negative control. Make the async result wrong, make an expected rejection fulfill, or prevent a required callback. Confirm whether the test still passes.
  2. Locate the runner-owned return value. Identify what the test function returns and when that value settles. An async function returns a promise, but it may settle before detached work.
  3. Trace every async helper. Verify each helper returns a promise covering its internal operations, assertions, and required cleanup.
  4. Search for detached chains. Find .then, .catch, .finally, .resolves, .rejects, async callbacks, event listeners, and timers that are not returned or awaited.
  5. Choose one completion contract. Use a returned promise, async and await, or a correctly implemented callback contract. Do not mix promise and callback completion in one test.
  6. Guard optional-looking assertion paths. Add expect.assertions or expect.hasAssertions where a catch, callback, branch, or loop could run zero assertions.
  7. Inspect unhandled rejections and late logs. Treat them as escaped work, then connect the responsible promise instead of suppressing the signal.
  8. Await cleanup. Ensure hooks and finally blocks finish before the test process or worker moves on.
  9. Run the negative control again. The repaired test must now fail at the intended assertion, not at a generic timeout or later test.
  10. Run the surrounding suite in a different order. Confirm no detached work or leaked resource changes another test's result.

For browser automation, the same ownership principle applies to commands and session cleanup. Selenium JavaScript async and await command order shows the WebDriver-specific version, while detecting unhandled Selenium WebDriver promises focuses on the escaped-command signal.

Apply the Workflow to the Repo Async Stream Tests

src/lib/companion/openrouter-core.test.ts contains useful positive examples because async generators add more than one ownership boundary. The local drain helper consumes an AsyncGenerator<string> with for await and returns a promise containing all chunks. Tests await openWithFallback, then await drain(textStream) before comparing the received array.

The primary-success test has this causal chain:

  1. Await openWithFallback so the selected model and stream are available.
  2. Assert the selected model.
  3. Await drain(textStream) so iteration completes.
  4. Compare all emitted chunks.
  5. Assert the mock call count.

If the test wrote expect(drain(textStream)).toEqual(['a', 'b']), it would compare a pending promise with an array. If it called drain(textStream).then without returning or awaiting the chain, it could finish before stream consumption and miss a mid-stream error.

The rejection cases are equally instructive:

TypeScript
it('throws when primary and fallback are empty', async () => {
  const open = vi.fn(() => empty());

  await expect(
    openWithFallback({ primary: 'p', fallback: 'f', open }),
  ).rejects.toThrow(OpenRouterError);

  expect(open).toHaveBeenCalledTimes(2);
});

The awaited .rejects assertion proves the operation fails. If it fulfills, the matcher rejects and the owned test promise fails. The call-count assertion runs only after the expected failure has been observed.

The mid-stream case wraps a for await loop in an async function passed to expect, then awaits .rejects.toThrow. That distinction matters because openWithFallback can succeed initially while iteration fails after partial content. The test also checks the chunks received before interruption and verifies that no fallback model was opened. It protects failure timing, partial evidence, and retry behavior as separate contracts.

Do not generalize this structure into a rule that every stream failure should be wrapped identically. The exact assertion depends on the stream API and runner. The reusable lesson is that the promise representing consumption must belong to the test. Testing fetch streams and backpressure in JavaScript provides a broader stream-testing context.

How Do Jest and Vitest Differ on Async Assertions?

Both runners support promise-returning tests, async test functions, and Jest-style .resolves and .rejects matchers. Both require the matcher promise to be owned. The details around callbacks and enforcement should not be blurred.

Jest documents done for callback APIs and throws when the same test also returns a promise. That protects against two completion signals. Vitest's current async guide recommends wrapping callback APIs in promises. An article that presents one shared callback snippet as universally interchangeable would hide a runner boundary.

Vitest's Expect documentation also records version changes for unawaited async assertions. It says newer versions fail rather than only warn in situations that previously created false-positive risk. That is useful protection, but explicit await remains necessary for readable ownership and compatibility with the configured version.

Runner configuration can strengthen detection, but it should not replace correct test structure. Treat unhandled rejections as failures, keep test and hook timeouts finite, and avoid process-level handlers that log and suppress errors. A handler that converts a rejection into a console message can turn a clear failure into the green-plus-warning pattern the suite is trying to eliminate.

CI should preserve stderr, runner diagnostics, and the test name attached to a late error. These artifacts help locate escaped work, but the repair remains in the code that owns the promise. Adding a retry to a falsely passing test is especially misleading: retries can only repeat the same missing ownership. First prove the test can fail under a negative control, then investigate any remaining nondeterminism.

Concurrent tests add another ownership dimension. Shared mock implementations, event emitters, fake timers, or process-level listeners can route one test's late work into another test's lifetime. Use local test context APIs where the runner documents them, avoid mutable module-level fixtures, and aggregate only promises that belong to the current concurrent case. Concurrency should not be enabled until each test has a self-contained completion promise.

Assertion counts exist in both ecosystems, with concurrency details documented by Vitest for concurrent tests. If using test.concurrent, follow the current Vitest advice about the local test context's expect. Do not copy a global assertion-count pattern into concurrent tests without checking ownership.

Polling APIs are a different concern. Once a promise belongs to the test, the test may still need to wait for eventually consistent state. Playwright expect.poll and toPass covers a browser-testing option. Polling cannot repair a promise that escaped the test function.

Frequently Asked Questions About Async Test False Positives

Why does a Jest test pass when an assertion inside then fails?

The test function returned before the .then callback executed because its promise chain was not returned or awaited. Return the chain or make the test async and await it. The runner then keeps the test open until the assertion settles and records its failure in the correct test.

Is returning a promise the same as awaiting it?

Both can give the runner ownership of completion. Returning works well for one chain. Async and await often read more clearly when several operations depend on one another. The failure appears when a promise is neither returned nor awaited, or when a helper hides a detached promise.

Do resolves and rejects need to be awaited?

Yes, unless the assertion promise is returned from the test. The modifiers produce asynchronous matchers. Without ownership, fulfillment or rejection can be evaluated after the test ends. Use await expect(...).resolves... or return expect(...).rejects....

When should I use expect.assertions?

Use it when a required assertion sits in a catch block, callback, conditional branch, or known collection traversal that might execute zero times. It proves the expected count ran. It does not replace returning or awaiting the asynchronous operation that leads to those assertions.

Can an async test use done?

Do not combine an async promise-returning test with Jest's done callback. Use one completion model. For a legacy callback API, either implement the callback contract carefully or wrap it in a promise and await it. Check the active runner's current callback guidance.

How can I prove the repair works?

Use a negative control. Make the expected rejection fulfill, prevent the required callback, or return a value that should fail the assertion. The test must fail at the expected line. Then restore the implementation and retain the same promise ownership and assertion guards.

Conclusion: Give Every Async Assertion One Owner

Preventing JavaScript async test false positives is less about adding waits and more about giving the runner a complete promise. Return or await operations, assertion chains, async iterators, helpers, hooks, and cleanup. Use assertion counts where a settled promise could still take a path with zero assertions. Keep callback and promise completion from competing.

The final proof is a negative control. A reliable async test fails when its protected behavior is deliberately broken, and it fails inside the test that owns the work. Once that is true, event-loop analysis, polling, and timeout policy can address real timing behavior instead of masking an escaped assertion. The Playwright clock and async timeout scenarios offer additional practice after the runner lifecycle is correct.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed July 25, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official jestjs.io reference

    jestjs.io

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official jestjs.io reference

    jestjs.io

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official vitest.dev reference

    vitest.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official vitest.dev reference

    vitest.dev

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

Why does a Jest test pass when an assertion inside then fails?

The test function can reach its end before the promise chain settles if that chain is neither returned nor awaited. Jest records the test as complete, while the later assertion failure becomes detached work or an unhandled rejection. Return the chain or make the test async and await it.

Is returning a promise the same as awaiting it in a test?

Both can give the runner ownership of promise completion when used correctly. Returning is natural for a single chain, while async and await are often clearer for several ordered operations. Do not return an unrelated value, and do not call an async helper without returning or awaiting its promise.

Do resolves and rejects assertions need to be awaited?

Yes, or their promise must be returned from the test. These modifiers create asynchronous assertions. Without ownership, the test may finish before the matcher observes fulfillment or rejection. Current Vitest also documents version-specific enforcement for unawaited async assertions, so check the runner version rather than relying on warnings.

When should I use expect.assertions?

Use expect.assertions when the expected assertion sits inside a callback, catch block, conditional branch, or collection path that might never execute. It proves the expected count ran. Use expect.hasAssertions when at least one assertion is required but the exact count legitimately varies with the tested input.

Can an async test use the done callback?

Do not combine a promise-returning async test with Jest's done callback. That gives the test two completion channels, and Jest rejects the ambiguity. For a callback-only API, either use done with careful error forwarding or wrap the callback in a promise and await that single completion contract.

How can I prove a test fails when its async path is broken?

Run a negative control: deliberately return a wrong value, remove an expected callback, or make an expected rejection fulfill. The test must fail at the intended assertion with useful output. Then restore the implementation and keep the ownership structure that made the negative control visible to the runner.