2017.06.28

メールや外部サービス連携を伴うテストの自動化


こんにちは。

今回はYii2ベースのCodeception/PhantomJSを利用して、メールや外部サービスとの連携を伴う機能のテスト自動化について紹介します。

数ヶ月前、関わっていた開発案件で作ったプログラムがリリースされ、現在は不具合の修正、改善要望、新機能の追加などの開発を行うフェーズになりました。改善や機能追加の開発スパンは短いため必然的にリリース作業も多くなっています。

当然リリース前に動作確認をするわけですが、メールや外部サービスと連携した機能を有するサービスの場合、手動でテストすると非常に時間がかかってしまいます。そこでメールや外部サービスとの連携を伴う機能のテスト自動化で動作確認にかかる時間の短縮を目指しました。

テスト自動化の際に利用したフレームワーク/APIの紹介とメール、外部サービスとの連携を伴う機能として以下の処理のテスト自動化を紹介します。

  1. 会員登録機能
    会員登録を行うとメールが送信され、そのメールに記載されているURLにアクセスすることで会員登録が完了する
  2. Yahoo! JAPAN ID / Facebook の認証機能を利用したログイン機能
    独自のアカウントでもログインできるが、Yahoo! JAPAN IDやFacebookのアカウントでもログインできる
目次
  • テストツールの概要
  • Yii2ベースのCodeceptionの構成
  • Gmail APIの使い方
  • 外部サービス固有の問題と解決策
  • Headless Chromeについて
  • 評価
  • まとめ

テストツールの概要

Codeception概要

  • 公式HP
  • 受け入れテスト(Acceptance Test)、機能テスト(Functional Test)、単体テスト(Unit Test)ができるテストフレームワーク
  • WebDriverによる、Javascriptの操作を含むテストが可能(PhantomJSを利用)

PhantomJS概要

  • 公式HP
  • CUIで動くブラウザ
  • WebKitベースのHeadlessブラウザ
  • レンダリングエンジンは「JavaScriptCore」を採用

CodeceptionでのPhantomJS

  • CodeceptionでJavaScriptが入っているページをテストするためにはPhantomJSが必須

Yii2ベースのCodeceptionの構成

Yii2のコンフィグ

プロジェクトではYii2 Advancedフレームワークをベースにして、frontend、backend、console、commonの標準モジュールに加えて独自にapi モジュールを追加した構成となっています。今回は、frontendとapiを対象としたテストケースを追加するため、プロジェクトのルートディレクトリでcodeception.ymlを以下のように設定します。

codeception.yml
# global codeception file to run tests from all apps
include:
    - api
    - frontend
paths:
    log: logs
settings:
    colors: true
各モジュールのディレクトリ下で以下のコマンドを実行してテストテンプレートを生成します(Codeception for Yii Framework参照)。
$ ../vendor/bin/codecept bootstrap
今回はURLへのリクエストに対するレスポンスをチェックするテストだけを用意するため、各モジュール用のcodeception.ymlとacceptance用のコンフィグ(acceptance.suite.yml)を用意して、テストケースをそれぞれfrontend/tests/acceptanceとapi/tests/acceptance以下に追加する構成にしています。

以降ではfrontend側のテストケースについての説明になるので詳細はfrontend側だけ記載します。

Yii2のHTTPクライアント用のライブラリを使うためにhttpclientを使えるように設定しています。
Yii2のコードレベルのテストをする場合はpartにorm等を追加するとコードレベルのテスト用のライブラリも利用できるようになります。

frontend/codeception.yml
namespace: frontend\tests
actor: AcceptanceTester
paths:
    tests: tests
    log: tests/_output
    data: tests/_data
    helpers: tests/_support
settings:
    bootstrap: _bootstrap.php
    colors: true
    memory_limit: 1024M
modules:
    enabled:
        - Yii2:
            part: [httpclient]
            configFile: 'config/test.php'
frontendではブラウザベースのテストを実施するので、WebDriverを使い、ステージング環境と本番環境でURLが異なるので環境で切り分けています。
モジュールは複数追加でき、例えばRESTモジュールを追加するとさらにHTTPベースのテストケースも追加できるようになります。

frontend/tests/acceptance.suite.yml
class_name: AcceptanceTester
modules:
    enabled:
        - WebDriver:
    config:
        WebDriver:
           browser: phantomjs
           host: '192.168.33.20'
           clear_cookies: true
           capabilities:
               phantomjs.page.settings.userAgent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36

env:
    staging:
        modules:
            config:
                WebDriver:
                    url: https://stg-gmo.example.com

    prod:
        modules:
            config:
                WebDriver:
                    url: https://gmo.example.com 

Yii2ライブラリの呼び出しのサンプルコード

Yii2モジュールを有効にすると、以下のようにYii2のライブラリをCest内で使えるようになります。

frontend/tests/acceptance/HttpClientSampleCest.php
<?php

namespace frontend\tests\acceptance;

use frontend\tests\AcceptanceTester;
use yii\httpclient\Client;

class HttpClientSampleCest
{
    public function checkHttpClientSample(AcceptanceTester $I)
    {
        $client = new Client();
        $url = 'http://gmo.example.com';
        $client->get($url, [])->send()->content;
    }
}

テスト環境の切り替え

Codeceptionの実行オプションでenvを指定すると、frontend/tests/acceptance.suite.ymlの指定したenvに応じたコンフィグが利用され、テスト環境を切り替えられます。
vendor/bin/codecept run --env staging

テストデータの分離

Codeceptionでは、各テストケースごとにデータプロバイダを指定することでテストロジックとデータを分離でき、テストケース間でテストデータを共有する仕組みとしても利用できます。しかし、さらにデータプロバイダが返す値を実行環境ごとに切り替えられるようにするのは標準の仕組みだけだとうまくいかなさそうでした。そのため、PHPの環境変数と組み合わせてデータプロバイダが返す値を実行環境ごとに切り替えられるようにしました。

環境ごとに切り分けたデータを持つファイルを用意して、テスト対象の環境が指定されている環境変数によって読み込むデータを切り替えるようにし、Codeceptionの実行時にテスト対象の環境を指定できるようにしています。

frontend/tests/acceptance/data/common.yml
staging:
    user:
        user_id: 1000
        password: stagingpass

prod:
    user:
        user_id: 2000
        password: prodpass
frontend/tests/acceptance/DataProviderExampleCest.php
<?php

namespace frontend\tests\acceptance;

use Codeception\Example;
use frontend\tests\AcceptanceTester;
use Symfony\Component\Yaml\Yaml;

class DataProviderExampleCest
{
    /**
     * @dataprovider _provider
     */
    public function checkDataProviderExample(AcceptanceTester $I, Example $example)
    {
        $example['user']['user_id'];
        $example['user']['password'];
    }

    public function _provider()
    {
        $common_data = Yaml::parse(file_get_contents(getcwd() . "/frontend/tests/acceptance/data/common.yml"));
        $env = getenv('env');

        return [$common_data[$env]];
    }
}
export env=staging;vendor/bin/codecept run --env $env

Gmail APIの使い方

利用の準備(認証情報設定)

  1. Google API Consoleからプロジェクトを作成
  2. 作成したプロジェクトを選択
  3. API Managerの認証情報ページからOAuth クライアント IDを作成
  4. Client IDとClient Secretを取得

利用の準備(Gmail APIの利用設定)

  1. API Managerのダッシュボードから「APIを有効にする」リンクをクリック
  2. ページから「Gmail API」リンクをクリック
  3. ページの上部にある「有効にする」ボタンをクリックして完了

認可設定について

  1. アプリケーションからユーザーの認可を取得するためのURLに情報をセットしGoogleへリダイレクトさせ認可画面のURLを取得
    https://accounts.google.com/o/oauth2/v2/auth?
     scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdrive.metadata.readonly&
     access_type=offline&
     include_granted_scopes=true&
     state=state_parameter_passthrough_value&
     redirect_uri=http%3A%2F%2Foauth2.example.com%2Fcallback&
     response_type=code&
     client_id=client_id
    
  2. そのCodeを利用してアクセストークンを取得
    リダイレクトさせて返ってきた「Code」の例
    https://oauth2.example.com/auth?code=4/P7q7W91a-oMsCeLvIaQm6bTrgtp7
    
  3. 取得したアクセストークンでGmail APIを操作

Scopeについて

  • メールのBodyを取得するためには下記のScopeの中で少なくても一つ以上を設定
  • 可能な限り最も制限のあるスコープを選択し、サービスが実際に必要としないスコープを要求しない
    Scope
    https://mail.google.com/
    https://www.googleapis.com/auth/gmail.modify
    https://www.googleapis.com/auth/gmail.readonly
    https://www.googleapis.com/auth/gmail.metadata

アクセストークンの再発行方法

API実行時に毎回必要で、30分で有効期限が切れるのでRefresh Tokenを利用してアクセストークンを取得します。
  1. テストケースを作成する際には最初に取得したRefresh Tokenを保存しておきます
  2. Refreash TokenをAccess Tokenに交換します
    POST /oauth2/v4/token HTTP/1.1
    Host: www.googleapis.com
    Content-Type: application/x-www-form-urlencoded
     
    refresh_token={保存していたRefresh Token}&
    client_id={取得したClientID}&
    client_secret={取得したClientSecret}&
    grant_type=refresh_token
    
  3. 新しいAccess Tokenが取得できます

メールの本文の取得方法

  1. まずメール一覧から本文を取得するメールのメッセージIDを確認します
    GET https://www.googleapis.com/gmail/v1/users/{取得したいユーザーのID}/messages/
    
    こちらを参照
  2. 確認したメールのメッセージIDを設定し本文を取得
    GET https://www.googleapis.com/gmail/v1/users/{取得したいユーザーのID}/messages/{メッセージID}
    
    こちらを参照

APIの制限について

  • 1,000,000,000回/日までRequest可能
  • 1ユーザーに対して1分以内に250回以上のRequestは不可

外部サービス固有の問題と解決策

外部サービスのAPIを使ったり、フォームを利用する際はその外部サービス側の仕様に則る必要があるので、Codeceptionでの書き方を工夫しなければならないことがあります。
そのようなテストケースについていくつかご紹介したいと思います。

メールと連携するテストケース

ユーザーの新規登録時にメールを送信して、そのメールに記載されているURLから本登録するようなサイトのテストは、Gmail APIを使って次のように書けます。

frontend\tests\RegistrationCest.php
<?php

namespace frontend\tests\acceptance;

use Codeception\Example;
use frontend\tests\AcceptanceTester;
use frontend\tests\helpers\GmailAPIHelper;

class RegistrationCest extends BaseRegistrationCest
{
    const WAIT_TIME_GMO_MAIL_SEND = 3;
    const REGEX_DETECT_REGISTRATION_URL = '#(https://w+)#';

    /**
     * @dataprovider _provider
     */
    public function checkRegistration(AcceptanceTester $I, Example $example)
    {
        $gmail = $example['gmail'];
        $member = $example['member'];

        $I->wantTo('新規登録する');
        $I->amOnPage('/');
        $I->click('新規登録');
 
        $I->fillField('email', $member['email']);
        $I->click('次へ');
        // メールが受信されるまで待つ
        $I->wait(self::WAIT_TIME_GMO_MAIL_SEND);
 
        $access_token = GmailAPIHelper::getAccessToken($gmail['client_id'], $gmail['client_secret'], $gmail['refresh_token']);
        $message_id = GmailAPIHelper::getLatestMessageId($access_token);
        $content = GmailAPIHelper::getMessageContent($message_id, $access_token);
        // メールの本文から登録URLを抽出する
        $registration_url = self::detectRegistrationUrl($content);
 
        $I->amOnPage($registration_url);
        $I->see('新規登録');
        $I->fillField('password', $member['password']);
        $I->fillField('password_confirm', $member['password']);
        $I->click('同意して登録する');
    }
 
    private static function detectRegistrationUrl($content)
    {
        preg_match(self::REGEX_DETECT_REGISTRATION_URL, $content, $m);
 
        return $m[1];
    }
}
Gmail APIを利用するためのアクセストークンを取得して、最新のメールのIDからその本文を取得しています。
有効なアクセストークンは、プログラム外でcurl等で個別に発行したアクセストークンとリフレッシュトークンを使って、アクセストークン発行APIから取得することができます。

frontend\tests\helpers\GmailAPIHelper.php
<?php

namespace frontend\tests\helpers;

use yii\httpclient\Client;

class GmailAPIHelper
{
    const HTTP_HEADER_OAUTH2_AUTH_ACCESS_TOKEN = 'Authorization';

    const GOOGLE_API_URL_OAUTH2 = 'https://accounts.google.com/o/oauth2';
    const GOOGLE_API_URL_OAUTH2_ISSUE_TOKEN = self::GOOGLE_API_URL_OAUTH2 . '/token';

    const GOOGLE_API_SCOPE_GMAIL_READONLY = 'https://www.googleapis.com/auth/gmail.readonly';

    const GOOGLE_API_OAUTH2_GRANT_TYPE_AUTHORIZATION_CODE = 'authorization_code';
    const GOOGLE_API_OAUTH2_GRANT_TYPE_REFRESH_TOKEN = 'refresh_token';

    const GMAIL_API_MESSAGES_URL = 'https://www.googleapis.com/gmail/v1/users/%s/messages/';
    const GMAIL_API_MESSAGE_URL = 'https://www.googleapis.com/gmail/v1/users/%s/messages/%s';
    const GMAIL_API_REDIRECT_URI = 'urn:ietf:wg:oauth:2.0:oob';

    const GMAIL_TARGET_USER = 'me';
    const GMAIL_MESSAGES_MAX_RESULTS = 1;

    public static function getAccessToken($client_id, $client_secret, $refresh_token)
    {
        $url = self::GOOGLE_API_URL_OAUTH2_ISSUE_TOKEN;
        $grant_type = self::GOOGLE_API_OAUTH2_GRANT_TYPE_REFRESH_TOKEN;
        $params = compact('client_id', 'client_secret', 'grant_type', 'refresh_token');
        $content = json_decode((new Client())->post($url, $params)->send()->content, true);
 
        return $content['access_token'];
    }
 
    public static function getLatestMessageId($access_token)
    {
        $url = sprintf(self::GMAIL_API_MESSAGES_URL, self::GMAIL_TARGET_USER);
        $maxResults = self::GMAIL_MESSAGES_MAX_RESULTS;
        $params = compact('maxResults');
        $headers = [self::HTTP_HEADER_OAUTH2_AUTH_ACCESS_TOKEN => "Bearer {$access_token}"];
 
        $content = json_decode((new Client())->get($url, $params, $headers)->send()->content, true);
 
        return $content['messages'][0]['id'];
    }
 
    public static function getMessageContent($message_id, $access_token)
    {
        $url = sprintf(self::GMAIL_API_MESSAGE_URL, self::GMAIL_TARGET_USER, $message_id);
        $params = [];
        $headers = [self::HTTP_HEADER_OAUTH2_AUTH_ACCESS_TOKEN => "Bearer {$access_token}"];
 
        $content = json_decode((new Client())->get($url, $params, $headers)->send()->content, true);
 
        return base64_decode($content['payload']['body']['data']);
    }
}

Yahoo! JAPAN IDの認証フォームのテストケース

Yahoo! JAPAN IDでの認証は、認証ページを開き、IDの入力フォームにポストして、パスワードの入力フォームをポストすればログインできます。
ところがこの手順だけをCodeceptionで実装すると、入力フォームに入力しようとした時点でPhantomJSで「Element is not currently interactable and may not be manipulated」という旨のエラーが発生してうまく動きませんでした。
表示直後では入力フォームオブジェクトの構築が完了していないために発生していると思われるので、フォームが利用できるようになるまで待つようにしています。これで認証操作が一通り通るようになりました。
しかし、この処置を追加した後に認証ページ側で「不正な操作が行われたため、最初からやり直してください。」というエラーが頻繁に起きるようになりました。原因は特定できていませんが、PhantomJSで使うユーザーエージェントにChromeのものを指定(acceptance.suite.yml)すると発生頻度を抑えられました。

frontend\tests\acceptance\social\yahoo\LoginCest.php
<?php

namespace frontend\tests\acceptance\social\yahoo;

use Codeception\Example;
use frontend\tests\AcceptanceTester;

class LoginCest
{
    const FIELD_ID_YAHOO_LOGIN_LOGIN = 'login';
    const FIELD_ID_YAHOO_LOGIN_PASSWORD = 'passwd';

    const CLASS_NAME_YAHOO_LOGIN_NEXT_BUTTON = 'btnNext';
    const CLASS_NAME_YAHOO_LOGIN_SUBMIT_BUTTON = 'btnSubmit';

    /**
      * @dataprovider _provider
      */
    public function checkLogin(AcceptanceTester $I, Example $example)
    {
        $member = $example['member'];

        $I->wantTo('Yahoo! JAPAN IDを使ってログインする');
        $I->amOnPage('/');
        $I->click('ログイン');
        $I->click('Yahoo! JAPAN IDでログイン');
        // 入力フォームが表示されるまで待つ
        $I->waitForElement('#' . self::CLASS_NAME_YAHOO_LOGIN_NEXT_BUTTON, 5);
        $I->fillField(self::FIELD_ID_YAHOO_LOGIN_LOGIN, $member['login']);
        $I->click('次へ');
        // 入力フォームが表示されるまで待つ
        $I->waitForElement('#' . self::CLASS_NAME_YAHOO_LOGIN_SUBMIT_BUTTON, 5);
        $I->fillField(self::FIELD_ID_YAHOO_LOGIN_PASSWORD, $member['password']);
        $I->click('ログイン');
        $I->see('現在のポイント');
    }
}

Facebookの再認証フォームのテストケース(フォームのサブミットとJSでの実行)

Facebookでの再認証は、Facebookにログインしている状態で再認証ページを開き、メールアドレスとパスワードの入力フォームをポストすれば再認証できます。
再認証前にログインする場合は、メールアドレスとパスワードのフィールドを指定して入力フォームに値を入れられましたが、再認証のフォームではうまく動きませんでした。
Codeceptionではフォームをサブミットする方式でも入力フォームをポストできるので、こちらを試してみたところうまく動きました。
フォームのXPathを指定してサブミットするため、Chromeのデベロッパーツールを使って、ログインフォームのHTMLからCopy > Copy XPathでXPathを取得しました。
今回はこれでうまくいきましたが、WebDriver::executeJSを使ってスクリプトでポストするのも良さそうです。
あと原因は不明ですが、テストケースを実行する環境によってログインフォームのラベルが「ログイン」になったり、「Log In」になったりする場合があったので、ログイン時は両方に適用できるようにtry-catchを使っています。

frontend\tests\acceptance\social\facebook\ReauthenticationCest.php
<?php

namespace frontend\tests\acceptance\facebook;

use Codeception\Example;
use frontend\tests\AcceptanceTester;

class ReauthenticationCest
{
    const FIELD_ID_FACEBOOK_LOGIN_EMAIL = 'email';
    const FIELD_ID_FACEBOOK_LOGIN_PASSWORD = 'pass';

    const URL_NEED_REAUTHENTICATION = '/need/reauthentication';

    const XPATH_FACEBOOK_REAUTHENTICATION_SUMIT_BUTTON = '//form[@id="u_0_1"]';

    /**
      * @dataprovider _provider
      */
    public function checkReauthentication(AcceptanceTester $I, Example $example)
    {
        $member = $example['member'];
        $host = $example['host'];

        $I->wantTo('Facebookアカウントで再認証する');
        $I->click('Facebookでログイン');
        $I->fillField(self::FIELD_ID_FACEBOOK_LOGIN_EMAIL, $member['email']);
        $I->fillField(self::FIELD_ID_FACEBOOK_LOGIN_PASSWORD, $member['password']);
        try {
            // ボタンのラベルが「Log In」のときはボタンが見つからないので例外が発生する
            $I->click('ログイン');
        } catch (\Exception $e) {
            $I->click('Log In');
        }
 
        $I->amOnUrl("https://{$host}");
        $I->amOnPage(self::URL_NEED_REAUTHENTICATION);
        $I->click('Facebookで再確認');
 
        // フォームをサブミットする
        $I->submitForm(self::XPATH_FACEBOOK_REAUTHENTICATION_SUMIT_BUTTON,
            [
                self::FIELD_ID_FACEBOOK_LOGIN_PASSWORD => $member['password'],
            ]
        );
        $I->see($member['email']);
    }
}

Headless Chromeについて

PhantomJSの代替としてHeadless Chrome

Headless ChromeがChrome59に搭載されることに起因して、Vitaly Slobodin氏がPhantomJSのMaintainerを引退するとのことです。PhantomJSのメンテナンスが止まると、PhantomJSからHeadless Chromeに変えないといけなくなる時期が来るのではないかと思います。

Headless Chromeとは

  • クロームをCUIで実行可能
  • ChromiumとBlink Rendering Engineが提供するすべてのモダンウェブプラットフォーム機能が利用可能

評価

導入の容易さ

Yii2ではCodeceptionを標準でサポートしていることもあり、簡単にYii2と親和性の高い形で導入することができました。Codeceptionではテストケースをユーザー操作に基づいた記述ができて、直感的で分かり易くいところも良いと思います。

手動テストからの解放

外部連携サービスのテストのように複雑なテストではテスト項目も多く、漏れが発生する可能性も高く、何度も繰り返すことでテスト担当者に掛かる心理的負担や工数も懸念されます。このような問題がテストの自動化で解決されるのはやはり嬉しいところです!

外部連携サービスのテストの難しさ

外部サービスと連携する際は、サイトの作りによってはclickメソッド等だけではうまく記述できないこともあり、submitやexecuteJSでの記述が必要になることもあり外部サイト連携部分のテストケースは思ったよりも大変でした。
そのような場合はそのままだと直感的な記述にはなりませんが、適切にラッパーメソッドを定義すれば直感的な記述もできるで一度動くものが用意できれば遜色なくテストケースが作れると思います。
また、今後外部サービスの仕様やデザインが変わるとテストが動かなくなる等の影響も考えられ、外部サービス特有の対処に掛かる工数も懸念となるところです。

テストケース拡充とメンテナンスの困難さ

テスト自動化の効果が高いところは対象が複雑なこともありCodeceptionでもテストケースを用意するのが大変なことが分かりました。スケジュールやリソースが限られる中、テスト自動化の工数を常に入れることは難しく、リリース後に維持し続けることも難しいと感じました。
テスト自動化は技術的な難しさもあるが工数がかかっても作成・維持し続ける必要があるものだと理解してもらい開発を進められるような体制にする難しさもあると感じます。

まとめ

テスト自動化は開発スケジュールにおまけで追加すれば対応できるレベルではなく、専門知識も必要だしテストエンジニアが必要だと思うぐらい深い内容だと感じました。
テストを全て自動化するのを目指すのではなく、何をもってサービスが正常に動いていると判断するのかを決めて、少なからずその動作確認は自動化しリリース前に必ず実施する等、効率化も図りながら活用していきたいです。

参考リンク

次世代システム研究室では、アプリケーション開発や設計を行うアーキテクトを募集しています。アプリケーション開発者の方、次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ 募集職種一覧 からご応募をお願いします。

皆さんのご応募をお待ちしています。