Casper.JSのススメ

2013/9/12 追記
続きを書きました: 続・Casper.JSのススメ

Phantom.JSというヘッドレスブラウザがある。
これが超便利なんだけど、セッション周りとか込み入った操作をしようと思うと途端に操作(JSファイルへの記述)が面倒になる。
そこで、Casper.JSの出番だ。
CasperはPhantomと連携するライブラリで、簡単な記述で複雑な操作を実現することができる。
また、Phantomのレンダリングエンジンはwebkitで、Geckoで動いているSlimer.JSってのもあって、これらの上で動くCasperを使っているとレンダリングエンジンをどっちも使えるので捗る(と思う)。
今回はEnd-to-Endテストの実現を目的に、Casper.JSでwebサイトの操作とHTMLの解析を行う。
もちろん、スクレイピングなどにも応用可能だ。

f:id:saisa6153:20130817004612j:plain
ヨルムンガンドに出てくる主人公(ココ・ヘクマティアル)の兄キャスパー・ヘクマティアル。
このイメージが強いので、僕はCasper.JSをカスパーではなくキャスパーと読んでいる。

環境構築

※Phantom.JS
$ wget https://phantomjs.googlecode.com/files/phantomjs-1.9.1-source.zip
$ unzip phantomjs-1.9.1-source.zip
$ phantomjs-1.9.1/build.sh
$ sudo cp phantomjs-1.9.1/bin/phantomjs /usr/local/bin/
※Casper.JS
$ git clone git://github.com/n1k0/casperjs.git
$ sudo ln -sf `pwd`/bin/casperjs /usr/local/bin/casperjs

ログイン操作を行う
localhostで動いているwebアプリをターゲットとする。
ログインが必要な時の例を書くと、

// casperオブジェクトを生成
var casper = require('casper').create({
    verbose: true,
    logLevel: 'debug',
    pageSettings: {
         loadImages:  false,         // The WebPage instance used by Casper will
         loadPlugins: false,         // use these settings
         userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_5)'
    }
});

var url = 'http://localhost/login';

// まずはフォーム要素に値を入れてログインボタンをクリック
casper.start(url, function(){
    this.fill('ul#login', {
        'login_id': 'hogehoge',
        'password': 'fugafuga'
    }, false);
    this.click('input[type="submit"][name="login"]');
});

// ログインページにしか表示されない要素でログインの成否を確認
casper.then(function(){
    this.test.assertExists('div[id="admin"]', 'login message is found');
});

// これが必要
casper.run();

で、Casper.JSはCoffeeScriptを直接解釈できるので、以下の様なcoffeescriptを書いても同じ動作をさせられる。

casper = require('casper').create
  verbose: true
  logLevel: 'debug'
  pageSettings:
    loadImage: false
    loadPlugins: false
    userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_7_5)'

casper.start 'http://localhost/login', ->
  @test.assertExists 'ul#login', 'form is found'
  @fill 'ul#login',
    'login_id': 'hogehoge'
    'password': 'fugafuga'
  @click 'input[type="submit"][name="login"]'

casper.then ->
  @test.assertExists 'div[id="admin"]', 'login message is found'

casper.run ->
  @test.renderResults true

コメントを省略したとはいえ、可読性が大幅に向上した。
テストシナリオが増えてくるとJSだと厳しそうなので、可能ならcoffeeで書いたほうがメンテナンス性が維持しやすそう。

$ casperjs test login_script.coffee --log-level=debug と実行すると

Test file: login_script.coffee
[info] [phantom] Starting...
[info] [phantom] Running suite: 3 steps
[debug] [phantom] opening url: http://localhost/login, HTTP GET
[debug] [phantom] Navigation requested: url=http://localhost/login, type=Other, willNavigate=true, isMainFrame=true
[debug] [phantom] url changed to "http://localhost/login"
[debug] [phantom] Successfully injected Casper client-side utilities
[info] [phantom] Step anonymous 2/3 http://localhost/login (HTTP 200)
PASS form is found
[info] [remote] attempting to fetch form element from selector: 'ul#login'
[debug] [remote] Set "login_id" field value to hogehoge
[debug] [remote] Set "password" field value to ********
[debug] [phantom] Mouse event 'mousedown' on selector: input[type="submit"][name="login"]
[debug] [phantom] Mouse event 'mouseup' on selector: input[type="submit"][name="login"]
[debug] [phantom] Mouse event 'click' on selector: input[type="submit"][name="login"]
[info] [phantom] Step anonymous 2/3: done in 175ms.
[debug] [phantom] Navigation requested: url=http://localhost/login_execute, type=FormSubmitted, willNavigate=true, isMainFrame=true
[debug] [phantom] Navigation requested: url=http://localhost/admin, type=FormSubmitted, willNavigate=true, isMainFrame=true
[debug] [phantom] url changed to "http://localhost/admin"
[debug] [phantom] Successfully injected Casper client-side utilities
[info] [phantom] Step anonymous 3/3 http://localhost/admin (HTTP 200)
PASS login message found
[info] [phantom] Step anonymous 3/3: done in 414ms.
[info] [phantom] Done 3 steps in 425ms

もちろん、JSを実行しても同じ結果になる。

複数ファイルでテストしたい
テストシナリオ毎とかでファイルやディレクトリを分けてどんどんテストを書いていくと思うのだけど、その場合上記の記述では対応できない。
公式ドキュメントにも"複数ファイルのスクリプトを読ませるときはcasperインスタンスの生成ができません"みたいに書いててちょっと詰んだ。
もちろん試してみたけど、どうやらcasperインスタンスのrun()メソッドで処理が止まってしまうらしい。何を待っているんだ。
そういう訳で、casperインスタンスを使用せずに上記テストを記述する。
例えば以下の様な構成の時。

$ tree
.
├── inc.coffee
├── post.coffee
├── pre.coffee
└── tests
    ├── 01-suit
    │   ├── 01-test.coffee
    │   └── 02-test.coffee
    └── 02-suit
        ├── 01-test.coffee
        └── 02-test.coffee

この時評価の順番は

  1. pre.coffee
  2. inc.coffee + tests/01-suit/01-test.coffee
  3. inc.coffee + tests/01-suit/02-test.coffee
  4. inc.coffee + tests/02-suit/01-test.coffee
  5. inc.coffee + tests/02-suit/02-test.coffee
  6. post.coffee

後で説明するけど、pre, post, incはスクリプトの実行全体に適用されるsetup, tearDown, beforeEach的な。実行時に引数で指定。
ここでtests/suit名/case名.coffeeのスクリプトに以下のような記述をする。

# わかりやすいように出力させる
casper.test.comment '01-login, test1'

casper.test.begin 'login-test', ->
  # casperをスタート(インスタンス化しない)
  casper.start()
  # URLを開き、処理を開始
  casper.thenOpen 'http://localhost/login', ->
    # 管理画面トップのログイン前にしか無い要素を確認
    casper.test.assertExists 'ul#login', 'form is found'
    # フォームにID/PWを入力
    casper.fill 'ul#login',
      'login_id': 'hogehoge'
      'password': 'fugafuga'
    # 要素を指定してクリック実行
    casper.click 'input[type="submit"][name="login"]'

  # 遷移先にしか無い要素を見ることでログインを確認
  casper.then ->
    casper.test.assertExists 'div[id="admin"]', 'login message is found'

  # テスト実行と完了
  casper.run ->
    casper.test.done()

$ casperjs test tests/ --pre=pre.coffee --includes=inc.coffee --post=post.coffee --log-level=debug みたいにして実行。

Test file: /home/saisa/mytests/pre.coffee
pre.coffee: executed before the suite.
Test file: /home/saisa/mytests/tests/01-suit/01-test.coffee
# dir1, test1
inc.coffee: included
PASS Subject is strictly true
Test file: /home/saisa/mytests/tests/01-suit/02-test.coffee
# dir1, test2
PASS Subject is strictly true
Test file: /home/saisa/mytests/tests/02-suit/01-test.coffee
# dir2, test1
inc.coffee: included
PASS Subject is strictly true
Test file: /home/saisa/mytests/tests/02-suit/02-test.coffee
# dir2, test2
inc.coffee: included
PASS Subject is strictly true
Test file: /home/saisa/mytests/post.coffee
post.coffee: executed after the suite.
PASS 4 tests executed in 0.147s, 4 passed, 0 failed, 0 dubious, 0 skipped.

ここで実行時にディレクリを指定した場合、--preとかが無いと何故か動かないっぽい。
casperjsコマンドに続けてtestサブコマンドとテストケースが入っているディレクトリを指定するんだけど、"/"をいれてディレクトリであることを明示する必要があるので注意。

  • pre.coffee: 最初に一度だけ実行される
casper.echo "pre.coffee: executed before the suite."
casper.test.done()
  • inc.coffee: テストの最初に毎回インクルードされる
casper.sayHi = ->
  casper.echo "inc.coffee: included"
  • post.coffee: 最後に一度だけ実行される
casper.echo "post.coffee: executed after the suite."
casper.test.done()

これで先程の記述と同じ動作をするし、複数ファイルが流れるように動作する。
ポイントは以下のとおり。

  • テスト開始、URLオープン、フォーム入力やテストといったブラウザ(Phantom)の操作をcasper.test.beginに渡して実行させる
  • casper.test.begin内でのcasper.runとtest.done()を忘れずに
  • casper.test.begin内でsetupやtearDownが書けるのでよしなに使う
  • 参考: 付属のサンプルコード(casperjs/tests/suites/http_status.js)
  • inc.coffee以外の各テストケースにはcasper.test.done()が必要
  • casper.test.doneみたいに()を付けないと変数として扱われて、テストが終了しない

特に最後に2点で詰まった。

あとXML出力もできるっぽいので、Jenkinsとの連携ができれば運用も少し楽できそうで良さげ。

参考ページ