WordPress
Composer
docker
behat

Behatでテスト実行時に自動的にスクリーンショットを撮る

はじめに

先日 @kurudrive さんがWordPressの公開テーマ、BillVektor用にBehatを使う記事が上げられていました。

WordPressの要件テストを自動化&スクリーンショットで保存出来るBDDテストがすごい!

丁寧にまとめられている上に公開されているプロジェクトなのですごく参考になりました。
僕も先日の案件で初めてBehatを使ってみて、そちらで取り入れた「テスト時に自動でスクリーンショットを撮る機能」便利だったのでそちらについてまとめてみます。

Behatを使った案件の概要

WordPressを使った管理システムの開発プロジェクトでした。
アサインされているプログラム開発メンバーは自分を含めて3人。
二ヶ月ほどの開発期間のあるプロジェクトでした。
kurudriveさんと同じく、ちょうど僕も宮内さんのPHPカンファレンスであったBehatの発表を聞いていたので、実際の案件で近々取り入れたいと思っていました。
Behat自体はWP-CLIで何度か読んでいて、「なるほどこれ流行りのがBDDか」と思っていました。

Behatを使ってWordPressの構成をテストする

ざっくりとした説明

BehatはBehavior Driven Development(振る舞い 駆動 開発)です。
テスト駆動開発の場合には多くがユニットテストで構成されています。
クラス化されたロジック周りはユニットテストが適していますが、それに対してBDDではクラス間をまたがった結合テストを行うことができます。
特にユーザの操作をベースにした振る舞いを記述する場合、E2Eテストと呼ばれているようです。

Dockerでの実行

今回の環境はDockerを利用しました。
ベースについてはこちらのDocker開発環境をベースにしました。

ローカル開発環境をもっとたくさんの人に使ってもらいたくてDockerで作りました

これのdocker-compose.yamlにSeleniumを追加しています。

Behatのコード

今回のテストコードはこちらです。
https://github.com/yousan/behat-example

docker-compose

Selenium用のdocker-compose.yamlファイルです。

version: '2'

services:

  php5.6-apache.dev:
    image: yousan/php5.6-apache
    ports:
      - 80:80
    volumes:
      # Add symlink directories if you use symlinks at documents.
      - ~/git:/Users/yousan/git
      - ~/public_html:/var/www/vhosts
    environment:
      PMA_HOST: mysql.dev
      PMA_USER: root
      PMA_PASSWORD: example

  mysql.dev:
    image: mariadb
    environment:
      MYSQL_ROOT_PASSWORD: example
    ports:
      - 3306:3306
    volumes:
      - /private/var/lib/mysql:/var/lib/mysql

  dnsmasq.dev:
    image: yousan/dnsmasq
    ports:
      - 53:53/tcp
      - 53:53/udp
    cap_add:
      - NET_ADMIN

  selenium.dev:
    image: selenium/standalone-chrome-debug:latest
    ports:
      - 4444:4444/tcp
      - 4445:5900/tcp
    external_links:
      - php5.6-apache.dev:behat-example.dev

このdocker-composeでは
1. Apache + mod_php
2. MySQL (MariaDB)
3. DNSサーバ
4. Seleniumホスト
が動きます。

このSeleniumのコンテナは内部でLinuxのX Window Systemが立ち上がり、その中でChromeを動かします。
BehatはこのSeleniumコンテナ上で動きます。
イメージのstandalone-chrome-debugはデバッグ用で、Xに対してVNCで接続することができます。

MacであればFinderの「サーバに接続(コマンド+k)」でVNC接続ができます。

Screen Shot 2017-08-15 at 16.32.35.png

Screen Shot 2017-08-16 at 15.16.39.png

このdocker-composeファイルでは *.dev のDNS解決も行っています。
注意点として、Macの場合には *.dev をlocalhostで解決できるようにしておきます。

http://qiita.com/ono_matope/items/cd3be40b5179731d4460

Behat

一番メインの箇所ですね。
BehatのbootstrapにFeatureContextを記述して、各種テストが通った時にスクリーンショットを撮るようにします。

tests/features/bootstrap/FeatureContext.php
<?php

use Behat\Behat\Context\Context;
use Behat\Behat\Hook\Scope\AfterStepScope;
use Behat\Gherkin\Node\StepNode;

/**
 * Defines application features from the specific context.
 */
#class FeatureContext extends Behatch\Context\BaseContext
class FeatureContext extends \Behat\MinkExtension\Context\RawMinkContext implements Context {
    private $screenshotDir;

    /**
     * Initializes context.
     *
     * Every scenario gets its own context instance.
     * You can also pass arbitrary arguments to the
     * context constructor through behat.yml.
     *
     * @param string $screenshotDir
     */
    public function __construct( $screenshotDir = '.' ) {
        $this->screenshotDir = $screenshotDir;
    }

    /**
     * Copy from vendor/behatch/contexts/src/Context/DebugContext.php:46
     * テスト実行時にスクリーンショットを撮影する
     * behatchのDebugContextはテストが失敗した時、かつ@javascriptタグがフィーチャーに付いている時のみ動作する。
     *   (Driverの実装によってはsession->driver->getScreenshot()が動作しない)
     * ファイル名を決定する際にシナリオ名をurl_encodeしているため、ファイル名が長くてfile_put_contentsに失敗する。
     *
     *
     * @AfterStep
     *
     * @param AfterStepScope $scope
     */
    public function saveScreenshotsAfterStep(AfterStepScope $scope)
    {
        #if (! $scope->getTestResult()->isPassed()) {
        #$makeScreenshot = false;
        $suiteName      = str_replace(' ', '_', $scope->getSuite()->getName());
        $featureName    = str_replace(' ', '_', $scope->getFeature()->getTitle());
        if ($this->getBackground($scope)) {
            #$makeScreenshot = $scope->getFeature()->hasTag('javascript');
            $scenarioName   = 'background';
            $stepLine       = 0;
        } else {
            $scenario       = $this->getScenario($scope);
            #$makeScreenshot = $scope->getFeature()->hasTag('javascript') || $scenario->hasTag('javascript');
            $scenarioName   = str_replace(' ', '_', $scenario->getTitle());
            $stepLine       = $scope->getStep()->getLine();
        }
        #if ($makeScreenshot) {
        $isPassed = $scope->getTestResult()->isPassed() ? "pass" : "fail";
        $filename = sprintf( '%s_%s_%s_%s_%s_%s.png', $isPassed, round( microtime( true ) * 1000 ), $suiteName, $featureName, $scenarioName, $stepLine );
        $this->saveScreenshot( $filename, $this->screenshotDir );
        #}
        #}
    }

    /**
     * StepからScenarioを直接辿れない構造になっているため
     * StepからFeatureを取得してScenario一覧を取得して
     * 一覧をforeachで回して実行中のStepの行番号に該当するScenarioを取得するメソッドっぽい。カオス。
     * @param AfterStepScope $scope
     * @return \Behat\Gherkin\Node\ScenarioInterface
     */
    private function getScenario(AfterStepScope $scope)
    {
        $scenarios = $scope->getFeature()->getScenarios();
        foreach ($scenarios as $scenario) {
            $stepLinesInScenario = array_map(
                function (StepNode $step) {
                    return $step->getLine();
                },
                $scenario->getSteps()
            );
            if (in_array($scope->getStep()->getLine(), $stepLinesInScenario)) {
                return $scenario;
            }
        }

        throw new \LogicException('Unable to find the scenario');
    }

    /**
     * @param AfterStepScope $scope
     * @return \Behat\Gherkin\Node\BackgroundNode
     */
    private function getBackground(AfterStepScope $scope)
    {
        $background = $scope->getFeature()->getBackground();
        if(!$background){
            return false;
        }
        $stepLinesInBackground = array_map(
            function (StepNode $step) {
                return $step->getLine();
            },
            $background->getSteps()
        );
        if (in_array($scope->getStep()->getLine(), $stepLinesInBackground)) {
            return $background;
        }

        return false;
    }
}

上記のコード中で定義していますが、スクリーンショットのファイル名は
「テスト結果 _ 時間 _ 機能名 _ シナリオ名」
となります。

こんな感じです。

Screen Shot 2017-08-18 at 11.57.19.png

スクリーンショットを撮るためにbehat.ymlに下記の設定を入れておきます。

behat.yml
     - FeatureContext:
        screenshotDir: %paths.base%/screenshot

コードはこちらにあります。

https://github.com/yousan/behat-example/blob/master/tests/behat.yml

Composer

テストはComposerで走らせています。 

composer.json
  "scripts": {
    "test": [
      "composer test-behat"
    ],
    "test-behat": [
      "./vendor/bin/behat --config ./tests/behat.yml"
    ],

ローカルにBehatをインストールしても良いですが、ここではComposerのバイナリを使っています。

全体のコードは下記にあります。
https://github.com/yousan/behat-example/blob/master/composer.json

Composerで動かすとこんな感じです。

$ composer behat-test 

Screen Shot 2017-08-18 at 12.04.18.png

感想

以上でBehatのテスト時に自動的にスクリーンショットを撮ることができます。
スクリーンショットを撮ることで、テストのパス、フェイルの判定もそうなのですが、FinderやExplorerなどのファイラーで簡単に結果を目視確認できることはすごく便利だと思いました。
特にWeb開発の実務よりだと、HTMLタグに不整合が発生してカラム落ちやズレが起きることが多く、それらをパパパッと確認できます。
その辺りのメリットついては別途まとめたいと思います。

また @miya0001 さんのボイラープレートではPhantom.JSで実装していたので、Seleniumではなくその方法でもやってみたいです。
WordPressに対してBDDなテストを行うためのボイラープレートをつくった

また今回のコード、特に核心のBehat周りについては @naname さんが書きました。というかBehatについてほぼ全てを教えてもらいました。ありがとうございます!

参考サイト

https://www.vektor-inc.co.jp/wordpress-tech/bbd-test/
http://www.atmarkit.co.jp/ait/articles/1403/05/news035_3.html
http://qiita.com/seiya1121/items/95dad2bbb489d3fa47ff