bitbank techblog

jest の toThrow でうっかり

これは ビットバンク株式会社 Advent Calendar 2020 の 1 日目の記事です。

はじめに

こんにちは、普段はサーバサイドでエンジニアしている monja です。

今日はそんな日常で使っている、弊 blog でも激オシの jest でちょっと悩んだ話をします。

なおこの記事で使用した各モジュールのバージョンは以下の通りです:

@types/jest 26.0.15
jest 26.6.3
ts-jest 26.4.4
typescript 3.9.7

不思議なFAILに遭遇

ある日の monja「よーし今日はここを修正するぞー」

// somecode.ts
export async function halfAmount(amount: number): Promise<number> {
  // await some promise...
  await new Promise(resolve => setTimeout(resolve, 100));

  if (9000 < amount) {
    throw "it's over 9000!";
  }

  return amount / 2;
}
// somecode.spec.ts
import { halfAmount } from './somecode';
describe('halfAmount', () => {
  it('should return half of an argument', () => {
    expect(halfAmount(5)).toBe(2.5);
  });
});

※登場するコードは架空であり、実在するプロダクトとは何ら関係ありません

monja「仕様変更で amount が 9000 でなく 10000 まで指定できるようになったので、実装をこうして〜…」

@@ -3,8 +3,8 @@
   // await some promise...
   await new Promise((resolve) => setTimeout(resolve, 100));

-  if(9000 < amount) {
-    throw "it's over 9000!";
+  if(10000 < amount) {
+    throw "it's over 10000!";
   }

   return amount / 2;

「…おや? ここ異常系のテストないじゃん! 追加しよ〜」

@@ -4,4 +4,7 @@
   it('should return half of an argument', async () => {
     await expect(halfAmount(5)).resolves.toBe(2.5);
   });
+  it('should throw if an argument is over 10000', async () => {
+    await expect(halfAmount(12345)).rejects.toThrow("it's over 10000!");
+  });
 });

「というわけで jest るぞー」

$ yarn jest
...
 FAIL  .../somecode.spec.ts
...
  ● halfAmount › should throw if an argument is over 10000

    expect(received).rejects.toThrow(expected)

    Expected substring: "it's over 10000!"

    Received function did not throw

       5 |     });
       6 |     it('should throw if an argument is over 10000', async () => {
    >  7 |         await expect(halfAmount(12345)).rejects.toThrow("it's over 10000!");
         |                                                 ^
       8 |     });
       9 | });
      10 |
...

おや? テストがコケましたね。"Received function did not throw" って、throwしてるのに? なんでだろう…?

原因はなんだろう

他の所ではちゃんと非同期な reject を toThrow でマッチできていたのに。

そう思って

oO( toThrow にメッセージ指定しなければ pass するけどエラー判別したい所でつらいなぁ… )

とか

oO( 同期的なコードなら toThrow うまくいくのになぁ… )

などと試行錯誤しましたが、どうもうまくいかない。

そこでおもむろに jest の toThrow マッチャのソース を覗いてみました。

const toThrowExpectedString = (
...
): SyncExpectationResult => {
  const pass = thrown !== null && thrown.message.includes(expected);

...

あっ、 thrown .message を見ていますね!

ここで実装の throw しているところを思い出してみましょう:

if (10000 < amount) {
  throw "it's over 10000!";
}

たしかに、今回修正した実装では Error ではなく string を throw していますね!!
string には message というプロパティは無いので、マッチしないのはある意味あたりまえですね。

修正してみる

ここでテストコードを expect(...).rejects.toBe("it's over 10000!") のように修正することもできますが、
string を throw するよりは Error を throw したほうがコールスタックの情報も残るので、今回は実装の throw の方を直してみましょう:

@@ -4,7 +4,7 @@
   await new Promise((resolve) => setTimeout(resolve, 100));

   if(10000 < amount) {
-    throw "it's over 10000!";
+    throw new Error("it's over 10000!");
   }

   return amount / 2;

ということで、もいちど jest をエイヤ!

$ yarn jest
...
 PASS  .../somecode.spec.ts
  halfAmount
    ✓ should return half of an argument (105 ms)
    ✓ should throw if an argument is over 10000 (107 ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
...

無事テストが正しく通るようになりましたね!

……toThrowとは…… 🤔

まとめ

rejects.toThrow('...') がうまくマッチしない時は、テストコードもそうだけど、そもそもどんな値が throw(reject)されているか見てみよう!

以上、monja がお送りしましたー。

Author image
About monja
よく焼きでお願いします。
expand_less