Skip to content

fix: record a call as having thrown regardless of the thrown value - #2763

Open
mrpmohiburrahman wants to merge 1 commit into
sinonjs:mainfrom
mrpmohiburrahman:fix/spy-threw-undefined-exception
Open

fix: record a call as having thrown regardless of the thrown value#2763
mrpmohiburrahman wants to merge 1 commit into
sinonjs:mainfrom
mrpmohiburrahman:fix/spy-threw-undefined-exception

Conversation

@mrpmohiburrahman

@mrpmohiburrahman mrpmohiburrahman commented Aug 20, 2026

Copy link
Copy Markdown

Purpose (TL;DR)

Fixes #2471. A spy over a function that throws undefined swallowed the throw, and spy.threw() then reported that the call never threw.

const a = sinon.spy(() => {
  throw undefined;
});

try {
  a();
} catch (e) {
  // never reached
}

sinon.assert.threw(a); // AssertError: spy did not throw exception

Background (Problem in detail)

The invoke path records what was thrown, never whether anything was thrown, so both readers infer it from the value. They infer it differently, so the two symptoms have different scope.

proxy-invoke.js gated the rethrow on exception !== undefined. Since exception is only assigned in the catch, a thrown undefined looks exactly like a call that never threw, so the throw was dropped and the caller got a normal return. That part is specific to undefined.

proxy-call.js's threw() used Boolean(this.exception) and !this.exception, which test truthiness rather than presence. Every falsy thrown value reported threw() === false: undefined, null, 0, "", false, NaN, 0n. So throw null already propagated correctly but was still reported as not having thrown, and spy.threw(null) returned false with it. Fixing the recording rather than the two readers closes both cases at once.

Solution

A didThrow boolean is set where the catch runs and carried to the call object as call.didThrow. The rethrow and threw() both read it.

The flag goes in its own parallel array (didThrowValues) beside exceptions, deliberately: spy.exceptions is public, so writing a sentinel into it to mark a real throw would be visible to anyone reading that array. The thrown value is left alone, and the regression test asserts spy.exceptions[0] is still undefined. threw() is the single root for the assertion surface, so one edit there covers call.threw(), spy.threw(), spy.alwaysThrew() and the two sinon.assert mirrors. The array is also initialised in the proxy template, cleared in resetHistory, and copied in withArgs's back-fill.

One subtlety for review: the old falsy short-circuit was incidentally guarding the this.exception.name read in the argument form. That read is now reachable with a nullish exception, so it is this.exception?.name, with a test for it.

Worth flagging for the changelog: threw() now returns true for every falsy thrown value, not just undefined. That follows from fixing the root cause rather than the reported symptom, but it is a behaviour change.

How to verify

  1. Check out this branch
  2. npm install
  3. npm run test-node

1561 passing, 12 pending, 0 failing, against 1556 passing before this change. Five new tests, no existing test affected. npm run lint and npm run prettier:check are clean.

Reverting only src/ turns three of the five red: the #2471 test and the two falsy-value cases. The other two should pass either way, since they pin the ?. guard and the case where a spy simply returns undefined.

The new tests capture throws with a plain try/catch rather than assert.exception, because @sinonjs/referee's assert.exception does if (!err) on the caught value and cannot see a falsy throw either. That may be worth a separate look in referee.

On the wontfix label: stale[bot] applied it in December 2023, right after its own staleness comment, while Help wanted and Difficulty: Easy came from @fatso83. I read it as bot noise rather than a decision. The approach here follows @fatso83's framing in the thread, which the reporter confirmed.

Checklist for author

  • npm run lint passes
  • References to standard library functions are cached.

The invoke path recorded what was thrown but never whether anything was
thrown, so both consumers re-derived "did it throw" from the value.

`proxy-invoke.js` gated the rethrow on `exception !== undefined`, but
`exception` is only assigned inside the catch, so `throw undefined` is
indistinguishable from a call that never threw. The throw was swallowed
and the caller saw a normal return.

`proxy-call.js`'s `threw()` used `Boolean(this.exception)` and
`!this.exception`, which are truthiness tests rather than presence
tests. Every falsy thrown value reported `threw() === false`, so
`throw null` propagated correctly while still being reported as not
having thrown.

Record the fact instead of inferring it from the value: a `didThrow`
boolean is set where the catch runs and carried to the call object in
a parallel array, leaving `spy.exceptions` and its contents untouched.

Fixes sinonjs#2471.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sinon not throwing the error that been thrown when the error is undefined and also fail to assert

1 participant