この記事は Retty inc. Advent Calendar 14日目の記事です。
昨日はYutaSakataくんの超実用的でラブリーなKotlinで古典的AIでした。
はじめに
この記事は私がiOS Test Night #1でお話させていただいた内容をまだWeb記事にしてなかったのでそれをQiitaに書いたものです^ω^
XcodeでアプリのネイティブコードのTestを書くことはよくありますが、今回のネタはアプリ内に含んだJavascriptをXcodeでテストしてしまおうという謎な内容になっております。
本編
みなさんWebView使ってますか?
RettyのiOSアプリは使っていただくとわかると思うのですが、WebViewを使っいる部分があり、それも特殊な感じでサーバ側のAPIの結果に基づきアプリ内のJavascriptのHTMLテンプレートエンジンを使ってHTMLのビューを作るといったことをしております。
通常WebViewというとURLを指定してそのHTMLを取得し表示するイメージですが、上で挙げたような例ではデータのみをサーバから受け取りHTMLはアプリ内で生成して表示しているため、少々仕組みが異なります。
そのため、RettyではこれをネイティブWebViewと呼んでURLを指定して表示するWebViewとは区別をしています。
なぜWebView使ってるの?
大きな理由はPDCAを高速に回すためでした。
当時のRettyでは(3年前)アプリ開発者が少なくて、ネイティブでUIも開発すると自分の作業がボトルネックになってPDCAが遅くなってしまうということがありました。
WebViewであればHTMLに理解のある人なら誰でもアプリ上のレイアウトを組むことができたため、その分高速に開発することができました。
またその頃、CSSもより便利になってきており複雑なレイアウトもHTMLだとけっこう簡単につくれたりして便利でした。
なんでJavascriptのテストなの?
上記のとおり、WebViewの表示はRettyのアプリでは大事な部分であり、とりわけHTMLの組み立てを行うJavascriptのテンプレートエンジン周りはバグが発生するとエラいことになってしまうため、ここにはちゃんとテストを入れておこう、と思ったわけです。
JavascriptでXCTest基礎編
さて、XcodeでJavascriptをテストするといってもどうするればいいのか?
そこで登場JavaScriptCoreです!
JavaScriptCoreはiOS 7以降で使えるフレームワークで、その名のとおりネイティブコードからJavaScriptに対して色々な操作ができるというものです。
(個人的な印象としては UIWebView に昔からあった stringByEvaluatingJavaScriptFromString とあんまり変わらないですがw)
これを使っていけばJavascriptのテストもXcodeでかけるはず!
ということで実際に書いたテストコードがこちらです。
(テストコードは下記のサンプルコードのGithub内にあるのでそちらをご参照ください)
(あとコードの例外処理やオプショナルの扱いなどは自明な部分は雑に記述しちゃっています)
class SimpleJSCoreTest: XCTestCase {
let context = JSContext()
func testSimpleValue() {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct results.
context?.evaluateScript("var source = '3';")
let value = context?.evaluateScript("source;").toInt32()
XCTAssert(value == 3, "check var value.")
}
}
こちらの例はとてもシンプルで最初のevaluateScriptで変数を定義し、それを次の行で取り出してその結果をInt32形式にしたものです。
最終的にそれをXCTAssertで確認をしているだけです。
この例からわかることはJSContextを作りその中にjavascriptのコードをevalさせてcontextに流し込めて、それをまた別の行の処理で評価(取り出し)ができることです。
JavascriptでXCTest応用編 その1
さてここからが本番になります。
RettyではHandlebarsというJavascriptのHTMLテンプレートを使っています。
http://handlebarsjs.com/
Handlebarsのいいところは、自前のhelperメソッドを定義できる、事前コンパイルをすることで実行速度をあげることができるということです。
応用編ではHandlebarsのライブラリにある基本メソッドを評価してみます。
class ThirdLibraryJSCoreTest: XCTestCase {
let context = JSContext()
override func setUp() {
super.setUp()
// Put setup code here. This method is called before the invocation of each test method in the class.
if let path = Bundle.main.path(forResource: "handlebars-v4.0.5", ofType: "js", inDirectory: "www/js") {
let script = try! String(contentsOfFile: path, encoding: String.Encoding.utf8)
context?.evaluateScript(script)
}
}
func test3rdLibraryTest() {
// This is an example of a functional test case.
// Use XCTAssert and related functions to verify your tests produce the correct results.
let script = "var source = '{{#if value}}foo{{else}}bar{{/if}}';"
var params, value : String
params = "var params = { value : true };"
context?.evaluateScript(script)
context?.evaluateScript(params)
context?.evaluateScript("var template = Handlebars.compile(source);")
value = (context?.evaluateScript("template(params);").toString())!
XCTAssert(value == "foo", "check library value.")
params = "var params = { value : false };"
context?.evaluateScript(params)
context?.evaluateScript("template = Handlebars.compile(source);")
value = (context?.evaluateScript("template(params);").toString())!
XCTAssert(value == "bar", "check library value.")
}
}
先ほどの基本編であったようにJSContextを作りその中にjavascriptのコードをevalさせてcontextに流し込める
ということはつまり、ライブラリも文字列として読み込んでそれをevalすればcontextに流し込めるということです。
そのため、setUp
内でライブラリのファイルを文字列として取得してそれをcontextにevalしてます。
それによってcontextは今Handlebarsのライブラリを読み込んだ状態となっています。
test3rdLibraryTest
ではHandlebarsの基本の関数であるif-else文
を書いています。
その後にvalueの内容をtrue/false
にした際の評価結果を求めてXCTAssertで比較検証しています。
valueがtrue
の時はfoo
が返り、true
の時はfoo
が返るというテストを書いています。
これによって基本の条件分岐が期待どおりに動作することをテストできます。
もちろん、true/false
だけでなく0/1
や空文字列など色々な比較パターンはあるのでもっとテストを書いていくことはできますが、Handlebars公式ライブラリでもテストコードは書いてあるのでここはそんなに重要ではありません。
大事なことは、サードパーティのJavascriptライブラリに対するテストコードも記載できる
ということです。
JavascriptでXCTest応用編 その2
前の節でHandlebarsのいいところは、自前のhelperメソッドを定義できる
ところと記載しました。
今回の記事の目玉はまさにココになります。
Handlebarsの公式のメソッドは既にテスト済みなので、自分で定義したhelper関数が期待どおりに動くかどうか保証するのは自分しかいないわけです。
では、さっそくやってみましょう。
今回は基本となる条件式の比較helperメソッドの比較をしてみましょう。
(Handlebarsにはif-else文
はありますが、何故か大小比較やイコール比較などがなくtrue/false
の比較しかなく、使いたければ自分で定義する必要があります)
function registerHelpersAndPartials() {
// {{#ifCond unicorns "<" ponies }}
// I knew it, unicorns are just low-quality ponies!
// {{/ifCond}}
Handlebars.registerHelper('ifCond', function(lvalue, operator, rvalue, options) {
if (arguments.length < 4) {
throw new Error("Handlerbars Helper 'ifCond' needs 3 parameters");
}
// validate value, if values is undefined then convert 0.
if (operator != 'typeof') {
if (typeof lvalue == 'undefined') lvalue = 0;
if (typeof rvalue == 'undefined') rvalue = 0;
}
var operators = {
'==': function(l,r) { return l == r; },
'===': function(l,r) { return l === r; },
'!=': function(l,r) { return l != r; },
'<': function(l,r) { return l < r; },
'>': function(l,r) { return l > r; },
'<=': function(l,r) { return l <= r; },
'>=': function(l,r) { return l >= r; },
'||': function(l,r) { return l || r; },
'&&': function(l,r) { return l && r; },
'typeof': function(l,r) { return typeof l == r; }
}
if (!operators[operator])
throw new Error("Handlerbars Helper 'ifCond' doesn't know the operator "+operator);
var result = operators[operator](lvalue,rvalue);
if( result ) {
return options.fn(this);
} else {
return options.inverse(this);
}
});
}
registerHelpersAndPartials();
これにより{{#ifCond leftValue 'operator' rightValue}}xxx{{else}}yyy{{/ifCond}}
というような構文が使えるようになります。
ではこのメソッドをテストしていきましょう。
class MyLibraryJSCoreTest: XCTestCase {
let context = JSContext()
override func setUp() {
super.setUp()
// Put setup code here. This method is called before the invocation of each test method in the class.
if let path = Bundle.main.path(forResource: "handlebars-v4.0.5", ofType: "js", inDirectory: "www/js") {
let script = try! String(contentsOfFile: path, encoding: String.Encoding.utf8)
context?.evaluateScript(script)
}
if let path = Bundle.main.path(forResource: "handlebars-helper", ofType: "js", inDirectory: "www/js") {
let script = try! String(contentsOfFile: path, encoding: String.Encoding.utf8)
context?.evaluateScript(script)
}
}
func testMyLibraryTest() {
// Int
_coreMyLibraryTest("3", condition: "<", right: "4", expect: true)
_coreMyLibraryTest("3", condition: ">", right: "4", expect: false)
_coreMyLibraryTest("3", condition: "!=", right: "4", expect: true)
// float
_coreMyLibraryTest("3.5", condition: "<", right: "3.501", expect: true)
_coreMyLibraryTest("3.5", condition: "<=", right: "3.50", expect: true)
// String
_coreMyLibraryTest("'sample'", condition: "==", right: "'sample'", expect: true)
_coreMyLibraryTest("'example'", condition: "!=", right: "'sample'", expect: true)
}
func _coreMyLibraryTest(_ left : String, condition: String, right: String, expect:Bool) {
let script = "var source = '{{#ifCond left \"\(condition)\" right}}foo{{else}}bar{{/ifCond}}';"
var params, value : String
params = "var params = { left : \(left), right : \(right) };"
context?.evaluateScript(script)
context?.evaluateScript(params)
context?.evaluateScript("var template = Handlebars.compile(source);")
value = (context?.evaluateScript("template(params);").toString())!
XCTAssert((value == "foo") == expect, "check my library value.")
}
}
先ほどの応用編1のようにsetUp
内でライブラリの読み込みと自分で定義したhelperメソッドのjavascriptメソッドを読み込みます。
その後testMyLibraryTest
内で色々な比較をするわけですが、左辺・右辺の値とoperatorの組み合わせなので_coreMyLibraryTest
というメソッドを用意してその中で比較結果が期待通り(true
)かそうでないか(false
)かを判定するメソッドを作りました。
ここではInt, Float, Stringの比較をそれぞれ書いてますが、どれもうまく評価できています。
その他のoperatorもあるので実際にはもっと充実させることでこのhelperメソッドをより正確なものにしていけます。
サンプルコード
下記にサンプルコードをあげていますので参考にしていただければと思います。
なお、当初サンプルを作ったときはXcode 7系を使っていたので、Xcode 7用とXcode 8用のtagを付けていますのでそちらからダウンロードしていただけると幸いです。
さいごに
よく使われている部分、基底となる部分は安心して動いてもらいたいのでテストコードは書いておきたいですよね。
最近もATS周りでhttpからhttps化した際に一部のJavascriptテストがコケていたことから変更に気づけた事例もあったので転ばぬ先の杖だと思います。
WebViewをこよなく愛する同志のための一例になれば幸いです^ω^