Titanium 使っています、と言うと「テスト、どうしていますか?」とよく聞かれます。テストができるかできないかで採用の障壁になるのでしょうね。よくよく考えると Titanium 製だからと言っても結果的にはスマホアプリなので、iOS であれば UI Automation を普通に使うことができます。これはタッチテストになりますが。
Titanium で書かれたアプリの中身は JavaScript なので、npm で配布されている Mocha や Jasmine が使えると、普段から JavaScript に馴染んでいる方は嬉しいですよね。はい、ぼくもです。ただ、 Mocha や Jasmine 等の実行環境は Node.js です。Titanium のソースコードで動かしてみるとわかりますが(動かしてみるまでもありませんね)、Titanium.(Ti.)などという API はもちろん Node.js にありませんのでテストは異常終了します。当たり前ですね。
ti-mocha はどうでしょう。Titanium の実行環境で動くようにした Mocha になります。titanium-jasmine もありましたね。こちらも同様です。これでユニットテストができるようになりました。やりましたね!
いやいや、ちょっと待って下さい。ユニットテストしたいだけなのに、Titanium の実行環境であるシミュレータ(エミュレータ)や実機が必要になってしまいます。titanium-jasmine は mobileweb で書きだしたものをNode.js 上で実行しています。iOS/Android 依存の API はどうなるのでしょうか?
これでは気軽にできませんね。ぼくは Node.js で動く Titanium のユニットテストが欲しいのです。テスト自体は慣れ親しんだ Mocha が嬉しいですね。であれば、Titanium.(Ti.)を認識できる実行環境を用意してあげれば良いのでは?と思い、今回ご紹介する ti-slag を開発しました。
Node.js の VM モジュール
ご存知でしょうか。あまり馴染みがないかもしれませんが、Node.js には VM モジュールというものがあります。これは簡単に言ってしまうと安全な eval になります。サンドボックスなコンテキストを作り、そのコンテキストの中で JavaScript を実行することができます。ただし、このコンテキストの中は exports や require がありません。コンテキストを作る時にそれ自体を渡してあげる必要があります。console もありませんし、JSON もありません。必要であれば渡してあげましょう。
var require('vm'),
context = vm.createContext({
require: require,
exports: exports,
console: console
}),
result = vm.runInContext('console.log(\'Hello, world.\');', context);
vm.createContext で実行環境を作り、vm.runInContext で実行して結果が返却されます。実行結果のコンテキストが返却されますね。はい、この実行結果のコンテキストを元に Mocha でアサーションするわけです。そしてコンテキストを作る際、require や exports を渡していますね。ここに Titanium API(を偽装したオブジェクト)を渡したらどうなるでしょうか。
var require('vm'),
context = vm.createContext({
Ti: {
UI: {
createWindow: function(){}
}
}
}),
result = vm.runInContext('var win = Ti.UI.createWindow();', context);
バッチリ動きますね!これで Node.js 上に Titanium の実行環境を作ることができます。って、いくつあると思いますか...?ぼくも正確に把握できていませんし、数千個はありますよね。プロパティやメソッド、それの返却値まで考慮するとなると死亡フラグです。手書きですと人為的なミスもあるでしょうし、それではテストの信頼性が全く無くなってしまいます。
Titanium API をどうにかする
Appcelerator のリファレンスサイトをスクレイピングしようかと一瞬頭をよぎりましたが、そもそもリファレンスサイトはどうやってできているかご存知でしょうか。まさかドキュメントの担当者が一つ一つ手書きしているとは思えません。Titanium のレポジトリを見てみましょう。apidoc というフォルダがありますよね。この中にジェネレータがあります。.yml で一つ一つの API が存在しているのですが、JSON 化されたものがリファレンスサイトにあるのです。こいつを使わない手はありません。これを元に Titanium API を書き出しているのが、ti-slag/furnace.js になります。ままままままさかこのファイルの正当性がないなんて事はないですよ。大丈夫です。安心してください。
これで Titanium API を何とかすることができました。やりましたね!
ti-slag でできること
- 廃止された(廃止対象であっても)メソッドを実行すると例外をスローします
実行環境は SDK のバージョンを指定することができるようになっていますので、ご利用の SDK のバージョンを指定することによって、潜在的な deprecated API を発見することができます。また、SDK のバージョンを上げる際に気になるのがレグレッションテストですよね。規模が大きくなってしまうと、どうしても見落としてしまいがちです。にんげんだもの。これを機械的に発見することが可能になります。 - オプションで指定されているプラットフォーム以外の Titanium API を使用すると例外をスローします
iOS アプリなのに Android 専用の API を許可してしまうなんてありえません。もちろんエラーとして拾うことができます。 - Titanium API に対して、カスタムプロパティを定義すると例外をスローします
カスタムプロパティ、便利ですよね。これを許しだしてしまうと複数人で開発する際に地獄の門を開けてしまうことになりかねません。ぼくは evalJS なみに非常に危険なものだと捉えています。ただ、どうしても使いたい方、オプションで許容するようにできますのでご安心ください。
ti-slag でできないこと
- 画面遷移をしません
画面、ありませんからね...。ヘッドレスな実行環境だと思ってください。画面遷移しながらのテストは UI Automation でがんばればと思います。ぼくは fastlane/snapshot を使って、機械的に画面遷移しながらスナップショットを撮影させたりしています。 - モジュール
ti.map 等の公式モジュールから GitHub で公開されているモジュールまで、もちろん ti-slag では一切認識することはできません。いくつあるかわかりませんしね。ただ、オプションでモジュールの偽装したオブジェクトを渡してあげることで回避できます。手間は掛かりますが。 - Ti.Network.HTTPClient で通信しません
同じように Ti.Database なども実際に CRUD したりできません。どうするのか?こんな感じで、npm/request の様なラッパーを用意しておきます。通常の Titanium 実行環境上ではこれを使って通信しますが、Node.js 上では npm/request を使って通信します。メソッドやプロパティ名を合わせてあげれば問題ありません。
実際にテストを書いてみよう
それでは実際にどうテストをするのか、解説していきます。テスト自体は Mocha を使ってアサーションを行いますので、別段難しいことはありません。
準備
まずは package.json を用意しましょう。
$ npm init
必要な npm をインストールします。
$ npm install mocha ti-slag --save-dev
package.json の scripts 項を以下のように編集して、プロジェクトフォルダの直下に test.js を作っておきましょう。
"scripts": {
"test": "mocha test.js"
}
app.js を書く
これがないと始まりませんね。Titanium アプリのエントリーポイントである 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();
テストコードを書く
Mocha のテストコードと何らかわりはありません。前述で作った test.js を編集していきます。
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
});
});
});
});
テストを動かしてみる
テストは 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 を使っていても大丈夫です。
普段あまり使わないと思いますが、npm/alloy をインストールすると、alloy CLI が使えるようになりますね。コントローラを作ったりは普段からやられていると思います。ビルドは accp/ti CLI をお使いですよね。さて、Alloy プロジェクトなアプリはビルドすると Resources 配下にクラシックなコードが生成されます。Alloy は、実はクラシックなコードを生成するトランスコンパイラなんですね。このコードを使ってテストすれば OK です。
実際、テストの度に毎回ビルドするのもアレですし、alloy CLI のコンパイルコマンドをご紹介しておきます。
$ alloy compile --config platform=ios
$ alloy compile --config platform=android
また、テストでは Alloy のコアを読み込む必要があります。と言っても、Resources を見てみるとわかる通り、require されているだけですので、ti-slag のモジュールオプションで渡してあげれば OK です。ti-slag/lib/Alloy.js に必要なパッケージ一式を詰め込んだヘルパーがありますので、必要でしたらこちらをご利用ください。もちろんご自身で読み込ませることも OK です。Backbone.js やUnderscore.js 等、色々と読み込まないといけませんので、ご自身の環境にあったヘルパーを作っておくのも手ですね。
var Alloy = require('ti-slag/lib/Alloy'),
alloy = Alloy.load({
titanium: '4.0.0.GA',
platform: 'ios'
}),
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
}
});;
あの、カバレッジが欲しいんですけど...
はい、大丈夫ですよ。npm で Istanbul というカバレッジフレームワークがあるのはご存知でしょうか?これを利用します。npm コマンドでインストールしておきましょう。signal-exit は Node.js のプロセスが終了するときのイベントを取得することができるので、ここで、カバレッジのレポートを出力するようにします。
npm install istanbul signal-exit --save-dev
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',
coverage: true
});
});
});
// ...
it('should tab1 click does not throw exception', function(){
assert.doesNotThrow(function(){
context.doFocus({
index: 0
});
// カバレッジ情報をコレクタへ渡す
collector.add(context.__coverage__);
});
});
});
coverage オプションを有効化すると、実行結果コンテキスト内に __coverage__ が含まれるようになります。これを Istanbul のコレクタに渡していき、最後にプロセスが終了したらこれを元にレポータがカバレッジを出力します。
------------|----------|----------|----------|----------|----------------|
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
これでカバレッジもバッチリですね!
まとめ
以上のテストを、Cricle CI で回しながら開発を行っています。最近、Circle CI の Xcode 環境は有償化されましたね。テストだけでしたら、実は Xcode の環境は不要です。Node.js でしか動いていませんから。なので、無償プランで問題なくテストを実行することが可能です。ぼくの場合は Ad-Hoc ビルドを配布するところまでを行いたいので、最近有償プランに切り替えました(ちょっとお高いですよね...)。
そんなこんなで長文を書くことに疲れてしまいました。文章を書くのが苦手なんです...。おっと、実は ti-slag の紹介サイトがあるんです。詳しくはそちらでどうぞ。それでは。