💞

ClusterScriptのファイルを分割したり、$を含めてテストできるようにする(コーディング実践編)

2022/12/06に公開

「VSCodeで捗らせる!幸せなCluster Script開発」連載記事一覧

  1. VSCode、Node.js、Gitをインストールして、各種用語をざっくり確認する
    (共通環境構築編)VSCode、Node.js、Gitをインストールする。
  2. VSCodeのワークスペースを作って、ClusterScript向けの機能を入れる
    (個別環境構築編)VSCodeのワークスペースを作成する。必要なパッケージをnpmでインストールする。
  3. VSCodeで複数のClusterScriptを一つにまとめたり、圧縮したりする
    (設定編)VSCodeのtasks.json、launch.jsonを作成する。webpackのwebpack.config.jsを作成する。
  4. ClusterScriptのファイルを分割したり、$を含めてテストする
    (コーディング実践編)型定義ファイルについて。ES Modulesでモジュールを書く。Mocha、Chai、Sinon.JSでテストを書く。$のテストダブルを作る。
  5. スクリプト共有編 予定

こんにちは!かおもです!
前回の内容で、VSCodeでの開発に必要な機能が揃いました。

この記事では、そのVSCodeの環境で、実際にClusterScript(JavaScript)で開発していくのに有用な情報をまとめています。

  • 複数のスクリプトファイルを扱って開発する方法について。
  • スクリプトをVSCode上でテストする方法について。特にClusterScriptの$を使っている場合に必要な工夫について。

0:JavaScript自体やClusterScript自体について

公式の記事が最強なので、そちらを参照してください。

1:ClusterScript固有の関数などを扱いやすくする

公式のリファレンスに書かれているクラスや関数を扱う場合、スペルミスをせずに入力するのは中々面倒ですし神経を使います。

そういった負荷を軽減するために、VSCodeにはIntelliSenseと呼ばれているコード補完機能があります。
例えば$.getStateCompatであれば、$.getsあたりまで入力すると$.getStateCompatが候補として表示されます。

当然ながらVSCodeは、ClusterScriptにどのような関数などが存在してるか知りません。
そのため、別途ファイルなどで伝える必要があります。
そのファイルは、ほとんどの場合型定義ファイルと呼ばれる形で提供されています。
ClusterScriptも同じで、Cluster公式から型定義ファイルが提供されています。

この連載記事では、個別環境構築編にて、型定義ファイルをインストールしてます。
そのため、ClusterScriptの関数やクラスについては、既にコード補完を行えるようになっています。

一般的な型定義ファイルについてはこちらなどを参照してください。

文頭の「/// <reference path…」について

この連載記事で作った環境では、文頭に

/// <reference path="../node_modules/@clustervr/cluster-script-types/index.d.ts" />

のように書く必要はありません。
VSCodeが把握できる(具体的にはpackage.jsonの内容から辿れる)所に、型定義ファイルが存在してるためです。

逆に、型定義ファイルだけ別の所に置いてあるような場合や、コード補完が効かないような場合には、文頭に書く必要があります。

⚠ClusterScriptがアップデートしたので、型定義ファイルを更新したい

ターミナルを開いて、以下のコマンドでアップデートできます。

npm update @clustervr/cluster-script-types

2:複数のスクリプトファイルにて開発する方法

現状、CCK(v2.1.0)のScriptableItemに登録できるスクリプトファイルは一つです。
しかし開発を進めていくと、同時に複数のファイルが扱えると便利なシーンが出てくると思います。

  • 共通の処理を使いまわしたい。
  • スクリプトから設定を分離させて、バリエーションを持たせたい。
  • 等々

現在のJavaScriptには、別のJavaScriptのファイルを読み込むための仕様が存在しています。
この連載記事では、ES Modulesという仕様を採用し、複数のファイルを扱えるようにします。

ES Modulesと似たような仕様で、他にCommonJSというものがあります。
この連載記事でも使用しているNode.jsは、CommonJSとES Modulesのどちらの仕様にも対応しています。
歴史的にはCommonJSの方が古いですが、ES Modulesの方が主流です。
そのため、ここではES Modulesを採用しています。

CommonJSやES Modulesについてはこちらこちらなどが詳しいです。

2.1:参照する側される側に共通の事項

Node.jsは、CommonJSとES Modulesのどちらにも対応しているため(CommonJSがデフォルト)、扱うJavaScriptがどちらの仕様で書かれているかを、判別させる必要があります。
その判別させる方法はいくつかありますが、この連載記事ではファイル名で判別させる方法を採用します。

通常、JavaScriptファイルの拡張子は.jsですが、ES ModulesのJavaScriptとして判別させる場合は.mjsとします。

2.2:参照される側(共通処理など)の書き方

前回の記事にて使用したスクリプトを参照して進めます。
先に書いた通り、ファイルの拡張子は.mjsです。

common.mjs
export const add = (a, b) => {
    return a + b;
};
export const log = (message) => {
    // #!LOGGING
    $.log(message);
};

方法はシンプルです。
他から参照される関数・変数・クラス名などの前にexportと書くだけです。

export function 関数名(){ }
export const 関数名 = () => { };
export let 変数名 = "";
export class クラス名 { }

この方法はnamed exportと呼ばれています。
他にもexportする方法はありますが、ひとまずはシンプルで分かりやすいので、この連載記事ではこの方法を採用しています。
exportの詳細についてはこちら、技術的な詳細はこちらが詳しいです。

もう一つの方法、default exportについて

どちらがいいの?となったら、以下の記事がとても詳しくて良いです。

2.3:参照する側(個別処理や設定など)の書き方

こちらも前回使用したスクリプトを参照して進めます。
同じくファイルの拡張子は.mjsです。

scriptableItem02.mjs
import { add, log } from './common.mjs';

const v = add(1, 2);
log(v);

一行目が参照するための記述です。

import { 使用する関数名等… } from '参照するファイル';

という書式になります。
import文の最後にあるファイルを指定するところは、参照する側のファイルから見た、相対パスで指定する必要があるので注意してください。

使用する関数名が被っているなどの場合、以下のような書き方も可能です。

import * as common from './common.mjs';

この場合、common.mjsでexportしている関数などを、まとめてcommon以下に入れて使えるようにしています。
add関数を使用する場合は以下のような書き方になります。

const v = common.add(1, 2);

as commonのcommonの個所については自由に指定できます。

importの詳細については、先にも紹介したこちらなどを参照してください。

3:作成したスクリプトのテストの書き方

ここでも前回の記事にて使用したスクリプトを参照して進めます。
このファイルもファイルの拡張子は.mjsです。

common.test.mjs
import { expect } from 'chai';
import { add } from './common.mjs';

describe("add", () => {
    
    it("1と2が足されて3が返される", () => {
        const result = add(1, 2);
        expect(result).equal(3);
    });

});

二つのファイルからimportしているのが分かると思います。

一つ目はChaiです。
これは以前の記事で触れていましたが、Chaiはテストの結果を確認しやすくするための機能を提供します。
二つ目は前項で扱ったcommon.mjsです。
これはテストする対象としてimportしています。

それではimport以降の内容を順に確認していきます。

3.1 Mochaを使用して、テストをまとめる

Mochaはこれも以前の記事で触れていましたが、テスト自体をしやすくするための機能を提供します。
ここでは、describeitがMochaの機能です。

Mochaはimportしなくていいの?

このスクリプトではChaiをimportをしていますが、Mochaはimportをしていません。

それでもこのスクリプトが動くのは、この連載記事の環境では、VSCodeのタスクなどから動かす場合に、Mochaを経由して動かしているためです。

itには、ある処理がどのように振る舞うべきかの説明(=仕様)と、それを確認するためのコードを書きます。
Mochaはこのitに指定された無名関数を順に実行していき、テストを行います。

describeは、そのitをグループ化します。さらにdescribedescribeもグループ化できます。
describeに書く説明は、関数単位でグループ化していれば、関数名などで良いと思います。
グループ化は、関数単位でまとめたり、itで示される振る舞いが起きる状況でまとめたりなど、色々あります。

そのため、以下のようにdescribeの下にはitが並ぶような書き方になります。

describe("関数名や状況", () => {
    it("仕様1",);
    it("仕様2",);
    it("仕様3",);});

Mochaにはdescribeit以外に、テストを作るうえで便利な機能があります。詳しくはこちらなどを参照してください。

describeは分かるけど、itってどういうこと?「それ」?

describeは和訳すると「説明」とかそういった意味なので分かりやすいと思います。
しかしitの方は抽象的過ぎてちょっと分かりづらいと思います。
これを理解するには、その背景の理解が有用かと思います。

このdescribeitを使用した書き方は、BDDという開発手法に沿った書き方で、説明文には英語が使われることを想定しています。
BDDというのは、簡単にいうと、振る舞いを意識しようという考え方です。

つまり、itの説明文には振る舞いを書こうという事です。
この時、先のadd関数の例では「it should be 1 and 2 add up to 3.」というような説明文にしたりします。
この文頭のitをそのまま関数名とすることで、英文として読みやすくしています。

BDDなどについてはこちらの記事がとても良いです。
本家はこちらです。

3.2:Chaiを使用して、テストの結果を確認する

Chaiはテストの結果を確認しやすくするための機能を提供しています。

ここでは、import文で指定しているexpect関数を使用しています。
以下の個所で使用しています。

const result = add(1, 2);
expect(result).equal(3);

resultが3となっている(equal)ことを期待(expect)している、という考え方で書きます。

expectの後ろに繋げられる要素については、英語ですがこちらを参照してください。

4:ClusterScriptの$を使用した処理をテストしようとする

このテストには工夫が必要なので、これまでのサンプルのスクリプトではあえて避けていました。

workフォルダ直下にcommon2.test.mjsというファイル名で以下のファイルを作ってください。
文末の});が漏れないように注意してください。

common2.test.mjs
import { expect } from 'chai';
import { log } from './common.mjs';

describe("log", () => {

    it("$.logに文字列を送れる", () => {
        const message = "test";
        log(message);
        // 何をexpectするの?
    });

});

このテストは、以下のエラーが出て動きません。
タスクのテストを実行してみてください(common2.test.mjsを開いたまま画面上側メニュー>ターミナル>タスクの実行「テスト work」)。

ReferenceError: $ is not defined

エラーの個所は以下の$.logです。

export const log = (message) => {
    // #!LOGGING
    $.log(message);
};

ここの$.logの、$が定義されていません、というエラーになります。
Clusterの型定義ファイルで型だけ定義されていますが、実体がない状態です。

なので、自力で$のダミーの実体を作る必要があります。
文頭で言っていた工夫というのはこれです。

4.1:$.logを作る

$は現状の挙動から、(ScriptableItem内なら)どこからアクセスしても同じものにアクセスできる、と考えてよいでしょう。
ですので、$はグローバルスコープにあると考えて進めます。

Node.jsで、グローバルスコープの$を作る場合は以下のようにします。
ついでにlogも作っておきます。

global.$ = {
    log(message){},
};

これを、先ほどのスクリプトに以下のように追加して、テストを実行してみましょう。

🔴追加したcommon2.test.mjs
common2.test.mjs
import { expect } from 'chai';
import { log } from './common.mjs';

//このあたりに追加しました
global.$ = {
    log(message){},
};

describe("log", () => {
    it("$.logに文字列を送れる", () => {
        const message = "test";
        log(message);
        // 何をexpectするの?
    });

});

これは正常に動き、テストは(見かけ上は)成功になります。

しかし、itに書かれた仕様を満たせたか確認ができていません。
具体的には、$.logtestという文字列が送られたかを確認する必要があります。

4.2:Sinon.JSを使用して、値を確認する

このような時に、Sinon.JSを使います。
Sinon.JSは、テストの時に使用するダミーの機能など(テストダブル)を提供します。

ここでは、色々ある機能の内のSpyを使用します。
Spyを使用すると、指定した関数がどのように実行されたかを取得できます。

以下のようにcommon2.test.mjsを修正し、テストを実行してみましょう。

🔴修正したcommon2.test.mjs
common2.test.mjs
import { expect } from 'chai';
import * as sinon from 'sinon';                     //追加
import { log } from './common.mjs';

global.$ = {
    log(message){},
};

describe("log", () => {
    it("$.logに文字列を送れる", () => {
        const message = "test";
        const logSpy = sinon.spy($, "log");         //追加
        log(message);
        expect(logSpy.callCount).equal(1);          //追加
        expect(logSpy.lastCall.firstArg).equal(message);    //追加
    });

});

これも正常に動き、テストは成功になります。
今回は、expectにて結果の確認が行えています。
ここでは、$.logが1回しか呼ばれていないことと、$.logの引数にtestの文字列が渡されていることを確認しています。

Sinon.JSには他にもStubやMockといった機能があります。詳しくはこちらなどを参照してください。

余談ですが、Sinon.JSはSinonという風に.JSが省かれることがあります。
検索する時とかに、頭の片隅に置いておくと良いかもしれません。

4.3:【注意】$.onUpdateが書かれているファイルをテストしたい

これは、少し注意が必要です。
importしただけで$を参照してしまうファイルに注意が必要です。
具体的には、$.onUpdate最初のフレームが実行される前に$を触っているファイルだったらアウトです。
極言すると$.onUpdateに色々書いてるファイルはほぼアウトです。
$.onInteract$.subNodeなども要注意です。

例として、workフォルダの直下に、以下の2つファイルを作って確認します。

🔴scriptableItem03.mjs、scriptableItem03.test.mjs
scriptableItem03.mjs
const initialize = () => {
    //初期化処理いろいろ
    onUpdate = waitSignal;
    $.log("finish initialize");
};

const waitSignal = () => {
    //シグナル待ち処理いろいろ
};

let onUpdate = () => {};
$.onUpdate(() => {
    onUpdate();
});

export const boot = () => {
    onUpdate = initialize;
};
boot();
scriptableItem03.test.mjs
import { expect } from 'chai';
import { boot } from './scriptableItem03.mjs';

global.$ = {
    _onUpdate: () => {},
    onUpdate(callback){
        this._onUpdate = callback;
    },
    _log: [],
    log(message){
        this._log.unshift(message);
    },
    _logCount(message){
        return this._log.filter(m => m === message).length;
    },
};


describe("onUpdate", () => {
    it("initializeの処理は一度だけ", () => {
        boot();
        $._onUpdate();
        $._onUpdate();
        const callCount = $._logCount("finish initialize");
        expect(callCount).equal(1);
    });
});

scriptableItem03.mjsは、bootonUpdateinitializeが登録されて、initializeが完了すると、waitSignalに切り替わるという流れになっています。
そしてscriptableItem03.test.mjsは、その流れのテストをしています。

見た目は動きそうなのですが、このテストを実行する(scriptableItem03.test.mjsを開いた状態でテストを実行する)と以下のエラーになります。

ReferenceError: $ is not defined

具体的には以下の個所でエラーになっています。

scriptableItem03.mjs 抜粋
$.onUpdate(() => {
    onUpdate();
});

前項でテストしていた$.logでも同じエラーになっていました。
その時はglobal.$に登録することで対応できていましたが、今回の$.onUpdateには対応できなかったという事になります。

4.4:対策その1、$に登録するタイミングを工夫する

原因はシンプルで、global.$に登録する前に、$.onUpdateが呼ばれてしまった事にあります。

では、import { boot } from './scriptableItem03.mjs';の前にglobal.$を書けば解決するのでは?となるかもしれませんが、これも上手くいきません。
これは、importで指定されている(参照される側の)スクリプトは、参照する側のスクリプトを実行する前に、実行されるためです。

なんでimportが先に実行されるの?

参照する側を最初に実行してしまうと、参照される側に定義してある関数等が読み込まれてない状態で、実行してしまうことになります。
この順番では、参照する側が、参照される側に定義してある関数等を、使用できません。
そのため、先のような挙動になっています。

ではさらに先を行って、import { boot } from './scriptableItem03.mjs';の前に、global.$を登録するスクリプトをimportするのはどう?となるかもしれません。
これは上手くいきます。

以下のスクリプトをコピペしてください。

🔴clusterTestDouble.mjs、scriptableItem03.test.mjs

以下のファイルは新しく作ってください。

clusterTestDouble.mjs
global.$ = {
    _onUpdate: () => {},
    onUpdate(callback){
        this._onUpdate = callback;
    },
    _log: [],
    log(message){
        this._log.unshift(message);
    },
    _logCount(message){
        return this._log.filter(m => m === message).length;
    },
};

以下は既存のファイルに上書きしてください。

scriptableItem03.test.mjs
import { expect } from 'chai';
import './clusterTestDouble.mjs';
import { boot } from './scriptableItem03.mjs';

describe("onUpdate", () => {
    it("initializeの処理は一度だけ", () => {
        boot();
        $._onUpdate();
        $._onUpdate();
        const callCount = $._logCount("finish initialize");
        expect(callCount).equal(1);
    });
});

scriptableItem03.test.mjsを開いた状態でテストを実行すると、結果がグリーンとなりテストが行われたことが分かります。

4.5:対策その2、$.onUpdateのファイルを読むタイミングを工夫する

逆に、scriptableItem03.mjsを読み込むのを遅くすればよいのでは?という発想もあるかと思います。
これも可能です。

以下のスクリプトを、新しく作ったファイルにコピペしてください。

🔴scriptableItem03.test2.mjs
scriptableItem03.test2.mjs
import { expect } from 'chai';

global.$ = {
    _onUpdate: () => {},
    onUpdate(callback){
        this._onUpdate = callback;
    },
    _log: [],
    log(message){
        this._log.unshift(message);
    },
    _logCount(message){
        return this._log.filter(m => m === message).length;
    },
};

describe("onUpdate", () => {
    it("initializeの処理は一度だけ", async () => {
        const subject = await import('./scriptableItem03.mjs');
        subject.boot();
        $._onUpdate();
        $._onUpdate();
        const callCount = $._logCount("finish initialize");
        expect(callCount).equal(1);
    });
});

このファイルを開いた状態で、テストを実行してみてください。
この結果もグリーンとなり、テストが行われました。

このファイルの内容を確認していきます。
まず文頭のimportですが、scriptableItem03.mjsのimportが行われていません。
次のglobal.$は、$.logの時と同じものです。

最後にitの中身ですが、ここで工夫が行われています。

const subject = await import('./scriptableItem03.mjs');

上記の行にて、scriptableItem03.mjsをimportしています。
このタイミングでimportをすることで、global.$に登録した後で、$.onUpdateを呼ぶことに成功しています。
これにより、ReferenceError: $ is not definedのエラーは発生しません。

このようなimport方法をdynamic importと呼びます。
dynamic importについて詳しくはこちらなどを参照してください。

急にasync/awaitって出てきたけど、なにこれ?

あまりにも難しいので言及を避けました!
大まかな背景や考え方だけ触れます。

まず基本的に、ファイルを読み込む処理は重いです。
そのため、こういった重い処理はバックグラウンドで動かして後続の処理をすすめます。
そして、読み込み終わったらそのファイルに関する処理をさせる、というのが無難です。
このasync/awaitは、そういった並列処理(非同期処理)を扱うための記法です。

ここのimport('./scriptableItem03.mjs')は、本来バックグラウンドで動かす処理です。
しかしawaitを付けることで、バックグラウンドで動かさずにファイルの読み込みまで待機する。
ということをしている、という理解でとりあえず大丈夫かと思います。
詳しくはこちらこちらなどを参照してください。

ここでの例では、it毎にimportしているので、なんだか非効率というか冗長だなぁと思われるかもしれません。
そういった場合は以下のような書き方も可能です。

scriptableItem03.test2.mjs
describe("onUpdate", () => {
    /** @type {import('./scriptableItem03.mjs')} */
    let subject;
    before(async () => {
        subject = await import('./scriptableItem03.mjs');
    });
    it("initializeの処理は一度だけ", () => {
        subject.boot();
        $._onUpdate();
        $._onUpdate();
        const callCount = $._logCount("finish initialize");
        expect(callCount).equal(1);
    });
});

beforeはMochaの機能の一つで、describe内でitを処理する前に、1度だけ最初に実行されます。
また、コード補完が効かなくなるので、@typeで無理矢理効かせています。

4.6:機能の揃ったテスト用の$誰か作ってくれませんか?!

おねがいします!!!!!!!
自分用に作ったのは、そのうち公開するかもしれないです。

5:次回、スクリプト共有編

次回はおそらく最終章です!
書いたスクリプトを公開したり、共有したりする方法などをまとめていきます。

以上です!よいVSCodeライフを!!

Discussion