🎭

To be, or not to be: JestのtoBe()の動きを知ろう!

2023/12/20に公開

こんにちは!サイボウズ株式会社フロントエンドエンジニアの おぐえもん(@oguemon_com) です。

JavaScriptのテスティングフレームワークの定番であるJestには、toBe()という便利なMatcher(評価用関数)が用意されています。

toBe()expect(A).toBe(B)の形式で記すことでABが等しいかどうかを評価してくれるのが特徴で、最も基本的な存在なのでテストコード初心者の方も多用されていることと思います。しかし、toBe()の挙動を正しく認識しておかないと、思わぬミスを招きかねません。そこで、この記事では、ある2つの値がtoBe()で等価判定されるのか、はたまた等価判定されないnot.toBe()なものなのかを見ながら、その挙動に迫ります。

この記事は執筆時点の最新版であるjest@29.7.0の挙動に基づいています。

toBe(), or not.toBe()?

To be, or not to be, that is the question.
『ハムレット』第3幕第1場より

上に示したハムレットの有名な独白には様々な解釈と日本語訳が存在することが知られていますが、この章ではシンプルに各ケースでexpect(A).toBe(B)expect(A).not.toBe(B)のどちらでテストがパスするのか(=前者でパスするか否か)をクイズ形式で見ていきます。

基本編

Q1

まずはこちらから。

expect(1 + 2).toBe(3);

ごく基本的なnumber型の整数同士です。これはみなさんの想像通り、toBe()が正解です!つまり、上のコードはパスします。

これに限らず、string型やboolean型をはじめとするプリミティブデータ型同士の比較は、原則としてtoBe()で等価を確認できます。

// これらは全てパスします
expect(null).toBe(null);           // null
expect(undefined).toBe(undefined); // undefined
expect(true).toBe(true);           // boolean
expect(0.1).toBe(0.1);             // number
expect(10n).toBe(10n);             // bigint
expect("Hello!").toBe("Hello!");   // string

ちなみに、nullundefinedはそれぞれ.toBeNull().toBeUndefined()で比較することもできます。これらはtoBe()よりも失敗時の出力が簡潔になっています。


toBeNull()toBeUndefined()toBe()よりも出力がスッキリしている

数値編

プリミティブデータ型の中でも数値周りには取り扱いに注意が必要なケースがいくつかあります。

Q2

こちらはどうでしょうか?

expect(0.1 + 0.2).toBe(0.3);

手計算すると0.1 + 0.2 = 0.3なのでtoBe()で正しそうですが、実はnot.toBe()です。つまり、上のコードはパスしません。

なぜならば、0.10.2のような小数(浮動小数点数)やその計算結果には、内部値にごく小さな誤差が生じることがあり、その場合に正確な計算結果と完全には一致しないからです。

expect(received).toBe(expected) // Object.is equality

Expected: 0.3
Received: 0.30000000000000004 // <-- 0.1 + 0.2の計算結果は0.3ピッタリでない

   5 |
   6 |   test("0.1 + 0.2 = 0.3", () => {
>  7 |     expect(0.1 + 0.2).toBe(0.3);
     |                       ^
   8 |   });

こういうときは、両者の近似的な比較をしてくれるtoBeCloseTo()を使いましょう。toBeCloseTo()を使うとデフォルトでは両者の差が±0.005未満ならばパスします。

// これはパスします
expect(0.1 + 0.2).toBeCloseTo(0.3);

浮動小数点数に潜むごく小さな誤差は、Jestに限らずJavaScript(ひいてはプログラミング言語全般)を扱う上での基本的な注意点です!

Q3

ものすごく大きい数値を使ったこちらはどうでしょうか?

// 1京 vs 1京1
// JavaScriptでは数字をアンダースコアで区切ることができる
expect(1_0000_0000_0000_0000).toBe(1_0000_0000_0000_0001);

1京と1京1は明らかに異なる数値なのでnot.toBe()になりそうなものですが、実際はtoBe()です。上のコードはパスしてしまいます。

なぜならばnumber型では絶対値が2^{53}(およそ9007兆)以上の整数を正確に表現できず、指定した通りの値が内部で扱われるとは限らないからです。


大きすぎる数値リテラルに対する警告

ちなみに、このような下限/上限値はNumber.MIN_SAFE_INTEGERNumber.MAX_SAFE_INTEGERでそれぞれ取得することができます。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Data_structures#数値型

限度を上回る整数値を扱う場合は、bigint型を使いましょう。整数値の末尾にnと付けるだけでOKです。

// bigint型同士の比較ならば、not.toBe()でちゃんとパスする
expect(1_0000_0000_0000_0000n).not.toBe(1_0000_0000_0000_0001n);

Truthy / Falsy編

JavaScriptでは、if文やfor文の条件式にboolean型以外の値を指定した場合でも、型変換を通じてその値がtruefalseかを判定してくれます。このとき、trueと判定される値をtruthyfalseと判定される値をfalsyと呼びます。

例えばnull, undefined, 0, ""(空文字)などの値がfalsyで、それ以外の値はtruthyです。

if (null) {
  // nullはfalsyなので、ここは実行されない
}

Q4

ではこちらはどうでしょうか?

expect(null).toBe(false);

nullはfalsyですが、not.toBe()です。上のコードはパスしません。

これは、toBe()は両者の比較にあたって==演算子で比較するときような型変換を行わないからです。boolean型の値においてはtrue同士 / false同士で一致しなければパスしません。

Jestでは、truthy / falsyの判定を行うためのtoBeTruthy()toBeFalsy()が用意されています。truthy / falsyを判定するにはこちらを使用しましょう。

// これはパスします
expect(null).toBeFalsy();

ただし、これではtrue / false以外の値もパスしうるので、boolean型の値を厳密に評価したいときは引き続きtoBe()を使うのが吉です。

配列 / オブジェクト編

今まではプリミティブデータ型を扱ってきました。ここでは配列やオブジェクトなどのプリミティブデータ型でない値の場合を見ていきましょう。

Q5

同じ中身で構成された簡単な配列・オブジェクト同士の比較です。

// 配列
expect([1]).toBe([1]);
// オブジェクト
expect({ a: 10 }).toBe({ a: 10 });

こちら、両方ともnot.toBe()です。上のコードはパスしません。

JavaScriptにおけるオブジェクトは参照型と呼ばれるものであり、プリミティブデータ型と違って変数には値そのものではなく、その値が格納されているメモリ領域を指す参照が格納されます。

上の例のexpect({ a: 10 }).toBe({ a: 10 })にある{ a: 10 }は同じプロパティを持っていますが、それぞれ別に宣言されたものであり、別のメモリ領域を指す別物のオブジェクトなので、toBe()で比較すると異なるものとして判定されてしまいます。これは、オブジェクトの一種である配列についても同様です

逆に言うと、比較対象の参照が共に同じならば良いので、次の例はパスします。

// これはパスします
const obj1 = { a: 10 };
const obj2 = obj1;
expect(obj1).toBe(obj2);

異なる配列やオブジェクトの中身の一致をテストしたいことは多いと思います。そんなときは、toEqual()を使いましょう。toEqual()はオブジェクトの中身(各プロパティ)を比較してくれます。

// これはパスします
expect([1]).toEqual([1]);
expect({ a: 10 }).toEqual({ a: 10 });
// 日付も比較できる!
expect(new Date("2023-12-20")).toEqual(new Date("2023-12-20"));

ただし、toEqual()ではundefinedなキーを無視することがあるなど、やや厳密性に欠ける部分があるので、より厳密な比較をしたいときは、toStrictEqual()がオススメです。

// これはパスします
expect([1, 2, undefined]).toEqual([1, 2]);
expect({ a: undefined }).toEqual({});
// これはパスしない
expect([1, 2, undefined]).not.toStrictEqual([1, 2]);
expect({ a: undefined }).not.toStrictEqual({});

以上、クイズでした!何問正解できましたか?

toBe()がやってくれること

ここまで具体例とともにtoBe()の挙動を見てきましたが、toBe()の比較の挙動は実はシンプルで、Object.is(A, B)を実行しているだけです。このことはJestのドキュメントに明記されている他、ソースコードを見ても分かります。

https://github.com/jestjs/jest/blob/v29.7.0/packages/expect/src/matchers.ts#L77-L85

Object.is()は、2つの値が機能的に同一かを評価します。プリミティブデータ型の値が等しいかどうか、参照型の値が同じ参照を持つかどうかを型変換を行わずに判定する点は、厳密等価の演算子===と同じです。しかし、===とは微妙に異なる点が2つあります。

  1. 3つのゼロ値+00-0について、+0 vs -00 vs -0===は等価であると判定しますが、Object.is()等価でないと判定します。
    • ちなみに+0 vs 0===Object.is()も等価であると判定します。
  2. NaN vs NaN===は等価でないと判定しますが、Object.is()等価であると判定します。
// これらは全てパスする
// 1. +0, 0, -0の比較
expect(+0).not.toBe(-0);
expect(0).not.toBe(-0);
expect(+0 === -0).toBe(true);
expect(0 === -0).toBe(true);
// 2. NaNの比較
expect(NaN).toBe(NaN);
expect(NaN === NaN).toBe(false);

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Object/is

ただし、こうした例外はテストを実装していてそう頻繁に出てくるわけではないので、「toBe()===とほぼ同じだけど、ゼロとNaN周りは注意しないと」くらいの認識で特段問題ないかなと思います。

まとめ

JavaScriptのテスティングフレームワーク・JestのMatcherの中で最も基本的なものであるtoBe()の挙動を、キワドイ例とともに確認してきました。

toBe()はJestの最も基本的なMatcherで、等価判定条件はObject.is()と全く同じ(===ほとんど同じ)という点で非常にシンプルですが、いざ活用するとなると、数値やオブジェクトの扱いなど、JavaScriptの仕様が絡む注意点がいくつか潜んでいることが分かりました。

テスティングフレームワークのことだけでなく、その根底にあるプログラミング言語の動きについても理解を深めて、より間違いの少ないテストコードを書くことができるよう努めていきたいですね。

また、Jestには今回紹介したもの以外にも痒い所に手が届くMatcherがたくさん用意されているので、ぜひ公式ドキュメントを読んでみてください!

https://jestjs.io/ja/docs/expect

GitHubで編集を提案
サイボウズ フロントエンド

Discussion