👨‍🦳

Sinon.jsの`stub.returns(Promise.reject())`と`stub.rejects()`の違い

2023/05/24に公開

結論

stub.returns(Promise.reject())はrejected promiseを必ず生成するがstub.rejects()はそのstubが呼ばれない限りrejected promiseを生成しない。
よって、stubされたメソッドが呼び出されない場合にstub.returns(Promise.reject())stub.rejects()は異なる挙動をする。

背景

stub.returns()stub.rejects()は共に関数をモックしrejectされたPromiseを返すためのSinon.jsのAPIです。
https://sinonjs.org/releases/latest/stubs/#properties
Jestを用いた二つのテストスイートで内容はほぼ同じも関わらず、片方は通ってもう片方は(node:34070) UnhandledPromiseRejectionWarning: undefinedというNode.jsのUnhandled Promise Rejectionエラーが出る、という不可思議なバグを業務中に踏んでしまいました。以下は状況を再現するための簡易コードです。

// モック対象のダミーAPI
const fetchWeatherModule = {
  fetchWeather: () => Promise.resolve('sunny'),
};

// テストしたい関数
const getWeather = async (city: 'Tokyo' | 'Osaka') => {
  if (city === 'Tokyo') {
    return 'rainy';
  }

  let weather: string | null;
  try {
    weather = await fetchWeatherModule.fetchWeather();
  } catch {
    weather = null;
  }
  return weather;
};

// テストコード
import { assert } from 'chai';
import * as Sinon from 'sinon';

describe('getWeather when fetchWeather returns rejected Promise', () => {
  let fetchWeatherStub: Sinon.SinonStub;
  beforeEach(() => {
    fetchWeatherStub = Sinon.stub(fetchWeatherModule, 'fetchWeather').returns(Promise.reject());
  });
  afterEach(() => fetchWeatherStub.restore());

  it('returns rainy for Tokyo', async () => {
    // this fails @___@
    // (node:34070) UnhandledPromiseRejectionWarning: undefined
    // (Use `node --trace-warnings ...` to show where the warning was created)
    // (node:34070) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 1)
    // (node:34070) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
    assert.equal(await getWeather('Tokyo'), 'rainy');
  });

  it('returns null for Osaka', async () => {
    // this passes! ^___^
    assert.equal(await getWeather('Osaka'), null);
  });
});

何が起きているのか

この二つのテストケースは、fetchWeatherという関数がリジェクトされたPromiseを返すようにモックをした上で、同一のgetWeatherという関数に異なる引数を渡して返り値をチェックしています。
getWeathercity: 'Tokyo'を渡す場合は'rainy'をearly returnし、city: 'Osaka'を渡す場合はtry-catchと共にfetchWeatherを呼ぶので、どちらのケースにおいてもcatchされないPromiseは発生しないように見えます。
ここでミソとなるのは、city: 'Tokyo'の場合はearly returnされてgetWeather内のtry-catchに到達していない、つまりstubしたfetchWeatherが呼び出されていないということです。実は、beforeEachの中でPromise.reject()をモックの返り値として宣言した時点で既にrejected promiseが生成されてしまっており、early returnしただけでこれがcatchされていないためUnhandled Promise Rejectionエラーを引き起こしてしまいます。
この場合、stub.rejects()を使うとそのstubが呼ばれない限りrejected promiseを生成しないので、該当の問題は解決します。

  beforeEach(() => {
-    fetchWeatherStub = Sinon.stub(fetchWeatherModule, 'fetchWeather').returns(Promise.reject());
+    fetchWeatherStub = Sinon.stub(fetchWeatherModule, 'fetchWeather').rejects();
  });

どっちを使えばいいの

個人の意見ですが、基本的にモックされたメソッドが呼ばれないにも関わらずモック処理の中のrejected promiseが漏れ出してしまうのは微妙に感じるので、特別な意図がないのであればstub.returns(Promise.reject())ではなくstub.rejects()を使っておく方が無難だと思います。

Discussion