JSでもCucumber

国内・海外問わずあまり使用例の聞かない[cucumber-js](https://github.com/cucumber/cucumber-js)ですが、個人的によく使うので使い方を紹介したいと思います。

cucumber-jsを利用しているプロジェクトとしては以下があります:

* [aws-sdk-js](https://github.com/aws/aws-sdk-js)

他にcucumber.jsを利用しているプロジェクトをご存知の方いましたら教えてください。

## はじめに

cucumberについて詳細は[Wikipedia](http://en.wikipedia.org/wiki/Cucumber_(software))に譲りますが、簡単にいえば[Gherkin syntax](https://github.com/cucumber/cucumber/wiki/Gherkin)でfeatureを定義してこれに対応するステップを実装し受け入れテストを実行します。オリジナルはrubyですがそのJavaScriptバージョンがcucumber-jsです。

ここではcucumber-jsと[selenium-webdriver](https://www.npmjs.org/package/selenium-webdriver)を用いて実際にfeatureとstep、これをサポートするスクリプトファイルを記述して動かしていってみます。実コードは以下にあります。

https://bitbucket.org/p_baleine/cucumber-sample/src

## サンプルアプリ

サンプルなので単純に、`/`にアクセスすると「Hello, cucumber」を表示するアプリとします。以下アプリ(`lib/app.js`):

```js
ar express = require('express');
var multiline = require('multiline');
var app = express();

app.get('/', function(req, res) {
  res.send(multiline(function() {/*
<!doctype html>
<html>
  <head>
    <meta charset="utf-8">
  </head>
  <body>
    <p>Hello, cucumber</p>
  </body>
</html>
  */}));
});

module.exports = app;
```

## 最初のfeature

最初といってもこのドキュメント中では唯一のfeatureですが、`features`ディレクトリを作成して、以下内容を記載した`features/hello-world.feature`ファイルを作成します。

```feature
Feature: Hello, world
  cucumber.jsの利用者として
  cucumber.jsの使い方を知るために
  トップページにアクセスして出力されるHTMLを検証する

  Scenario: トップページへアクセス
    Given トップページへアクセスする
    Then 「Hello, cucumber」と表示される
```

## stepをサポートするスクリプト

stepを実装する前にstepをサポートするスクリプトを作成します。まずstepの実行環境となる`world`を定義するファイル`features/suppoer/world.js`を以下内容で作成します

```js
var url = require('url');
var sw = require('selenium-webdriver');
var SeleniumServer = require('selenium-webdriver/remote').SeleniumServer;
var server = new SeleniumServer('~/bin/selenium-server-standalone-2.40.0.jar', { port: 4444 });
var chai = require('chai');
var chaiWebdriver = require('chai-webdriver');
var PORT = 3001;

function World(callback) {
  this.app = require('../../lib/app');
  this.server = null;
  callback();
}

// サーバ起動
World.prototype.bootServer = function(callback) {
  this.server = this.app.listen(PORT, callback);
};

// サーバシャットダウン
World.prototype.shutdownServer = function(callback) {
  this.server.close(callback);
};

// webdriver起動
World.prototype.startDriver = function() {
  server.start();
  this.driver = new sw.Builder()
    .usingServer(server.address())
    .withCapabilities(sw.Capabilities.chrome())
    .build();
  chai.use(chaiWebdriver(this.driver));
};

// webdriver終了
World.prototype.quitDriver = function() {
  this.driver.quit();
};

// `path`にブラウザでアクセスする
World.prototype.visit = function(path, callback) {
  this.driver.get(this.url(path));
  callback();
};

// urlを返却する
World.prototype.url = function(path) {
  return url.format({
    protocol:'http',
    hostname: 'localhost',
    port: PORT,
    pathname: path
  });
};

exports.World = World;
```

長い…アプリサーバの起動、終了と検証用ブラウザ(selenium-webdriver)の起動、終了用のメソッドを実装しておきます。今回は検証用のブラウザをselenium-webdriverで準備します、ブラウザの選択については最後の方に少し記述します。

scenarioの前や後に実行したいコードは`features/support/hooks.js`に記述すると実行されるようになります、ここで`features/support/world.js`で定義したメソッドをコールします。

```js
module.exports = function() {
  this.Before(function(callback) {
    this.startDriver();
    this.bootServer(callback);
  });

  this.After(function(callback) {
    this.quitDriver();
    this.shutdownServer(callback);
  });
};
```

## stepファイル

ここまでで一度cucumber.jsを実行してみます:

```bash
# selenium-serverを起動しておく
# $ java -jar ~/bin/selenium-server-standalone-2.40.0.jar
$ ./node_modules/.bin/cucumber.js 
UU

1 scenario (1 undefined)
2 steps (2 undefined)

You can implement step definitions for undefined steps with these snippets:

this.Given(/^トップページへアクセスする$/, function (callback) {
  // express the regexp above with the code you wish you had
  callback.pending();
});

this.Then(/^「Hello, cucumber」と表示される$/, function (callback) {
  // express the regexp above with the code you wish you had
  callback.pending();
});
```

まだstepが一つも実装されていないので、実装すべきstepが出力されます、`features/step_definitions/hello-world.step.js`に出力された内容をコピー＆ペーストします。

ここでもう一度cucumber.jsを実行すると各ステップの状態が未実装からペンディングに変わります。ここから各ステップを実装していきます。↓は実装後のコード:

```js
var World = require('../support/world').World;
var expect = require('chai').expect;

module.exports = function() {
  this.World = World;

  this.Given(/^トップページへアクセスする$/, function (callback) {
    this.visit('/', callback);
  });

  this.Then(/^「Hello, cucumber」と表示される$/, function (callback) {
    expect('p').dom.to.contain.text('Hello, cucumber')
      .then(function() {
        callback();
      });
  });
};
```

ステップの実装の内容は利用するフレームワークに依存するものになります。今回はselenium-webdriverと[chai-webriver](https://github.com/goodeggs/chai-webdriver)を利用した例となります。ここまで記述して再度cucumber-jsを実行すると各ステップがパスするはずです。

## タグ

cucumberに記述する内容はいわゆる受け入れテスト系のテストなので、各シナリオを実行するのに時間がかかるかと思います。タグを利用することで特定のシナリオだけ実行することができるようになります。具体的には以下のように

```feature
@dev
Scenario: トップページへアクセス
  Given トップページへアクセスする
  Then 「Hello, cucumber」と表示される
```

シナリオの上に`@dev`等のように記述してcucumber実行時に`-t`オプションでこれを指定することでタグの付いているシナリオだけ実行されます:

```bash
$ ./node_modules/.bin/cucumber.js -t @dev
```

前述のhookは特定のタグのときにだけ実行されるように指定することもできます:

```js
this.Before('@someCondition', function(callback) {
  this.loadFixtures(callback);
});
```

## cucumber-jsで使うブラウザ

今回は[selenium-webdriver](https://www.npmjs.org/package/selenium-webdriver)を利用しましたが以前は[zombie](https://github.com/assaf/zombie)をよく使っていました。cucumber-jsの公式ページでもzombieを利用した例が記述されています。どれも一長一短かと思いますが、個人的な感想：

### zombie

Nodeで実装されたテスト用のブラウザ(みたいなもの)です、よくメンテナンスされていて、何よりもcucumber-jsと同一プロセス上でnodeのプログラムとして動くのでブラウザのhttpリクエストをモックできます、なのでFacebookやTwitterのOAuth認証なんかもモックしてテストすることができます。以下は以前書いていたFacebook認証のモック用のコードです、これをFacebook認証を必要とするシナリオのフックで実行します

```js
// Facebookのリクエストをmock
World.prototype.mockFacebookRequest = function() {
  var code = 'mycode';
  var accessToken = 'mytoken';

  nock('https://www.facebook.com')
    // ログインダイアログ
    .get('/dialog/oauth?' + querystring.stringify({
      response_type: 'code',
      scope: 'email',
      redirect_uri: config.facebook.redirectUri,
      client_id: config.facebook.clientId
    }))
    // コールバックにリダイレクト
    .reply(303, '', {
      'Location': this.url('/sessions/facebook-callback') + '?code=' + code
    })
    .reply(200, '');

  nock('https://graph.facebook.com')
    // アクセストークンリクエスト
    .get('/oauth/access_token?' + querystring.stringify({
      code: code,
      client_id: config.facebook.clientId,
      client_secret: config.facebook.clientSecret,
      redirect_uri: config.facebook.redirectUri
    }))
    .reply(200, {
      access_token: accessToken,
      expires: new Date().getTime()
    })
    // API呼び出し
    .get('/me?' + querystring.stringify({ access_token: accessToken, locale: 'ja_JP' }))
    .reply(200, {
      id: this.REGISTERED_USER_DATA.facebook_id
    });
};
```

ここでモックには[nock](https://github.com/pgte/nock)を用いています。

ただときどきクライアントサイドのJSが動かなかったりして、まぁそこからzoombieのissueとか漁りに行ったりもするんですが、いかんせんデバッグ対象がリアルなブラウザではなくNode上で動くほぼユーザのいないブラウザってところが結構萎えるのが欠点かと思います、まだIEデバッグしてるほうがイイ気もする。

### selenium-webdriver

なので最近はこちらを使ってみています。[chai-webdriver](https://github.com/goodeggs/chai-webdriver)を一緒に使えばかなり素直にexpectationも書けるし、<del>[windowリサイズしないとvisibleなはずのものが見えない](https://github.com/ariya/phantomjs/issues/11637)とかみたいな変なバグ踏まなければ(今朝これで2時間くらい悩んだ、そんなんわからないって)</del>(←はPhantomJSの問題です、webdriverは悪くありませんでした…ぼけてるなぁ)、実ブラウザで動いてくれるのはやはり快適かと思います。


## 最後に

自分はcucumberでまず実装する予定の機能を検証して(最初は実装がないのでテストが落ちて)から、サーバサイドであればmocha、クライアントサイドであれば[karma](http://karma-runner.github.io/0.12/index.html)(+ mocha)でユニットテストを書いたりしています。まぁここら辺は個人とかプロジェクトの趣味みたいなものかと思いますが、cucumber側でユーザが実際に触る観点でテストを記述できると、じゃぁユニットテスト側ではMとかCのインタラクションをテストすれば良い、という風に切り分けの見通しが良くなって良いかなと思います。