🎃

JavaScriptでのモックの実装

2023/06/26に公開

Jestのmockとspy

言わずと知れたJavaScriptにおけるテストフレームワークであるJestですが、提供している機能にmockおよびspyというものがあります。
これらを使うと、テスト対象コード中に出現するオブジェクトの挙動をテスト用の挙動に差し替えたり、該当オブジェクトがテスト内でどのように扱われたか(呼び出し回数や呼び出し時の引数)を確認することが可能です。

export const hoge = () => {
  return 1;
};

export const fuga = () => {
  return hoge() * 100;
};

例えば、上記のコードにおける関数fugaをテストしたい場合、hogeをモックに差し替えることで、fugaの振る舞いに注目したテストコードを記述することができます。

import * as modules from ".";
import { fuga } from ".";

describe("hoge", () => {
  beforeEach(() => {
    jest.resetAllMocks();
  });
  it("when return value 100", () => {
    jest.spyOn(modules, "hoge").mockReturnValue(100);
    expect(fuga()).toBe(10000);
    expect(modules.hoge).toBeCalledTimes(1);
  });
  it("when return value 100", () => {
    jest.spyOn(modules, "hoge").mockReturnValue(50);
    expect(fuga()).toBe(5000);
    expect(modules.hoge).toBeCalledTimes(1);
  });
});

ここで気になるのはjest.spyOn の振る舞いです。
この関数によって対象のオブジェクトの関数をモックに置き換えているのは想像がつきますが、どのようなAPIやアプローチでそれを実現しているのか、内部実装について調査してみました。

JavaScriptにおけるprototype

mockおよびspyの挙動を理解するためには、JavaScriptにおけるprototypeに対する理解が必要になります。ここでは軽く紹介することにします。

prototypeプロパティとは、関数を含めあらゆるオブジェクトに含まれるプロパティで、オブジェクトもしくはnullを指定することができます。
prototypeは動的に設定することが可能であり、プログラミングにおける継承を実現することを主の目的として存在しています。

下の例では、function hoge1をコンストラクタとして生成されたオブジェクトh1h2は、標準でプロパティfuga,fugafugaを持ち、そのprototypeとしてhoge1のprototypeが設定されます。(Functionコンストラクタであるhoge1は、デフォルトのprototypeが与えられています。)

function hoge1() {}
hoge1.prototype.fuga = 1;
hoge1.prototype.fugafuga = () => {
  return "fugafuga";
};

function hoge2() {}
hoge2.prototype = hoge1.prototype;

const h1 = new hoge1();
const h2 = new hoge2();

console.debug(h1.fuga); // 1
console.debug(h1.fugafuga()); // fugafuga
console.debug(h2.fuga); // 1
console.debug(h2.fugafuga()); // fugafuga

prototype chain

prototype自体もオブジェクトであるため、それもまたprototypeを持つことになります。
JavaScriptのオブジェクトは、prototypeを順に追いかけていくと必ず最後はnullに到達する仕様になっており、このようなprototypeの連なりをprototype chainといいます。

JavaScriptでは、あるオブジェクトのプロパティにアクセスしようとした際にそのプロパティがオブジェクトに定義されていない場合、prototypeプロパティに指定されているオブジェクトに定義されているかを暗黙的に探しに行きます。
もし探しに行った先で該当のプロパティが定義されていることがわかった場合はそれを返し、見つからなかった場合はundefinedが返されます。
こうしてオブジェクトに直接定義されていないプロパティも、まるでそのオブジェクトに定義されているかのように振る舞うようにすることがprototype chainの役割の一つです。

例)単純なFunction コンストラクタのprototype chain

function hoge(){}
console.log(hoge.prototype.__proto__.__proto__); //null

hoge.prototype.__proto__Object.prototypeになります

Jestにおけるmockの実装

以上を踏まえて、実際にJestでのmockの実装を見ていきます。
jestのspyOnは引数としてオブジェクトとそのメソッド名を取りますが、対象となるメソッド(オブジェクト)を探索する際には、prototype chainを考慮しています。

以下は実際にjestで記述されている、spyOnの一部ソースコードになります。(mockの差し替え部分)

const isMethodOwner = Object.prototype.hasOwnProperty.call(
  object,
  methodKey,
);

let descriptor = Object.getOwnPropertyDescriptor(object, methodKey);
let proto = Object.getPrototypeOf(object);

while (!descriptor && proto !== null) {
  descriptor = Object.getOwnPropertyDescriptor(proto, methodKey);
  proto = Object.getPrototypeOf(proto);
}

let mock: Mock;

if (descriptor && descriptor.get) {
  const originalGet = descriptor.get;
  mock = this._makeComponent(
    {type: 'function'},
    {
      reset: () => {
        this._attachMockImplementation(mock, original);
      },
      restore: () => {
        descriptor!.get = originalGet;
        Object.defineProperty(object, methodKey, descriptor!);
      },
    },
  );
  descriptor.get = () => mock;
  Object.defineProperty(object, methodKey, descriptor);
} else {
  mock = this._makeComponent(
    {type: 'function'},
    {
      reset: () => {
        this._attachMockImplementation(mock, original);
      },
      restore: () => {
        if (isMethodOwner) {
          object[methodKey] = original;
        } else {
          delete object[methodKey];
        }
      },
    },
  );
  // @ts-expect-error: overriding original method with a mock
  object[methodKey] = mock;
}

Object.getOwnPropertyDescriptorを利用し、モック化したいメソッドが定義されたprototypeをprototype chainを利用して探索しています。

ここで疑問なのは、なぜわざわざどのprototypeに定義されているのかを確認しているのかという点です。
オブジェクトのメソッドをモックに差し替えたいだけならば、object[methodName] = fnのように直接メソッドを上書きすれば済みそうなものです。(readonlyでない場合のみ)

プロパティ記述子

ここで注目したいのが、getOwnPropertyDescriptorの結果に応じた分岐が仕込まれている点です。実はJavaScriptでのオブジェクトのpropertyの指定の仕方は2種類に大別することができます。

  1. データ記述子による定義: プロパティの値を直接指定する
    例)
    Object.defineProperty(obj, 'key', {
        enumerable: false,
        configurable: false,
        writable: false,
        value: 'static'
    });
    
  2. アクセサー記述子による定義: プロパティの値をgetter、setterで指定する
    例)
    let bValue = 38;
    Object.defineProperty(o, 'b', {
       get() { return bValue; },
       set(newValue) { bValue = newValue; },
       enumerable: true,
       configurable: true
    });
    

詳細:Object.defineProperty

データ記述子の場合ではプロパティの取得、更新の際にロジックが介在する余地はないのですが、プロパティがアクセサー記述子によって定義されていた場合は取得、更新時になんらかの処理が走る可能性があるということになります。

Jestでのプロパティ記述子の考慮

それを踏まえて改めてJestのコードを見ると、下記の2点に気づくかと思います。

  • プロパティがデータ記述子によって指定されていた場合は、元のオブジェクトのメソッドを直接上書きする
  • プロパティがアクセサー記述子によって指定されていた場合は、元のオブジェクトのgetterのみを上書きする

おそらくですが、このような実装になっているのはアクセサー記述子で定義されていた場合に影響を最小限に抑えるために、getterのみを置き換えたいというのが大きいと思われます。
現状ではプロパティがどの記述子で定義されているかは、getOwnPropertyDescriptorを使って判別する以外のアプローチはなさそうなので、プロパティが定義されているprototypeを探索する処理は不可欠になっているようです。

ちなみに、jestの他にも、jasminやsinonjs(cypress) といったフレームワークもモックの置き換えについて同様のアプローチを取っているようでした。

まとめ

  • JavaScriptのオブジェクトではプロパティアクセス時にprototype chainを利用する
  • Jestや主要なJavaScriptテストフレームワークのモックの実装には、prototype chainを考慮してモック対象のメソッドが定義されたprototypeを検索している
  • モックに差し替える際に直接モック関数に上書きしないのは、プロパティがアクセサー記述子で定義されていた場合を考慮したものである(おそらく!)

参考文献

https://github.com/jestjs/jest
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Object/getOwnPropertyDescriptor
https://techplay.jp/column/618

Discussion