ClusterScriptのファイルを分割したり、$を含めてテストできるようにする(コーディング実践編)
「VSCodeで捗らせる!幸せなCluster Script開発」連載記事一覧
-
VSCode、Node.js、Gitをインストールして、各種用語をざっくり確認する
(共通環境構築編)VSCode、Node.js、Gitをインストールする。 -
VSCodeのワークスペースを作って、ClusterScript向けの機能を入れる
(個別環境構築編)VSCodeのワークスペースを作成する。必要なパッケージをnpmでインストールする。 -
VSCodeで複数のClusterScriptを一つにまとめたり、圧縮したりする
(設定編)VSCodeのtasks.json、launch.jsonを作成する。webpackのwebpack.config.jsを作成する。 -
ClusterScriptのファイルを分割したり、$を含めてテストする
(コーディング実践編)型定義ファイルについて。ES Modulesでモジュールを書く。Mocha、Chai、Sinon.JSでテストを書く。$のテストダブルを作る。 - スクリプト共有編 予定
こんにちは!かおもです!
前回の内容で、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
です。
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
です。
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
です。
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はこれも以前の記事で触れていましたが、テスト自体をしやすくするための機能を提供します。
ここでは、describe
とit
がMochaの機能です。
Mochaはimportしなくていいの?
このスクリプトではChaiをimportをしていますが、Mochaはimportをしていません。
それでもこのスクリプトが動くのは、この連載記事の環境では、VSCodeのタスクなどから動かす場合に、Mochaを経由して動かしているためです。
it
には、ある処理がどのように振る舞うべきかの説明(=仕様)と、それを確認するためのコードを書きます。
Mochaはこのit
に指定された無名関数を順に実行していき、テストを行います。
describe
は、そのit
をグループ化します。さらにdescribe
はdescribe
もグループ化できます。
describe
に書く説明は、関数単位でグループ化していれば、関数名などで良いと思います。
グループ化は、関数単位でまとめたり、itで示される振る舞いが起きる状況でまとめたりなど、色々あります。
そのため、以下のようにdescribe
の下にはit
が並ぶような書き方になります。
describe("関数名や状況", () => {
it("仕様1", …);
it("仕様2", …);
it("仕様3", …);
…
});
Mochaにはdescribe
やit
以外に、テストを作るうえで便利な機能があります。詳しくはこちらなどを参照してください。
describeは分かるけど、itってどういうこと?「それ」?
describe
は和訳すると「説明」とかそういった意味なので分かりやすいと思います。
しかしit
の方は抽象的過ぎてちょっと分かりづらいと思います。
これを理解するには、その背景の理解が有用かと思います。
このdescribe
とit
を使用した書き方は、BDDという開発手法に沿った書き方で、説明文には英語が使われることを想定しています。
BDDというのは、簡単にいうと、振る舞いを意識しようという考え方です。
つまり、it
の説明文には振る舞いを書こうという事です。
この時、先のadd
関数の例では「it should be 1 and 2 add up to 3.」というような説明文にしたりします。
この文頭のitをそのまま関数名とすることで、英文として読みやすくしています。
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
というファイル名で以下のファイルを作ってください。
文末の});
が漏れないように注意してください。
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
import { expect } from 'chai';
import { log } from './common.mjs';
//このあたりに追加しました
global.$ = {
log(message){},
};
describe("log", () => {
it("$.logに文字列を送れる", () => {
const message = "test";
log(message);
// 何をexpectするの?
});
});
これは正常に動き、テストは(見かけ上は)成功になります。
しかし、it
に書かれた仕様を満たせたか確認ができていません。
具体的には、$.log
にtest
という文字列が送られたかを確認する必要があります。
4.2:Sinon.JSを使用して、値を確認する
このような時に、Sinon.JSを使います。
Sinon.JSは、テストの時に使用するダミーの機能など(テストダブル)を提供します。
ここでは、色々ある機能の内のSpyを使用します。
Spyを使用すると、指定した関数がどのように実行されたかを取得できます。
以下のように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が省かれることがあります。
検索する時とかに、頭の片隅に置いておくと良いかもしれません。
$.onUpdate
が書かれているファイルをテストしたい
4.3:【注意】これは、少し注意が必要です。
importしただけで$
を参照してしまうファイルに注意が必要です。
具体的には、$.onUpdate
で最初のフレームが実行される前に$を触っているファイルだったらアウトです。
極言すると$.onUpdate
に色々書いてるファイルはほぼアウトです。
$.onInteract
や$.subNode
なども要注意です。
例として、workフォルダの直下に、以下の2つファイルを作って確認します。
🔴scriptableItem03.mjs、scriptableItem03.test.mjs
const initialize = () => {
//初期化処理いろいろ
onUpdate = waitSignal;
$.log("finish initialize");
};
const waitSignal = () => {
//シグナル待ち処理いろいろ
};
let onUpdate = () => {};
$.onUpdate(() => {
onUpdate();
});
export const boot = () => {
onUpdate = initialize;
};
boot();
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
は、boot
でonUpdate
にinitialize
が登録されて、initialize
が完了すると、waitSignal
に切り替わるという流れになっています。
そしてscriptableItem03.test.mjs
は、その流れのテストをしています。
見た目は動きそうなのですが、このテストを実行する(scriptableItem03.test.mjsを開いた状態でテストを実行する)と以下のエラーになります。
ReferenceError: $ is not defined
具体的には以下の個所でエラーになっています。
$.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
以下のファイルは新しく作ってください。
global.$ = {
_onUpdate: () => {},
onUpdate(callback){
this._onUpdate = callback;
},
_log: [],
log(message){
this._log.unshift(message);
},
_logCount(message){
return this._log.filter(m => m === message).length;
},
};
以下は既存のファイルに上書きしてください。
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を開いた状態でテストを実行すると、結果がグリーンとなりテストが行われたことが分かります。
$.onUpdate
のファイルを読むタイミングを工夫する
4.5:対策その2、逆に、scriptableItem03.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しているので、なんだか非効率というか冗長だなぁと思われるかもしれません。
そういった場合は以下のような書き方も可能です。
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