みなさんこんにちは!
先週に引き続きまして、スマホ版Ameba担当の川口です。

前回予告した通り、後編ではPhantomJSを使ったさらに実践的なテスト手法を解説していきます。

※前編に関しては以下のリンクを参照ください。
PhantomJSを使ったスマホサイトテストの自動化(前編)

内容に関しては、前回お伝えしたとおり

・認証を前提としたページのテスト方法
・Sinon.JSを利用したAjaxのレスポンスの改変


という構成になっています。

認証を前提としたページのテスト方法

みなさんは認証が必要なサイトのテストをブラウザで行う場合、いつもどのようにテストされているでしょうか?

・アカウントとパスワードを手入力してログイン後のページをチェック
・男性ユーザーと女性ユーザーで表示が変わるため、それぞれでログインし直して表示を確認
・ユーザーのカスタマイズによって自由に表示が変えられるページを、色々なパターンで作ったアカウントを用いて1つずつ確認
・さらには上記のようなパターンを新しいリリースが行われるたびに一から(ry


上げればキリがないのですが、昨今の複雑化したWebサイト、特にユーザー認証を挟んで利用するようなサービスではとにかくテストが大変です。
ですが、そのようなサイトでもPhantomJSを使えば上記のようなパターンを全て自動でテストすることができます。

※前回と同じくMacOSX上での動作を前提として進めていきます。

■使用するツール・ライブラリ
・Node.js (0.10.11 最新安定バージョン)
  言わずと知れたサーバサイドJavaScriptです。
  今回のテスト対象である認証が実装されたサンプルをNode.jsで作成しましたので、まずこれをインストールします。
  Node.js公式サイト

・Express (2.5.11)
  Node.js用のフレームワークです。
  Express公式サイト

・jade (0.31.2)
  Node.js用のテンプレートエンジンです。
  jade公式サイト

Node.jsのインストール
公式サイトにあるインストールパッケージを使ってインストールしてもOKですが、brewが使える場合は
$ brew install node

でもインストール可能です。
※brewからのインストールの場合、最新安定版であるv0.10.11ではなくv0.10.0でインストールされますがこちらでも問題ないです。

認証が実装されたサンプル
下記URLにアップしていますので、ダウンロードして作業ディレクトリに配置して下さい。
なお、今回用意したサンプルはCookieのみを使ったあくまで簡易的な認証システムですので、実際には使用しないで下さい。
https://github.com/feb0223/1pixelTestSample201306_02

Express, jadeのインストール
ダウンロードしたサンプルのpackage.jsonにインストール設定を記述していますので、
$ cd ./server_sample
$ npm update

と実行すればインストールされます。
※npmはNode.js用のパッケージ管理ツールです。Node.jsのインストール時に同時にインストールされます。

さて、これで準備が整いましたので、ここから実際にテストを実行していきます。

まず認証が実装されたサンプルのサーバーを立ちあげます。
$ cd ./server_sample
$ node server.js

この状態でブラウザからhttp://localhost:8080にアクセスするとログイン画面が表示されます。

このサンプルを操作するPhantomJSのコードは以下の通りです。

test_auth.js
(function() {
var webpage = require('webpage');
var mocha = require('./lib/mocha-phantom.js').create({reporter:'spec', timeout:1000*60*5});
require('./lib/expect.js');

mocha.setup('bdd');

// テストの定義
describe('認証テスト', function() {
// pageオブジェクトを作成
var page = webpage.create();
// 画面のサイズを設定
page.viewportSize = {width: 320, height: 480};

/**
* 指定したアカウントの認証Cookieデータを取得
* @param {String} account
* @param {String} password
* @param {Function} callback
*/
function getAuthCookieData(account, password, callback) {
// Cookieクリア
phantom.clearCookies();

// 指定したページをオープン
page.open('http://localhost:8080', function(status) {
if (status !== 'success') {
console.log('error!');
phantom.exit();
return;
}

// ログインボタン押下時にも再度openイベントが走ってしまうため、
// ここで終わらせる。
if (page.url.match('\/top$')) {
return;
}

page.evaluate(function(account, password) {
$('#account').val(account);
$('#password').val(password);
}, account, password);

// ログインボタンの位置を取得
var btnClickPosition = page.evaluate(function() {
var btnShowModal = $('#login_btn').get(0);
var rect = btnShowModal.getBoundingClientRect();

var sx = (btnShowModal.screen) ? 0 : document.body.scrollLeft;
var sy = (btnShowModal.screen) ? 0 : document.body.scrollTop;
var position = {
left: Math.floor(rect.left + sx),
top: Math.floor(rect.top + sy),
width: Math.floor(rect.width),
height: Math.floor(rect.height)
};

return {
left: Math.round(position.left + position.width / 2),
top: Math.round(position.top + position.height / 2)
};
});

// ボタンをクリックしてログインする
page.sendEvent('click', btnClickPosition.left, btnClickPosition.top);

// submit処理のため1秒待つ
setTimeout(function() {
callback(phantom.cookies);
}, 1000);
});
}

var authCookieData = {};

// テストの前処理の記述
before(function(done) {
// user1,user2の認証Cookieデータを取得
getAuthCookieData('user1', 'user1password', function(user1Cookie) {
authCookieData['user1'] = user1Cookie;
getAuthCookieData('user2', 'user2password', function(user2Cookie) {
authCookieData['user2'] = user2Cookie;
done();
});
});
});

describe('user1でログイン', function() {
before(function(done) {
// Cookieクリア
phantom.clearCookies();
// user1の認証Cookieデータをセット
var cookies = authCookieData.user1;
for (var i=0; i < cookies.length; i++) {
phantom.addCookie(cookies[i]);
}
page.open('http://localhost:8080', function(status) {
if (status !== 'success') {
console.log('error!');
phantom.exit();
return;
}
// 画面のキャプチャ
page.render('./capture/user1_auth.png');
done();
});
});

describe('表示チェック', function() {
it('ユーザー名', function() {
var welcomeText = page.evaluate(function() {
return $('#main h4').text();
});
expect(welcomeText).to.be('ようこそuser1さん!');
});
});
});

describe('user2でログイン', function(done) {
before(function(done) {
// Cookieクリア
phantom.clearCookies();
// user2の認証Cookieデータをセット
var cookies = authCookieData.user2;
for (var i=0; i < cookies.length; i++) {
phantom.addCookie(cookies[i]);
}
page.open('http://localhost:8080', function(status) {
if (status !== 'success') {
console.log('error!');
phantom.exit();
return;
}
// 画面のキャプチャ
page.render('./capture/user2_auth.png');
done();
});
});

describe('表示チェック', function() {
it('ユーザー名', function() {
var welcomeText = page.evaluate(function() {
return $('#main h4').text();
});
expect(welcomeText).to.be('ようこそuser2さん!');
});
});
});

after(function() {
// PhantomJSの終了
phantom.exit();
});
});

// テストの実行
var runner = mocha.run();
})();

実行の際は、先ほどNode.jsのサーバーを立ち上げたものとは別のコンソールを立ちあげ、
$ cd ./phantomjs
$ phantomjs test_auth.js

という風に実行すればOKです。

一応PhantomJSでのテストの流れを解説すると、

1.PhantomJS上でログインページからアカウントとパスワードを指定してログイン。
2.ログイン後にCookie情報を抜き出して保持。
3.1~2の流れを別ユーザーでも実行してユーザーごとにCookie情報を保持。
4.ログインしたいユーザーのCookieをpage.open前にPhantomJSにセットしてログイン状態を再現。
5.ページを開くと、Cookieに認証情報が入っているため認証後のページにアクセスできる。


以上のような流れになっています。

テストの結果はどうでしたでしょうか?
正しく実行できていればコンソールに以下のように表示されると共に、/phantomjs/captureディレクトリにキャプチャ画像が保存されていると思います。
認証テスト
user1でログイン
表示チェック
ユーザー名
user2でログイン
表示チェック
ユーザー名
2 tests complete (3 seconds)


Sinon.JSを利用したAjaxのレスポンスの改変

Ajaxを使ってフロントでデータを取得してJavaScriptでDomを描画。
Webサイトの表示を速くする効果もあるため、昨今のサイトでは必ずといっていいほど使われている技術です。
ですがフロントで完結する作業だけあり、

・データ数が多い場合は「もっとみる」ボタンを表示
・逆にデータ数が少ない場合はページのレイアウトを調整
・Ajaxでエラーが返った場合は専用のエラーメッセージを表示する


などなど・・・
先ほどの認証を前提としたページに負けないぐらいの細かい表示分けがあることと思います。

こんなケースでもPhantomJS、そしてSinon.JSを使えばAjaxのリクエストを好きに改変して色々なパターンでテストすることができます。

使用するツール・ライブラリ
・Node.js (0.10.11 最新安定バージョン)

・Sinon.JS (2.5.11)
  Sinon.JS
  JasmineやMochaなどのテストフレームワークと組み合わせて使う、テストに関する様々なユーティリティを含んだライブラリです。
  残念ながら私はそこまで詳しくはないのですが、特に便利なものではフロントのJSにおけるxhr処理の置き換えやtimer系関数の置き換えなど、普段はテストが難しい箇所でもSinon.JSを使えば細かいテストが可能になります。
  更に詳しい解説については@hokacchaさんのスライドを見てもらえれば分かりやすいと思います。
  http://hokaccha.github.io/slides/sinonjs/

テスト対象のサンプル
先ほどの "認証を前提としたページのテスト" で使用したサンプルをそのまま使います。
先ほどと同じように
$ cd ./server_sample
$ node server.js

でサーバーを立ちあげておいてください。
テスト対象のページはhttp://localhost:8080/modalです。
(前回のモーダルウィンドウの使い回しですすいませんすいません)

このサンプルを操作するPhantomJSのコードは以下の通りです。
test_sinonjs.js
(function() {
var webpage = require('webpage');
var mocha = require('./lib/mocha-phantom.js').create({reporter:'spec', timeout:1000*60*5});
require('./lib/expect.js');

mocha.setup('bdd');

// テストの定義
describe('モーダルウィンドウテスト', function() {
// pageオブジェクトを作成
var page = webpage.create();
// 画面のサイズを設定
page.viewportSize = {width: 320, height: 480};

// テストの前処理の記述
before(function(done) {
// 指定したページをオープン
page.open('http://localhost:8080/modal', function(status) {
if (status !== 'success') {
console.log('error!');
phantom.exit();
return;
}

// モーダル表示ボタンの位置を取得
var btnClickPosition = page.evaluate(function() {
var btnShowModal = $('#btn_show_modal').get(0);
var rect = btnShowModal.getBoundingClientRect();

var sx = (btnShowModal.screen) ? 0 : document.body.scrollLeft;
var sy = (btnShowModal.screen) ? 0 : document.body.scrollTop;
var position = {
left: Math.floor(rect.left + sx),
top: Math.floor(rect.top + sy),
width: Math.floor(rect.width),
height: Math.floor(rect.height)
};

return {
left: Math.round(position.left + position.width / 2),
top: Math.round(position.top + position.height / 2)
};
});

// Sinon.jsの読み込み
page.injectJs('./include/sinon-1.7.1.js');

// xhrを置き換えてダミーサーバーを作成
page.evaluate(function() {
var server = sinon.fakeServer.create();
var response = [200, {}, '{"title":"モーダルウィンドウテストSinon.JS", "description":"PhantomJS上でSinon.JSを使ってAjaxのレスポンスを改変しています。"}'];
server.respondWith('GET', '/json/detail.json', response);
server.autoRespond = true;
// レスポンスを返すまでの時間をmsで指定
server.autoRespondAfter = 100;
window.sinonServer = server;
return;
});

// ボタンをクリックしてモーダルウィンドウを表示
page.sendEvent('click', btnClickPosition.left, btnClickPosition.top);

// Ajaxリクエストが発生するため1秒待つ
setTimeout(function() {
// ページのキャプチャ
page.render('./capture/modal_capture.png');

// 非同期処理の終了
done();
}, 1000);

});
});

describe('表示チェック', function() {
it('ウィンドウ', function() {
var modalLength = page.evaluate(function() {
return $('#main .modal').length;
});
expect(modalLength).to.be(1);
});

it('タイトル', function() {
var titleText = page.evaluate(function() {
return $('#main .modal .title').text();
});
expect(titleText).to.be('モーダルウィンドウテストSinon.JS');
});

it('説明', function() {
var descriptionText = page.evaluate(function() {
return $('#main .modal .description').text();
});
expect(descriptionText).to.be('PhantomJS上でSinon.JSを使ってAjaxのレスポンスを改変しています。');
});
});

after(function() {
// xhrをもとに戻す
page.evaluate(function() {
window.sinonServer.restore();
});
// PhantomJSの終了
phantom.exit();
});
});

// テストの実行
var runner = mocha.run();
})();

実行の際も先ほどと同じようにNode.jsのサーバーを立ち上げたものとは別のコンソールを立ちあげ、
$ cd ./phantomjs
$ phantomjs test_sinonjs.js

という風に実行すればOKです。

テストの結果はどうでしたでしょうか?
正しく実行できていればコンソールに以下のように表示されると共に、captureディレクトリに画像が保存されていると思います。
モーダルウィンドウテスト
表示チェック
ウィンドウ
タイトル
説明
3 tests complete (2 seconds)

キャプチャした画像
$1 pixel|サイバーエージェント公式クリエイターズブログ

このテストサンプルでは、ページ表示後のボタンがクリックされる前のタイミングでxhrの置き換えをしてAjaxを改変しましたが、ページの表示と同時にAjaxを実行しているようなケースではこれではうまくいきません。
ですがそんなケースでも、page.onInitializedを使ってフロントのJSが読み込まれる前のタイミングでSinonJSの処理を実行してしまえばOKです。
// page.onInitializedを使った例
page.onInitialized = function() {
// Sinon.jsの読み込み
page.injectJs('./include/sinon-1.7.1.js');

// xhrを置き換えてダミーサーバーを作成
page.evaluate(function() {
var server = sinon.fakeServer.create();
var response = [200, {}, '{"title":"モーダルウィンドウテストSinon.JS", "description":"PhantomJS上でSinon.JSを使ってAjaxのレスポンスを改変しています。"}'];
server.respondWith('GET', '/json/detail.json', response);
server.autoRespond = true;
// レスポンスを返すまでの時間をmsで指定
server.autoRespondAfter = 100;
window.sinonServer = server;
return;
});
};


今回のソースを下記にアップしていますので、よければそちらもご覧ください。
https://github.com/feb0223/1pixelTestSample201306_02

終わりに

前編・後編と分けて解説したPhantomJSでのテストはどうでしたでしょうか?
テストフレームワークやNodeをある程度理解している前提で解説した部分も多かったため、分かりにくいところもあったかと思いますが、これを機にPhantomJSについて興味を持っていただければ幸いです。

そして!
そんな技術を使ったスマートフォン版Amebaもどうかよろしくお願いしますm(_ _)m
https://s.amebame.com