桜の花が散っていくときって、寂しさと同時に、新緑の季節も予感させるからいい感じですよね。
そんな4月ですが、ここの所以前に作ったEnd 2 End テストの完全な作り直しを行っています。以前に作ったEnd 2 Endテストは、以下のような構成でした。
- https://github.com/jnicklas/capybara
- rspec
- Selenium hub on Docker on Virtualbox
- 併せて Chrome on Docker
のようにして、DockerにXvfbを利用して構築したChromeをSelenium hubに参加させて、Virtualboxの中だけで完結できるような構成にしてました。
ただし、この構成と私が担当しているシステムの相性がすこぶる悪く、毎回Googleログインが必要となってしまい、最終的にはCaptchaが毎回必須となるようになってしまいました。
(システム自体はシングルサインオンを利用してGoogle Appsのアカウント認証を取得しているため、システム自体にログインという概念がない)
さらに、
− 現在そして将来、自分以外にRubyが書けそうな人が来る気配はない
− Rubyを教える教育コスト(自分はいずれいなくなるので・・・)
ということもあり、とりあえず作ったはいいものの、流すことも出来ずに放置していました。
JavaScriptへの書き換え
しかし、最近システムリリース前の確認作業(手動)の分量が多くなってきて、かつ流れ作業なので確認自体がおざなり・・・というとてもありがちな状況となってきたので、メンテナンスコストを勘案しても、作って継続的に流せる方がいいだろう、ということで、E2Eテストをちゃんとした工数を使って作成することにしました。
前述の事情があるので、以前Rubyで書いたものは破棄して、メンバー全員が書ける動的言語=JavaScriptで書きなおすことにしました。Groovy自体は導入していたので、Gebという手段もありましたが、Gebは以前使ってみてなんかしっくりこなかったのと、結局はコンパイルが必要なので、手軽さを優先しました。
さて、そうなるとSeleniumを利用するのは確定事項として、ライブラリとして何を利用するか、を決める必要がありました。
これについては、Rubyで書く前にもさらっと調べましたが、概ね下に挙げたライブラリが利用されているようです。
- https://github.com/segmentio/nightmare
- Electron/PhantomJSを利用して動作。対象システムは、Chrome限定(!)なのでとりあえず対象外
- https://github.com/SeleniumHQ/selenium/wiki/WebDriverJs
- Selenium公式で提供している。API的には標準のAPIなので、Javaとかで書いてた人はほとんどそのまま。
- 以前調べた時は琴線に触れなかったので対象外(ぉ
- https://github.com/groupon/testium
- 以前JavaScriptで試しに書いた時に使ったライブラリ
- よさ気だったが、気づいたらCoffeeScript前提になってるっぽいので対象外
- https://github.com/admc/wd
- この中では有名ドコロ。yieldとか使って非同期を同期的に書けたり。
色々あるようですが、基本的には Testiumを除いては、全てPromiseを利用した非同期チェインが前提となっています。
Capybaraを利用していて気持ちが良かったのが、非同期な部分がライブラリ側でほぼすべて隠蔽されていて、普通に手順を書くのと同じ感覚でテストを書ける、ということでした。
それに加えて、 https://github.com/natritmeyer/site_prism というCapybara用のDSLライブラリが提供されており、PageObjectパターンが非常に書きやすいこと、これを利用しなくても、取得したElement以下のオブジェクトからの相対的な検索とかがやりやすい、ということなども、Capybaraが使いやすかった要因だったと思います。
で、最終的に今回は http://webdriver.io/ にMocha、PowerAssertを組み合わせて利用することにしました。
選定のポイントは、
- v4からAPIが刷新され、条件付きではあるが、Capybara/Testiumのように同期的なAPIを利用して書ける
- MochaやJasmineなど、テストランナーは自分で選べる。
- まぁMochaにしておきましたが。
- 検索結果として取得できるオブジェクトに対して、APIをチェイン出来る
- 開発が活発(若干進み過ぎると不安になるけど、E2Eテスト用なのである程度は許容)
3番目は書いた本人も言葉にしづらいんですが、次の様な感じです。
driver.element('#foo') // id=fooという要素
.element('.name') // id=fooの中の.name
PageObjectパターン
WebdriverIOを選定した理由にはもうひとつあって、公式でPageObjectパターンを適用する例があった、ということもあります。
公式に書いてある例は以下のような感じでした。
// login.page.js
var Page = require('./page')
var LoginPage = Object.create(Page, {
/**
* define elements
*/
username: { get: function () { return browser.element('#username'); } },
password: { get: function () { return browser.element('#password'); } },
form: { get: function () { return browser.element('#login'); } },
flash: { get: function () { return browser.element('#flash'); } },
/**
* define or overwrite page methods
*/
open: { value: function() {
Page.open.call(this, 'login');
} },
submit: { value: function() {
this.form.submitForm();
} }
});
module.exports = LoginPage
browserというオブジェクトは、WebdriverIOのCLIから流した時に自動的に作成されるグローバルなオブジェクトです。
見てもらえればなんとなく使い方はわかるんじゃないかと思います。当初はこれを真似しようと思いました・・・しかし世界は甘くなかった。利用しようとしたら、すぐに次のような問題にぶち当たりました。
- 複雑な画面(ほぼすべての画面がこれ)だと対応しきれない
− テーブルの各行のようなものに対応するのがきつい
− 構造がネストしているような画面だと管理が死ぬ - WebdriverIOが提供する要素取得APIによって返るものが違ってストレス
- 単数取得のelementと複数取得のelementsで返ってくるものが違う(単純な配列ではなくて、別の形式になってる)
- Site_prismが気持ちよかったので使いたい(?
ということで、仕方ないので、Site_prismライクな使い方が出来る簡単なライブラリを作りました。こんな感じです。
'use strict';
let R = require('ramda');
let _ = require('lodash');
/**
* キーと値のペアから、セレクタからElementへの変換を定義したオブジェクトを
* 作成する
*
* @param {array[string]} pairs キーと値のペア
*/
function convertToSpec(pairs) {
let key = pairs[0];
let value = pairs[1];
let spec = {
name: key,
selector: '',
converter: null
};
if (_.isString(value)) {
spec.selector = value;
spec.converter = null;
} else {
spec.selector = value[0];
spec.converter = value[1];
}
return spec;
}
/**
* ある要素が表示されているかどうかを返すためのプロパティを追加する
*/
function addVisibilityProperty(obj, spec) {
obj[`has${_.upperFirst(spec.name)}`] = function() {
let e = _.result(obj, 'attachedElement');
if (!e) {
return browser.isVisible(spec.selector);
}
return _.every(
browser.elementIdElements(e.value ? e.value.ELEMENT : e.ELEMENT, spec.selector).value,
e => browser.elementIdDisplayed(e.ELEMENT)
);
};
}
/**
* ある要素が表示されるまで待つためのプロパティを追加する
*/
function addWaitingVisibleProperty(obj, spec) {
obj[`waitFor${_.upperFirst(spec.name)}`] = function() {
let e = _.result(obj, 'attachedElement');
if (!e) {
return browser.waitForVisible(spec.selector);
}
return browser.elementIdElements(e.value ? e.value.ELEMENT : e.ELEMENT, spec.selector).waitForVisible();
};
}
/**
* スペックから、単一のエレメントに対するプロパティをオブジェクトに定義する
*
* 簡易的にするため、渡されるオブジェクトにはElementというプロパティが定義されていること、そして
* 毎回取得するような処理としている。
*
* @param {object} obj プロパティを定義するオブジェクト
* @param {object} spec 定義するプロパティのスペック
*/
function applySingleElementSpec(obj, spec) {
Object.defineProperty(obj, spec.name, {
configurable: false,
get: function() {
let e = _.result(obj, 'attachedElement');
if (!e) {
return browser.element(spec.selector);
}
return browser.elementIdElement(e.value ? e.value.ELEMENT : e.ELEMENT, spec.selector);
}
});
addVisibilityProperty(obj, spec);
addWaitingVisibleProperty(obj, spec);
}
/**
* スペックから、複数のエレメントに対するプロパティをオブジェクトに定義する
*
* 簡易的にするため、渡されるオブジェクトにはElementというプロパティが定義されていること、そして
* 毎回取得するような処理としている。
*
* @param {object} obj プロパティを定義するオブジェクト
* @param {object} spec 定義するプロパティのスペック
*/
function applyMultiElementSpec(obj, spec) {
Object.defineProperty(obj, spec.name, {
configurable: false,
get: function() {
let e = _.result(obj, 'attachedElement');
if (!e) {
return browser.elements(spec.selector);
}
return browser.elementIdElements(e.value ? e.value.ELEMENT : e.ELEMENT, spec.selector);
}
});
addVisibilityProperty(obj, spec);
addWaitingVisibleProperty(obj, spec);
}
/**
* スペックから、単一のSectionに対するプロパティをオブジェクトに定義する
*
* @param {object} obj プロパティを定義するオブジェクト
* @param {object} spec 定義するプロパティのスペック
*/
function applySingleSectionSpec(obj, spec) {
Object.defineProperty(obj, spec.name, {
configurable: false,
get: function() {
if (!spec.converter) {
throw new Error('section spec have to need converter');
}
let e = null;
let el = _.result(obj, 'attachedElement');
if (el) {
e = browser.elementIdElement(el.value ? el.value.ELEMENT : el.ELEMENT, spec.selector);
} else {
e = browser.elements(spec.selector).value[0];
}
return new spec.converter(e);
}
});
addVisibilityProperty(obj, spec);
addWaitingVisibleProperty(obj, spec);
}
/**
* スペックから、複数のSectionに対するプロパティをオブジェクトに定義する
*
* @param {object} obj プロパティを定義するオブジェクト
* @param {object} spec 定義するプロパティのスペック
*/
function applyMultiSectionSpec(obj, spec) {
Object.defineProperty(obj, spec.name, {
configurable: false,
get: function() {
if (!spec.converter) {
throw new Error('section spec have to need converter');
}
let elements = null;
let el = _.result(obj, 'attachedElement');
if (el) {
elements = browser.elementIdElements(el.value ? el.value.ELEMENT : el.ELEMENT, spec.selector);
} else {
elements = browser.elements(spec.selector);
}
return elements.value.map(v => new spec.converter(v));
}
});
addVisibilityProperty(obj, spec);
addWaitingVisibleProperty(obj, spec);
}
const mapSpec = (f) => R.pipe(
R.toPairs,
R.map(convertToSpec),
R.forEach(f)
);
class ElementDSL {
/**
* 単一要素に対するプロパティを定義する
*/
defineElement(obj) {
mapSpec(R.curry(applySingleElementSpec)(this))(obj);
}
/**
* 複数要素に対するプロパティを定義する
*/
defineElements(obj) {
mapSpec(R.curry(applyMultiElementSpec)(this))(obj)
}
/**
* 単一要素に対するプロパティを定義する
*/
defineSection(obj) {
mapSpec(R.curry(applySingleSectionSpec)(this))(obj);
}
/**
* 複数要素に対するプロパティを定義する
*/
defineSections(obj) {
mapSpec(R.curry(applyMultiSectionSpec)(this))(obj);
}
}
module.exports = ElementDSL;
それなりに長いですが、やってることは割と単純です。これを使うと、例えば上にあった例なら次のように書くことが出来ます。
let ElementDSL = require('./element-dsl');
class LoginPage extends ElementDSL {
initialize() {
this.defineElement({
username: '#username',
password: '#password',
form: '#login',
flash: '#flash'
});
}
submit() {
this.form.submitForm();
}
});
module.exports = LoginPage
ちょっと余計なところは削ってますが、定義がシンプルになりました。他にも、複数の要素を扱う場合は defineElements
の方に書けば複数の要素に対応できます。また、あるテーブル内の複数行に対しては、
let ElementDSL = require('./element-dsl');
class Row extends ElementDSL {
initialize() {
this.defineElement({
name: '//td[1]',
id: '//td[2]'
});
}
}
class Page extends ElementDSL {
initialize() {
this.defineSections({
rows: ['table tbody tr', Row]
});
}
});
module.exports = Page
// 利用するとき
new Page().waitForRows(); // 自動的に作られるメソッド
new Page().rows[0].name.getText()
というようにできます。 defineElement
と同じような対比で、defineSection/Sectionsを用意しています。これを利用することで、ページの定義が多少はマシになりました。また、頻用する ある要素があるかどうかチェックする という処理と、 ある要素が表示されるまで待つ という処理については、自動的に has*
と waitFor*
というメソッドが生えるようになっています。
それぞれのシナリオ長すぎっ
上述のライブラリを利用して書き始めましたが、ある程度事前にわかったことではあるんですが、一つのシナリオがかなり長いです。具体的にどんくらいかというと、一番短いシナリオで100行位あります。これを一つのFeatureの中に複数書いていく、というのはかなりしんどい。
TurnipやCucumberでは、FeatureとStepという形で分解することで、長すぎない(それでも長いけど)ようにすることができていたので、同じように出来るように、すごいシンプルな関数を一つ用意しました。
let R = require('ramda');
let glob = require('glob');
let path = require('path');
/**
* パスから、オブジェクトをキーにしたシナリオの一覧を取得する
*
* @param {string} path 読込対象のパス
* @return {object} シナリオ名をキーにしたオブジェクト
*/
module.exports = function scenarioLoader(dir) {
let files = glob.sync(path.join(dir, 'scenario_*.js'));
let basename = v => path.basename(v, '.js');
return R.reduce((obj, v) => R.merge(obj, {[basename(v)]: require(v)}),
{}, files);
}
scenario_*
というファイル名にしておくことで、自動的に
{
"scenario_1": ...,
"scenario_2": ...,
...
}
というようなオブジェクトを自動的に作成できるようにしました。後は、各Feature毎のシナリオをそれぞれ単一関数になるように分けて配置します。そうすると、最終的に一番上のfeatureでは、
'use strict';
let scenarios = require('../scenarios/sample');
describe('サンプルFeature', function() {
describe('シナリオ1', scenarios.scenario_1);
describe('シナリオ2', scenarios.scenario_2);
});
このような形で記述できます。もし特定シナリオだけ確認したかったらそれだけ書けばいけます。
これから先の展望
Assertionを都度行わなければならないことと、それぞれ観点だったり条件が複雑だったりと、中々共通化が難しく、それぞれのシナリオで同じような処理をコピペしてしまっているのをなんとかしたいところです。
実際には、よほど共通化出来るもの以外では、むしろそれぞれで独立して書いてしまったほうがいいんではないかとも思ってますが、その辺はメンテナンスのコスト次第かなぁと。
現状でもそれなりに書けるようになってきましたが、やはりトライアンドエラーが必要だったり、特定するためのnameからidやらclassやらが無くて、CSSセレクタだとどうしようもないからXPathで泣きながらゴリゴリ書いたりと、色々と問題はあります。
ただ、Rubyで書いた時とは違って、他のメンバーでもメンテナンスできる(気がする)ようになったので、その辺は進歩したなぁと思いたいです。本当はCIとかで回したりしたいんですが、CIサーバーは常時動いていないのと、シングルサインオンが必要という前提条件、そして予算から、CIは諦めてます。
おわりに
WebdriverIO自体を利用する記事とかはあったんですが、バージョン4以降の話がなかったのと、PageObjectで複雑なページをやる時の話とかが中々なかったので書きました。他のライブラリだともっと楽だよ!とかあれば教えていただければ・・・。