はじめに
この記事は株式会社ACCESS Advent Calendar 2022の13日目の記事です(以前社内勉強会で発表した内容を書き直しています)。
そろそろ一周回ってグローバル変数が許されそうな風潮を感じている SekiT です。
趣味でチューリングマシンをテーマにしたゲームを作っていたのですが(宣伝)、そこでテストを書く時に面白いことができたので共有します。
前提
今回は関数のユニットテストだけをやりたいので、(ヘッドレス)ブラウザを用いたテストはしません。
Node.js (あるいは deno などの JavaScript ランタイム)向けにテスト対象コードとテストコードをビルドし、テストを実行します。
妙なミニマリズムに固執しているので、テストフレームワークは使っていません。一方で tape という、アサートができるだけのライブラリは使っています。なので自分でモックの機構を用意する必要があったんですね。
モックが欲しい
以下の理由から、モックが欲しいのです:
-
Math.random()
やnew Date()
など、実行するたびに値が変わるものを使うコードをテストするにはモックが必要 -
setTimeout
を使うコードをそのまま実行してしまうとテストに時間がかかってしまう -
navigator
などの、ブラウザ固有の機能を使っている場合、 polyfill のようなものを入れたりヘッドレスブラウザを使うという手もあるが、大袈裟に感じる
(最後のは個人的な感覚ですが……)
「モックする」ために
以下の2つができれば良いでしょう。むしろこの2つを合わせて「モックする」と表現しています:
- テスト中に使われるモックを作成する。必要ならテスト中に動的に書き換えられるようにしても良い
- プロダクション用ビルドでは本物を、テスト用ビルドでは上記のモックを使うように差し替える
対象のオブジェクトを直接書き換えるのは怖いのでやりません。
モックを作る
なんと Proxy を使います。今となっては数々の記事で紹介されているAPIなのでご存知の方も多いと思います。
詳細は割愛しますが、元のオブジェクト(・関数・コンストラクタ)に対するプロパティの get や set (・関数適用・new)に、自由に処理を挟み込んだ新しいオブジェクトを作ることができます。
元のオブジェクトを参照しながら作ることができるため、例えば「特定のプロパティのみモックに差し替える」ということもできます:
const fakeRandom = () => 0;
const MockMath = new Proxy(Math, {
get: (original, key) => key === 'random' ? fakeRandom : original[key],
});
MockMath.PI // 3.141592653589793 (元の Math.PI と同じ)
MockMath.random() // 0 (差し替えた関数)
動的にモックを書き換える
Proxy のコンストラクタの第二引数の handler を書き換えることで、 proxy object の挙動を変えることができます(できてしまいます)。
const handler = { apply: () => 42 };
const mockNow = new Proxy(Date.now, handler);
mockNow() // 42 (モック)
handler.apply = () => 0;
mockNow() // 0
delete handler.apply;
mockNow() // (Date.now() と同じ値)
これがあまり自由にできても困るので、handler への参照を1つのモジュール内で管理することで、そのモジュールで export した関数を通してのみ変更を許すことにします。例えば、関数のモックに限定すると以下のようにできます:
const mocks = new Map(); // このモジュール内でのみアクセスできる変数
// 関数のモックを作る関数
export const mockFunction = (original) => {
const handler = {}; // この handler を書き換えると挙動を変更できる。空だと original そのまま
const mock = new Proxy(original, handler);
mocks.set(mock, handler); // モックオブジェクトから handler を参照できるようにする
return mock;
};
// モックの内容を変更する関数
export const updateFunctionMock = (mock, newFakeFun) => {
const handler = mocks.get(mock);
handler.apply = newFakeFun;
return mock;
};
// 関数のモックを解除する(= 元のオブジェクトと同じ挙動に戻す)関数
export const resetFunctionMock = (mock) => {
const handler = mocks.get(mock);
delete handler.apply;
return mock;
};
とりあえず以上でモックを作るということに関しては十分な機能ができたと思います。
ブラウザの機能に依存したオブジェクトを Node.js 環境で動かす時などで、そもそもビルドできないものはとりあえず {}
でモックしといて、必要なものだけ手書きの関数なり値なりで埋めとけば良いでしょう。
本番時とテスト時で依存物を差し替える
さて、どこでどうやって依存物を差し替えましょうか?色々方法はあると思います:
- 全てを
(...dependencies) => (本来の処理)
という形で書き直す - import 解決時に差し替える
- 対象のオブジェクトを書き換える
1 の方法もワンチャンありそうな気はしていますが、記述量がかなり増えそうなので今回はやめときました。
今回は2の方法を取ることにします。
依存物をまとめておく
例えば Math.random()
と書かれている箇所をモックで差し替えようとすると、 Math
というオブジェクトを書き換えるしかありません。これでは困ってしまいます。依存物を明確にする意味でも、以下のように書くことにします:
import { globals, uhtml } from 'dependencies'; // 'dependencies' から全ての依存物をインポート
const { Math } = globals; // ライブラリ以外のブラウザ固有の機能などの依存物はここから取り出す
const { html } = uhtml; // μhtml というライブラリの html という関数
export const rngView = () => {
const rng = Math.random();
return html`
<span>Your RNG is: ${rng}</span>
${rng < 0.01 ? 'So small!' : ''}
`;
};
この 'dependencies'
というのは後で説明しますが、エイリアスです。実体を以下の2つのファイルで定義しておきます:
// 本番用の dependencies
import uhtml from 'uhtml';
export default {
globals: { Math },
uhtml,
};
// テスト用の dependencies
import { mockFunction } from './lib/mock'; // さっき作った mock.js
export default {
globals: {
Math: { random: mockFunction(Math.random) },
},
uhtml: {
html: mockFunction(() => {}), // 元のままだとビルドできないので、とりあえず何もしない関数を入れておく
},
};
エイリアスを使って差し替える
各種バンドラーのエイリアスの機能を使い、上記の 'dependencies'
の解決先を、本番時は dependencies.prod.js
・テスト時は dependencies.test.js
と差し替えます。
Rollup なら @rollup/plugin-alias, webpack や vite なら設定の resolve.alias
(webpack / vite) ですね。
本番時のビルドコマンドとテスト時のビルドコマンドはそれぞれ異なる package script (npm run script) で定義し、それぞれ異なる設定ファイルを指定することになるでしょう。
本番用の設定ファイルとテスト用の設定ファイルで dependencies
というエイリアスの解決先を変えておくことで、差し替えが実現できます。
テストコード
tape を使う場合、テストコードはこんな感じになります:
import { describe, test } from 'tape';
import { globals, uhtml } from 'dependencies';
import { updateFunctionMock, resetFunctionMock } from '../lib/mock';
import rngView from '../src/views/rngView';
const { Math } = globals;
const { html } = uhtml;
// テスト終了時にモックを解除する
test.onFinish(() => {
resetFunctionMock(Math.random);
resetFunctionMock(html);
});
test('rngView does not show comment if RNG >= 0.1', (t) => {
t.plan(1);
// モックを書き換える
updateFunctionMock(Math.random, () => 0.1);
updateFunctionMock(html, (template, slots) => {
t.deepEqual(slots, [0.1, '']);
});
rngView();
});
test('rngView shows some comment if RNG < 0.1', (t) => {
t.plan(1);
updateFunctionMock(Math.random, (original) => original() * 0.1);
updateFunctionMock(html, (template, slots) => {
t.assert(slots[1].length > 0);
});
rngView();
});
まとめ
- モックを作る
- Proxy を使って、動的にも書き換え可能なモックを作った
- 本番時とテスト時で依存物を差し替える
- バンドラのエイリアスの機能を使い、 import 解決時に差し替えた
-
'dependencies'
というエイリアスの先に依存物をまとめて定義した - 本番時とテスト時でそれぞれ異なるファイルにエイリアスの解決先を差し替えた
普段はフレームワークに乗っかるだけなのであまり使うことはないと思いますが、「エイリアスを使って中身を差し替える」というのは割と汎用性の高いテクニックなのではないかと思います。
Proxy の使い方の例としても、機能と噛み合っていて割と良かったのではないでしょうか?
ACCESS Advent Calendar 2022、明日は @aqua_ix さん(内容は @tonionagauzzi さん)です!