ti-slag Titanium faker API, Titanium app running on Node.js

はじめに

ti-slag は Node.js 実行環境で動く、Titanium アプリケーション用のテストフレームワークです。

これまで Titanium にはいくつかのテストフレームワークがありましたが、どのフレームワークにもシミュレータやエミュレータ、もしくは実機が必要でした。 つまり Titanium は容易にユニットテストをすることができなかったのです。 ti-slag は Node.js 実行環境さえあれば、完全にターミナル上でテストを完結することができます。 アプリケーションのターゲットになるプラットフォームを用意する必要はありません。

ti-slag は現在も開発中です。 万が一実行中に VM がクラッシュしたりする場合は、Issues 等でコードと一緒にご連絡ください。

アーキテクチャ

Titanium faker API
ti-slag の核となる、Titanium API を Node.js 実行環境で動作できるようする API 郡です。 この API 郡は Appcelerator 社から提供されている、公式リファレンスマニュアルの JSON を元に自動生成しています。 コードを ti-slag で実行する際に、SDK のバージョンやプラットフォームを指定することができます。
VM モジュール
簡単に言ってしまうと、安全な eval 環境を提供してくれます。 サンドボックスなコンテキストを作り、その中で JavaScript を実行することができます。 require さえ存在しない環境ですが、この require をうまくフックすることにより、アプリケーションコード内部で Titanium のコードを require された場合でも、継続して ti-slag が実行しています。
Mocha
とてもシンプルで柔軟な JavaScript 用テストフレームワークです。 たぶんその他のテストフレームワークを利用することもできますが、今回は Mocha を採用しています。 それは Mocha が大好きだからです。
Instanbul
JavaScript 用 のカバレッジフレームワークです。 ti-slag のカバレッジオプションを有効にすると、テストスクリプト内で Instanbul のコレクタを使ってカバレッジを収集できるようになります。 テストスクリプトが完走した際、収集されたカバレッジを元にレポートとして出力することができます。

オプション

ti-slag は Titanium SDK のバージョン、プラットフォームをシミュレートします。 ご利用の開発環境にあったオプションを指定してください。

ti-slag はネイティブモジュールをシミュレートすることができます。 例えば地図を扱う ti.map モジュールであれば、プロパティ・メソッドを JavaScript で記述し、オプションで指定すると Titanium API 以外の API を実行することができます。 ただし、これはご自身で用意する必要があります。

ti-slag はテストを厳格にするため、いくつかの例外をスローします。 詳細な使い方はリファレンスマニュアルを参照してください。

シミュレート

ti-slag はいくつかの Titanium API をシミュレートします。

add/remove
ti-slag は Titanium.UI API の add/remove メソッドをシミュレートするため、内部的に ID を割り当てます。 つまり、テストスクリプト内であってもなくても add/remove すると、getParent/getChildren の返却値を忠実にシミュレートするため、必要であればアサーションすることも可能になります。
getParent
Titanium faker API を自動生成するための JSON には、そもそも getParent メソッドが存在していません。 自動生成する際、getChildren メソッドが存在する Titanium API に対して getParent メソッドを追加して、シミュレートしています。
Ti.App.Properties
実際にファイルに書きだされることはありません、ti-slag は Ti.App.Properties をシミュレートします。 実行結果コンテキストには展開されているので、本来の Titanium コードで期待できる通りの振る舞いをします。
Ti.Database
実際にデータベースを CRUD するわけではありませんが、Ti.Database の一連の振る舞いをシミュレートします。 また、Ti.Database.ResultSet.isValidRow メソッドについては、false と true を交互に返却します。

注意事項

ti-slag はデフォルトで Titanium オブジェクトのカスタムプロパティを許可していませんが、Alloy プロジェクトの場合は id が自動で割り当てられるため、id のみ許可しています。

Ti.Network.HTTPClient でサーバと通信する場合、ti-slag が提供する Ti.Network.HTTPClient は通信することはありません。 https://gist.github.com/k0sukey/831dc06bccffe479c857 のような、npm/request の振る舞いをする Ti.Network.HTTPClient のラッパーを用意し、テストコードは npm/request を使用すればテストコード内で通信することができます。 モジュールオプションで読み込む request パッケージを npm のものに切り替えてください。 この時、通信時間が長くなってしまった場合、Mocha はタイムアウトでテストが通過しない可能性があります(Mocha のタイムアウトする初期値は 2,000 ミリ秒)。 通信を伴うテストについては、タイムアウトの制限を外すか、タイムアウトする時間を伸ばしてテストしてください。


環境構築

Titanium プロジェクトを作成する

まずはじめにこれがないと実行することができません。 すでにご自身のプロジェクトがある方は読み飛ばしてください。

$ appc ti create --type app --id be.k0suke.sandbox --name Sandbox --platforms ios,android --url http://sandbox.k0suke.be --workspace-dir ./

Alloy を使用する場合は、プロジェクトフォルダへ移動して Alloy 環境を生成してください。

$ cd Sandbox
$ alloy new

npm パッケージをインストールする

テストをおこなうには、最低でも ti-slag と Mocha をインストールする必要があります。 カバレッジを計測する場合は、Instanbul と signal-exit をインストールしてください。 signal-exit は Node.js のプロセスが終了するときのイベントを取得することができるので、ここで、カバレッジのレポートを出力するようにします。

$ npm init
$ npm install ti-slag mocha istanbul signal-exit --save-dev

package.json を編集する

script.test を編集します。 Mocha でテストを動かすことができるように編集してください。 テストスクリプトは test.js とします。

npm パッケージインストール時に --save-dev オプションを付与していると、devDependencies にインストールしたパッケージが羅列されています(パッケージのバージョンは執筆時のものとなります)。

{
  "name": "sandbox",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "author": "",
  "license": "ISC",
  "scripts": {
    "test": "mocha test.js"
  },
  "devDependencies": {
    "istanbul": "^0.3.17",
    "mocha": "^2.2.5",
    "signal-exit": "^2.1.2",
    "ti-slag": "0.0.20"
  }
}

テストスクリプト

package.json へ記述されているとおり、test.js ファイルをプロジェクト直下へ設置します。

var slag = require('ti-slag'),
    context = slag('path/to/titaniumcode.js', {
        titanium: '4.0.0.GA',
        platform: 'ios'
    });

slag 関数の第一引数は評価対象のファイルパスになります。 第二引数のオブジェクトで、Titanium SDK のバージョンやプラットフォームを指定します。 戻り値は実行結果のコンテキストになります。 これを元にオブジェクトのアサーションや、イベントハンドラを実行することができます。

Classic プロジェクト

create コマンドで作成したプロジェクトの初期 app.js になります。 それにイベントハンドラを付け加えているだけの状態になります。

Ti.UI.setBackgroundColor('#000');
var tabGroup = Ti.UI.createTabGroup();

var win1 = Ti.UI.createWindow({  
    title: 'Tab 1',
    backgroundColor: '#fff'
});
var tab1 = Ti.UI.createTab({  
    icon: 'KS_nav_views.png',
    title: 'Tab 1',
    window: win1
});

var label1 = Ti.UI.createLabel({
    color: '#999',
    text: 'I am Window 1',
    font: {
        fontSize: 20,
        fontFamily: 'Helvetica Neue'
    },
    textAlign: 'center',
    width: 'auto'
});

win1.add(label1);

var win2 = Ti.UI.createWindow({  
    title: 'Tab 2',
    backgroundColor: '#fff'
});
var tab2 = Ti.UI.createTab({  
    icon: 'KS_nav_ui.png',
    title: 'Tab 2',
    window: win2
});

var label2 = Ti.UI.createLabel({
    color: '#999',
    text: 'I am Window 2',
    font: {
        fontSize: 20,
        fontFamily: 'Helvetica Neue'
    },
    textAlign: 'center',
    width: 'auto'
});

win2.add(label2);

tabGroup.addTab(tab1);  
tabGroup.addTab(tab2);  

function doOpen() {
    console.log('TabGroup opened');
}

function doFocus(e) {
    if (e.index === 0) {
        console.log('tab1 clicked');
    } else if (e.index === 1) {
        console.log('tab2 clicked');
    } else {
        console.log('tabX clicked');
    }
}

tabGroup.addEventListener('open', doOpen);
tabGroup.addEventListener('focus', doFocus);

tabGroup.open();

ti-slag から返却されたコンテキストを用いて、Assert モジュールでアサーションをおこなっています。 今回は Assert モジュールを使いましたが、should パッケージを用いることも可能です。

/* global describe: true, it: true */
var assert = require('assert'),
    path = require('path'),
    slag = require('ti-slag');

describe('ti-slag test', function(){
    var context;

    // context 変数へ ti-slag の実行結果を代入し、例外がスローされていないこと
    it('should does not throw exception', function(){
        assert.doesNotThrow(function(){
            context = slag(path.join(__dirname, 'Resources', 'app.js'), {
                titanium: '4.0.0.GA',
                platform: 'ios'
            });
        });
    });

    // 一つ目のタブの名前が「Tab 1」であること
    it('should win1 title is \'Tab 1\'', function(){
        assert.strictEqual(context.win1.title, 'Tab 1');
    });

    // tab1 オブジェクトの window プロパティは win1 であること
    it('should tab1 window property is win1', function(){
        assert.strictEqual(context.tab1.window, context.win1);
    });

    // label1 の文字色は「#999」であること
    it('should label1 color is \'#999\'', function(){
        assert.strictEqual(context.label1.color, '#999');
    });

    // 二つ目のタブの名前が「Tab 2」であること
    it('should win2 title is \'Tab 2\'', function(){
        assert.strictEqual(context.win2.title, 'Tab 2');
    });

    // tab2 オブジェクトの window プロパティは win2 であること
    it('should tab2 window property is win2', function(){
        assert.strictEqual(context.tab2.window, context.win2);
    });

    // label2 の文字色は「#999」であること
    it('should label2 color is \'#999\'', function(){
        assert.strictEqual(context.label2.color, '#999');
        collector.add(context.__coverage__);
    });

    // タブグループの focus イベントで例外がスローされていないこと
    it('should tab1 click does not throw exception', function(){
        assert.doesNotThrow(function(){
            context.doFocus({
                index: 0
            });
        });
    });
});

test.js の準備ができたら、ターミナルで npm test を実行してみましょう。 先ほど記述したテストが実行されていきます。

$ npm test

> sandbox@1.0.0 test /Users/Kosuke/src/Sandbox
> mocha test.js



  ti-slag test
    ✓ should does not throw exception (122ms)
    ✓ should win1 title is 'Tab 1'
    ✓ should tab1 window property is win1
    ✓ should label1 color is '#999'
    ✓ should win2 title is 'Tab 2'
    ✓ should tab2 window property is win2
    ✓ should label2 color is '#999'
tab1 clicked
    ✓ should tab1 click does not throw exception


  8 passing (126ms)

Alloy プロジェクト

Alloy プロジェクトでは、Alloy 自体を ti-slag でロードする必要があります。 オプションとして Alloy 自体を渡し、ti-slag で実行できるようにする必要があります。 この作業を簡略化できるよう、ti-slag/lib/Alloy.js を用意してありますので利用してください。

var alloy = require('ti-slag/lib/Alloy.js');

また、app 配下のコントローラをそのまま実行することはできません(コントローラとビュー、スタイルが分かれている状態なので、コントローラのみを実行したとしても Titanium API が一切存在しない可能性もあります)ので、テストを実行する前に Alloy でコンパイルする必要があります。

$ alloy compile --config platform=ios
$ alloy compile --config platform=android

ti-slag でコードを実行すると実行結果のコンテキストが返却されますが、この時点ではまだ正確には実行されていません。 コントローラに書かれたコードは、コンパイルするとビューとスタイル結合され、Controller という名前の関数に括られます。 次にこの関数を実行する必要があります。 また、Controller 関数を実行しない限り、コントローラ内部のコードは実行されませんので、必ず実行してください。

コントローラは Controller に括られてしまうので、中にあるイベントハンドラがそのままでは実行できません。 Alloy@1.7.x から、イベントが Alloy で管理されるようになり、$.getListener(); メソッドでイベントハンドラを取得できるようになりました。 ti-slag でも同じように実行できますので、context.getHandler(); として、イベントハンドラを取得し、テストすることができます。 もしくは、イベントハンドラ自体を exports で公開してしまうのも手ではありますが、テストのために公開するのは少々考えものですね。

/* global describe: true, it: true */
var assert = require('assert'),
    path = require('path'),
    slag = require('ti-slag'),
    Alloy = require('ti-slag/lib/Alloy');

describe('ti-slag test', function(){
    var context,
        alloy = Alloy.load({
            titanium: '4.0.0.GA',
            platform: 'ios'
        });

    it('should does not throw exception ti-slag load', function(){
        assert.doesNotThrow(function(){
            context = slag(path.join(__dirname, 'Resources', 'iphone', 'alloy', 'controllers', 'index.js'), {
                titanium: '4.0.0.GA',
                platform: 'ios',
                module: {
                    alloy: alloy.core,
                    'alloy/controllers/BaseControlle': alloy.BaseController
                }
            });
        });
    });

    it('should does not throw exception Controller', function(){
        assert.doesNotThrow(function(){
            context.Controller();
        });
    });
});

カバレッジ

カバレッジを計測するには Instanbul と signal-exit パッケージが必要になります。 coverage オプションを true にすると、ti-slag から返却されるコンテキストに __coverage__ が含まれるようになります。 これをコレクタに渡してください。 最後に、テストスクリプトが完走し、プロセスが終了すると signal-exit が反応し、レポーターの処理を行えばカバレッジが出力されます。

/* global describe: true, it: true */
var assert = require('assert'),
    istanbul = require('istanbul'),
    collector = new istanbul.Collector(),
    reporter = new istanbul.Reporter(),
    path = require('path'),
    onexit = require('signal-exit'),
    slag = require('ti-slag');

// テストスクリプトが完走すると、レポーターがカバレッジを出力する
onexit(function(){
    reporter.add('text');
    reporter.addAll([ 'lcov', 'clover' ]);
    reporter.write(collector, true, function(){
        console.log('All reports generated');
    });
}, {
    alwaysLast: true
});

describe('ti-slag test', function(){
    var context;

    it('should does not throw exception', function(){
        assert.doesNotThrow(function(){
            context = slag(path.join(__dirname, 'Resources', 'app.js'), {
                titanium: '4.0.0.GA',
                platform: 'ios'
            });
        });
    });

    // ...

    it('should tab1 click does not throw exception', function(){
        assert.doesNotThrow(function(){
            context.doFocus({
                index: 0
            });
            // カバレッジ情報をコレクタへ渡す
            collector.add(context.__coverage__);
        });
    });
});

カバレッジのレポートはターミナルに出力されます。 また、プロジェクトフォルダ直下に coverage というフォルダも生成され、その中に HTML も出力されているので、ブラウザでステップを確認しながらカバレッジを 100 %にしていくことができます。 今回のテストは focus イベント内の if 文分岐や open イベントを網羅していないので、もちろん 100 %以下になっています。

$ npm test

> sandbox@1.0.0 test /Users/Kosuke/src/Sandbox
> mocha test.js



  ti-slag coverage
    ✓ should does not throw exception (122ms)
    ✓ should win1 title is 'Tab 1'
    ✓ should tab1 window property is win1
    ✓ should label1 color is '#999'
    ✓ should win2 title is 'Tab 2'
    ✓ should tab2 window property is win2
    ✓ should label2 color is '#999'
tab1 clicked
    ✓ should tab1 click does not throw exception


  8 passing (126ms)

------------|----------|----------|----------|----------|----------------|
File        |  % Stmts | % Branch |  % Funcs |  % Lines |Uncovered Lines |
------------|----------|----------|----------|----------|----------------|
 Resources/ |    82.61 |       25 |       50 |    82.61 |                |
  app.js    |    82.61 |       25 |       50 |    82.61 |    62,68,69,71 |
------------|----------|----------|----------|----------|----------------|
All files   |    82.61 |       25 |       50 |    82.61 |                |
------------|----------|----------|----------|----------|----------------|

All reports generated

リファレンスマニュアル

第一引数 <String>

ti-slag で実行したい Titanium コードのファイルパスを指定します。

第二引数 <Object>

SDK バージョン <String>

ti-slag で実行する Titanium SDK バージョンを指定してください。'4.1.0.GA'、'4.0.0.GA'、'3.5.1.GA' を指定することができます。 もしもこの中にないバージョンを使用したい場合は、furnace.js で自動生成することができます(自己責任)。

{ titanium: '4.0.0.GA' }

プラットフォーム <String>

ti-slag で実行するプラットフォームを指定してください。'ios'、'android'、'windowsphone' を指定することができます。

{ platform: 'ios' }

端末プロファイル <Object>

ti-slag で指定したい端末のプロファイルを指定することができます。 いくつかの端末プロファイルがプリセット('iPhone4s'、'iPhone5'、'iPhone5s'、'iPhone6'、'iPhone6Plus')されているのでそれを使っても良いですが、ご自身で用意することも可能です。 この端末プロファイルを指定することで、Ti.Platform(.displayCaps) API のいくつかのプロパティが指定された値を返却するようになります。

{ device: require('ti-slag/lib/device').iPhone5s }
{
    device: {
        name: 'iPhone OS',
        osname: 'iphone',
        model: 'Simulator',
        version: '8.4',
        architecture: 'x86_64',
        ostype: '64bit',
        displayCaps: {
            density: 'high',
            dpi: 320,
            platformWidth: 320,
            platformHeight: 568
        }
    }
}

モジュール <Object>

ネイティブモジュールや Alloy を ti-slag 内で実行する場合、ここで渡すことができます。 実際に require される名称をキーにしてください。

{
    module: {
        ti.map: {
            // 自分でがんばれ
        }
    }
}

Strict <Boolean>

true を指定すると Titanium オブジェクトでカスタムプロパティを使用している場合、例外がスローされるようになります(初期値は true)。

{ strict: true }

サイレント <Boolean>

true を指定すると Ti.API.info、console.log、alert 系の API はターミナルへ出力しなくなります(初期値は false)。

{ silent: true }

カバレッジ <Boolean>

カバレッジを計測する場合は true を指定します(初期値は false)。 true を指定すると、実行結果コンテキストに __coverage__ が含まれるようになります。 また、カバレッジの計測には往々にして実行速度が低下します。

{ coverage: true }

Injection <Function>

ti-slag で実行される Titanium コードへ、コードを注入することができます。 このオプションは乱用しないでください。 テストの信頼性が著しく低下してしまう可能性があります。

例えば、以下の例では Alloy.Globals.navigation に Ti.UI.iOS.NavigationWindow がどこかの段階で代入される場合、テストではそれを知ることができません。 その場合、exports.Globals のコードを置換して、あたかも Ti.UI.iOS.NavigationWindow として振る舞うようにしています。 あくまでも文字列として置換していますので、仮に require するようであればモジュールオプションで実体を渡す必要があります。 ここで渡される TiProxy は、Titanium API の全プロパティとメソッドを持ったワイルドカード API です。 これは ti-slag に内包されているので、必要に応じて使用してください。

{
    module: {
        'ti-slag/lib/TiProxy': require('ti-slag/lib/TiProxy')
    },
    injection: function(code){
        return code.replace(/exports\.Globals\s=\s\{\};/m, 'exports.Globals = { navigation: require(\'ti-slag/lib/TiProxy\') };');
    }
}

戻り値 <Object>

ti-slag で実行された結果のコンテキストが返却されます。 このコンテキストを用いて Titanium オブジェクトのアサーションやイベントハンドラのユニットテストをおこなうことができます。

ti-slag/lib/Alloy <Object>

Alloy プロジェクトで容易に Alloy 本体を読み込むためのヘルパーです。 基本的な読み込みしかおこないませんので、複雑な、例えば Alloy.Globals 等を作りこんでいる場合は、やはりご自身で Alloy をロードをしたほうが良いかもしれません。

var Alloy = require('ti-slag/lib/Alloy'),
    alloy = Alloy.load({
        // オプションで指定できます。初期値は 4.0.0.GA
        titanium: '4.0.0.GA',
        // オプションで指定できます。初期値は ios
        platform: 'ios',
        // オプションで指定できます。初期値は Resources/iphone/alloy/alloy.js
        alloy: 'path/to/alloy.js',
        // オプションで指定できます。初期値は Resources/iphone/alloy/controllers/BaseController.js
        BaseController: 'path/to/BaseController.js',
        // オプションで指定できます。初期値は Resources/iphone/alloy/underscore.js
        underscore: 'path/to/underscore.js',
        // オプションで指定できます。初期値は Resources/iphone/alloy/backbone.js
        backbone: 'path/to/backbone.js',
        // オプションで指定できます。初期値は Resources/iphone/alloy/constants.js
        constants: 'path/to/constants.js',
        // オプションで指定できます。初期値は Resources/alloy/CFG.js
        CFG: 'path/to/CFG.js'

    });

Alloy 本体と BaseController がセットになって返却されます。 ti-slag のモジュールオプションで require できるように指定します。

{
    module: {
        alloy: alloy.core,
        'alloy/controllers/BaseController': alloy.BaseController
    }
}

ti-slag/lib/TiProxy <Object>

全 Titanium API のプロパティとメソッドを持っている Titanium オブジェクトを返却するヘルパーです。 例えば、Ti.UI.iOS.NavigationWindow を Alloy.Globals.navigation に格納したい場合、ユニットテスト時は Alloy.Globals.navigation に存在するか保証されません。 Injection オプションと合わせて、Alloy.Globals.navigation に TiProxy を読みこめば、この問題を回避することができます。 しかし、このヘルパーを多用しているようであれば、アプリケーションのロジックを見なおしたほうが良いかもしれません。 Alloy.Globals に依存しすぎている可能性があります。

var TiProxy = require('ti-slag/lib/TiProxy');