なぜJestが全てをモックできるのか

  • 2
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

Jest 0.2.2の時点の内容であり、後のバージョンだと変更されている可能性がある。

TL;DR

Contexifyが作り出したsandbox内でモックを返すようにrequireを置き換え、テストコードを実行している。

拾い読みTestRunner

CLIで入力されたパスを元にテストを実行する最もシンプルな形であろうTestRunner.prototype.runTestから拾っていく。

src/TestRunner.js#320
var configDeps = this._loadConfigDependencies();

var env = new configDeps.testEnvironment(config);
var testRunner = configDeps.testRunner;

まずは設定に依存する環境を構築する。設定のデフォルトの値は別の場所に記述されている。

src/lib/utils.js#15
var DEFAULT_CONFIG_VALUES = {
  cacheDirectory: path.resolve(__dirname, '..', '..', '.haste_cache'),
  globals: {},
  moduleLoader: require.resolve('../HasteModuleLoader/HasteModuleLoader'),
  modulePathIgnorePatterns: [],
  testDirectoryName: '__tests__',
  testEnvironment: require.resolve('../JSDomEnvironment'),
  testFileExtensions: ['js'],
  moduleFileExtensions: ['js', 'json'],
  testPathDirs: ['<rootDir>'],
  testPathIgnorePatterns: ['/node_modules/'],
  testRunner: require.resolve('../jasmineTestRunner/jasmineTestRunner'),
};

testEnvironmentとして指定されているJSDomEnvironmentのコンストラクタ内を見ると、jsdom().parentWindowというjsdomを見たことある人間には見覚えのある記述があるので、ざっくりとtestEnvironmentはjsdomのwindowを保持していると考えられる。

この後しばらくTestRunner.prototype.runTestは設定に依存する記述があるので読み飛ばしてゆくと、testRunner(config, env, moduleLoader, testFilePath)とjasmineTestRunnerを実行する。

jasmineTestRunnerは冒頭でenvironment.runSourceText(jasmineFileContent, JASMINE_PATH)を実行しており、JSDomEnvironment.prototype.runSourceTextの実装を辿ると、JSDomEnvironment内部に保持しているwindowオブジェクトのrunへと移譲されている。

jsdomのwindow.runwindowをグローバルコンテキストとして、与えたJavaScriptコードをwindowより外部に影響を与えず実行する。これを可能にしているのがContextifyだ。

var window = require('jsdom').jsdom().parentWindow;
window.run('x = 1;');  // => 1
console.log(window.x);  // => 1

Contextifyによりこのようなことが可能になる。つまりここでは、Jasmineをwindow内部で実行していると言える。そうしてロードされたJasmineに対して、テストを実行させる。

src/jasmineTestRunner/jasmineTestRunner.js#238
// Run the test by require()ing it
moduleLoader.requireModule(testPath, './' + path.basename(testPath));

jasmine.getEnv().execute();

テストコードを読み込む際はmoduleLoader.requireModuleという、直接window.runを呼ぶ方法ではない方法をとっている。そこで、moduleLoaderの内部に注目してみよう。moduleLoaderは最初のデフォルト設定どおりの場合、HasteModuleLoaderにて実装されている。

拾い読みHasteModuleLoader

Loader.prototype.requireModuleはコメントにもあるとおり、モックされていないモジュールを返すためのものである。だが、前述の通りモジュールを読み込むのはwindowというグローバルな、なおかつrequireが存在しない環境である。

いかにrequireを実現しているかはLoader.prototype.requireModuleの最後で呼び出されているLoader.prototype._execModuleを見る。

src/HasteModuleLoader/HasteModuleLoader.js#211
moduleObj.require = this.constructBoundRequire(modulePath);

var moduleLocalBindings = {
  'module': moduleObj,
  'exports': moduleObj.exports,
  'require': moduleObj.require,
  '__dirname': path.dirname(modulePath),
  '__filename': modulePath,
  'global': this._environment.global,
  'jest': this._builtInModules['jest-runtime'](modulePath).exports
};

ここでモジュールをwindowで実行した際に補完されるであろうmoduleexports, requireが定義されている。Loader.prototype.constructBoundRequireの内部では、Loader.prototype.requireModuleOrMockという読んで字の如しなfunctionにrequire.resolve相当のfunctionなどをつけて返している。

モジュールの内容に補完された変数群を与え、実行する。

src/HasteModuleLoader/HasteModuleLoader.js#245
utils.runContentWithLocalBindings(
  this._environment.runSourceText.bind(this._environment),
  moduleContent,
  modulePath,
  moduleLocalBindings
);

それでは、実際のモジュール実行部分を見てみよう。contextRunnerJSDomEnvironment.prototype.runSourceTextだ。

src/lib/utils.js#341
var wrapperFunc = contextRunner(
  '(function(' + boundIdents.join(',') + '){' +
  scriptContent +
  '\n})',
  scriptPath
);
src/lib/utils.js#357
wrapperFunc.apply(null, bindingValues);

boundIdents, bindingValuesは先ほど定義されたモジュールロード時に補完される変数群から導き出されたものである。ざっくり言えば、window上でmodule, requireなどNode環境に欠けているものを変数として取り、モジュールコードをそのまま実行するfunctionを一時的に定義してNode環境へと返し、Node環境側で変数群を与えて実行しているということだ。

そしてrequireLoader.prototype.requireModuleOrMockなので、通常であればモックを返すことになり、jest.dontMockされていれば同じ方法でロードされる。

これがJestの「全てのrequireがモックを返す」という設計の根幹だ。

つまり

テストコードを動かしている環境はjsdomのwindowであり、Contextifyが作り出したsandboxの内部である。

ロードする際に実行されるfunctionでjestという引数が定義されているから、テストコード内でjest.dontMockという変数を疑似グローバルに参照できる。

require('react/addons')すればRenderIntoDocumentが普通に使えるのも、テストコードを実行する環境がjsdomのwindow内部だからだ。