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.
In this guide10 sections
- 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?
- When Should You Count Assertions or Own Callbacks?
- Follow a Numbered False-Positive Repair Workflow
- Apply the Workflow to the Repo Async Stream Tests
- How Do Jest and Vitest Differ on Async Assertions?
- Frequently Asked Questions About Async Test False Positives
- Why does a Jest test pass when an assertion inside then fails?
- Is returning a promise the same as awaiting it?
- Do resolves and rejects need to be awaited?
- When should I use expect.assertions?
- Can an async test use done?
- How can I prove the repair works?
- 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:
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:
test('saves the order', () => {
return saveOrder().then((result) => {
expect(result.saved).toBe(true);
});
});Or use async and await:
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
.resolvesand.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, andafterAllhooks.- 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:
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:
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 smell | Why the test can finish | Typical symptom | Reliable rewrite | Additional guard |
|---|---|---|---|---|
Unreturned .then or .catch | Outer function returns undefined | Green test plus later rejection | Return the chain or await the promise | Negative control |
Unawaited .resolves or .rejects | Matcher promise is detached | Expected failure is missed | Await or return the assertion | Assertion count where useful |
Assertion only in catch | Fulfillment skips the catch | Green test with zero assertions | Use awaited .rejects | expect.assertions(1) |
| Async helper returns nothing | Caller cannot own nested work | Cleanup or assertion runs late | Return or await inside helper | Type helper as Promise<void> |
| Callback assertion without completion | Test ends before callback | Intermittent pass or warning | Wrap callback in a promise | expect.hasAssertions() |
Async forEach callback | forEach ignores callback promises | Loop assertions finish late | Use for...of or await Promise.all | Exact assertion count |
| Event test starts listener only | No promise represents event | Test passes if event never fires | Resolve or reject an event promise | Timeout at owned boundary |
| Cleanup not awaited | Test verdict precedes release failure | Leaks affect later tests | Await cleanup in a hook or finally | Fail 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:
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:
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:
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:
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:
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:
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.
- 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.
- 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.
- Trace every async helper. Verify each helper returns a promise covering its internal operations, assertions, and required cleanup.
- Search for detached chains. Find
.then,.catch,.finally,.resolves,.rejects, async callbacks, event listeners, and timers that are not returned or awaited. - 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.
- Guard optional-looking assertion paths. Add
expect.assertionsorexpect.hasAssertionswhere a catch, callback, branch, or loop could run zero assertions. - Inspect unhandled rejections and late logs. Treat them as escaped work, then connect the responsible promise instead of suppressing the signal.
- Await cleanup. Ensure hooks and
finallyblocks finish before the test process or worker moves on. - Run the negative control again. The repaired test must now fail at the intended assertion, not at a generic timeout or later test.
- 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:
- Await
openWithFallbackso the selected model and stream are available. - Assert the selected model.
- Await
drain(textStream)so iteration completes. - Compare all emitted chunks.
- 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:
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.
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.
- 01Official jestjs.io reference
jestjs.io
Primary documentation selected and verified for the claims in this guide.
- 02Official jestjs.io reference
jestjs.io
Primary documentation selected and verified for the claims in this guide.
- 03Official vitest.dev reference
vitest.dev
Primary documentation selected and verified for the claims in this guide.
- 04Official 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.
RELATED GUIDES
Continue the learning route
GUIDE 01
JavaScript Async Await Interview Questions for QA
JavaScript async await interview questions QA: practical design, implementation, debugging, CI, metrics, and interview guidance for QA, SDET, and automation engineers.
GUIDE 02
Async Orchestration Architecture for JavaScript Test Runners
JavaScript async orchestration architecture: practical design, implementation, debugging, CI, metrics, and interview guidance for QA, SDET, and automation engineers.
GUIDE 03
Debug JavaScript Unhandled Promise Rejections in Tests
A practical guide to debug unhandled promise rejection tests, covering design, implementation, debugging, scale, measurable release gates, and senior interview scenarios.
GUIDE 04
Selenium JavaScript Async/Await Patterns That Preserve Command Order
Preserve Selenium JavaScript command order with explicit async/await, sequential iteration, failure-safe helpers, and awaited WebDriver cleanup in Node tests.
GUIDE 05
Retrying Async State with expect.poll and expect.toPass in Playwright
Use Playwright expect.poll and expect.toPass for bounded asynchronous checks, with intentional intervals, idempotent probes, and useful failures.