Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

YappliにおけるPHPのテストと現状の課題

サーバーサイドエンジニアの田実です!

Yappliでは一部のネイティブAPIのアプリケーションや社内用管理画面、マイクロサービスなどをPHPを使って構築しています。 今回はYappliにおけるPHPのテストと現状の課題について紹介したいと思います。

詳細はこちらのスライドでも紹介しているので、もし興味がありましたらご覧ください! speakerdeck.com

テスト

Yappliのアプリケーションは主に独自フレームワークを使ったサービスと、Laravelを使ったサービスの二種類になります。 いずれもPHPUnitを使ってテストしており、独自フレームワークの方では主に 単体テストE2Eテスト 、 Laravelを使ったサービスは 単体テスト結合テスト (HTTPテスト)を行い、品質を担保しています。

独自フレームワークのサービスの方はクラスベースではなくincludeベースで処理をしていくようなアーキテクチャになっています。 そのため、オブジェクトベースでのテストが難しいため、実際にサーバーを立ててAPIやHTMLのレスポンスを検証するE2Eテストを採用しています。

E2EテストではDockerを使ってPHPのアプリケーションを立ち上げ、それに対して Guzzle を使ってHTTPリクエストを送信しています。 実際のコードの例はこんな感じです。

<?php

final class HogeTest extends Base
{
    public function test_GET()
    {
        $client = new GuzzleHttp\Client();
        $res = $client->get('http://localhost/api/hoge', []);

        $actual = json_decode($res->getBody()->getContents(), true);
        $this->assertSame([
            "result" => true,
        ], $actual);
    }
}

E2Eテストは原理上どの言語・フレームワークを使っても良いのですが、コンテキストスイッチの発生を防ぐため、テスト対象と言語を合わせた PHPUnit からテストを行うようにしています。 setUp() の処理ではデータ作成も行っています。リモートホストでアプリケーションが動くことになるので、トランザクションを貼って最後にデータをロールバックするような方法は利用できず、実際にデータをコミットし、 tearDown() でデータを削除する必要があります。

また、E2Eテストで時間や外部HTTPリクエストのモックをする場合は、auto_prepend_file を使ってテスト用の処理を割り込ませてモックをしています。 以下は PHP-VCR を使ってモックするスクリプトの例になります。

<?php

class MockHandler
{
    public function run($cassetteName)
    {
        \VCR\VCR::turnOn();
        \VCR\VCR::insertCassette($cassetteName);
    }

    public function __destruct()
    {
        \VCR\VCR::eject();        
        \VCR\VCR::turnOff();
    }
}

$cassetteName = $_SERVER['HTTP_X_VCR_CASSETTE'];
$_mockHandler = new MockHandler();
$_mockHandler->run($cassetteName);

上記のスクリプトをauto_prepend_fileに指定することで、テスト時のHTTPリクエストで X-VCR-CASSETTE ヘッダが指定されているときにVCRでHTTPリクエストのモックを行い、本体のPHPのスクリプト処理が完了したときにデストラクタが呼ばれて後処理を行います。テストクラスで言うところの setUp() メソッドがrun()メソッド、tearDown() メソッドがデストラクタに相当する処理になっています。

さらに、時間系のテストを行う場合は現在時刻をインジェクト・モックして行うことが多いと思いますが、 time() 関数や date() 関数を直接使っている箇所が多かったため、 uopz を使った処理の差し替えも行っています。 例えば、 time() 関数の戻り値を固定にしたい場合は以下のようなコードで実現できます。

<?php

uopz_set_return('time', function () use ($fixedTime) {
    return $fixedTime;
}, true);

uopzはデフォルトでexitのオペコードをスルーしてしまうようなので、auto_prepend_fileのスクリプト内でexitの有効化も行っています。

uopz_allow_exit(true);

カバレッジの計測

テストがカバーしている範囲の確認やテストに対するモチベーションを上げていくため、カバレッジ計測もしています。

クラスベースの単体テストですと同一プロセス内で処理を実行するためカバレッジ計測が簡単なのですが、E2Eテストの場合はリモートのプロセスに対してカバレッジ計測を行う必要があります。リモートホストのスクリプト実行に対してxdebugの関数を呼び出してカバレッジ集計すれば良いので、こちらもauto_prepend_fileを使って実現しています。

具体的にはauto_prepend_fileのファイル呼び出し時に xdebug_start_code_coverage() を呼び出し、そこで定義したクラスのデストラクタで xdebug_stop_code_coverage() xdebug_get_code_coverage() を呼び出すことで各リクエストに対するカバレッジを取得しています。

<?php

class MockHandler
{
    public function run()
    {
        xdebug_start_code_coverage(3);
    }

    public function __destruct()
    {
        xdebug_stop_code_coverage();        
        $data = xdebug_get_code_coverage();
        file_put_contents('{適当なファイル名}', json_encode($data));
    }
}

$_mockHandler = new MockHandler();
$_mockHandler->run();

ちなみに、上記の方法でxdebugではなく pcov を使った集計も試しましたが、カバレッジ集計そのものよりもE2Eテスト自体のコストの方が支配的だったため、速度の改善は見込めませんでした。

リクエストごとにカバレッジファイルが生成されるのと、クラスベースでの単体テストに関しては別のカバレッジファイルが生成されるため、最終的にこれらをまとめて1つのファイルにする必要があります。 この集約処理は sebastianbergmann/php-code-coverage を使って実装できます。

<?php

use SebastianBergmann\CodeCoverage\Driver\Selector;
use SebastianBergmann\CodeCoverage\Filter;
use SebastianBergmann\CodeCoverage\RawCodeCoverageData;
use SebastianBergmann\CodeCoverage\Report\Clover;

require_once 'vendor/autoload.php';

// E2Eテストのカバレッジファイル
$coverages = glob('./coverage/coverage-*.json');

$filter = new Filter();
$filter->includeDirectory("/path/to/project");

$finalCoverage = new SebastianBergmann\CodeCoverage\CodeCoverage(
    (new Selector)->forLineCoverage($filter),
    $filter
);

foreach ($coverages as $coverageFile) {
    $rawData = json_decode(file_get_contents($coverageFile), true);
    $coverageData = RawCodeCoverageData::fromXdebugWithoutPathCoverage($rawData);
    $testName = str_ireplace(basename($coverageFile, ".json"), "coverage-", "");
    $finalCoverage->append($coverageData, $testName);
}

// 単体テストのカバレッジファイル
$finalCoverage->merge(include './coverage/phpunit.php');

// 集約処理
$report = new Clover();
$report->process($finalCoverage, 'reports/clover.xml');

Yappliではこうして集約されたカバレッジファイルを coveralls に送って可視化しています。

静的解析

YappliのPHPアプリケーションでは、型の恩恵を受けたり最低限のチェックができるように PHPStan を導入しています。

Laravelのアプリケーションはさらに larastan を使っていたり、 env() ではなく config() を使うように spaze/phpstan-disallowed-calls を使って制御しています。

独自フレームワークのアプリケーションは、include元で定義した変数をinclude先で使っていたりするのですが、include先のスクリプトをPHPStanでチェックすると未定義変数として扱われてしまう課題がありました。 未定義変数を正確に把握するにはメソッド・関数化するなどの対策が必要なのですが一旦妥協して未定義変数のチェックは行わず特定の変数名の変数だけ ignoreErrors を使って回避しています。

PHPStanを導入した効果ですが、namespaceの付け忘れによるundefined class問題やリファクタリング時のタイポ防止、アップグレード時の不具合防止等、型レベル・構文レベルでの細かいミスは大幅に減ったように思います。

また、 PHP_CodeSniffer によるフォーマットチェックをCIで行っていたり、同じコードの検出をするため PHPCPD も活用しています。導入前はインデントや空白の数、中括弧の位置がバラバラだったのですが PHP-CS-Fixer などを駆使して一括で整形したりしました。

現状の課題

テストや静的解析などを有効に活用しているものの、まだまだ課題はたくさんあります。

例えばカバレッジは、Laravelのアプリケーションは80%と良い感じなのですが、独自フレームワークの方は65%と少し物足りない感じになっています。 また、カバレッジを通すだけのコードもあるため、有効なテストを増やしていく必要があります。

E2Eテスト自体が遅いことも課題です。現状テストにかかっている時間は5〜6分程度なのですがテストケースを増やしたり、フロントエンドの処理も含めたE2Eテストの推進も行っている状況なので今後はもっと増えることが予想されます。高速化・フィードバックの速さを考慮すると、独自フレームワークにおいても、E2EテストではないLaravelのHTTPテストのようなオブジェクトベースでの結合テストが必要だと考えています。

その意味ではテストをしやすくなるようなリファクタリングも行っていく必要もあります。現状ではE2Eテストでなんとかテストしているケースが多く、単体テストが少なめな状況です。 テストピラミッドあるいはTesting Trophy*1のようなバランスにできるようなリファクタリングをすることで、結果として品質を上げていけると良さそうです。

リファクタリングの文脈で、PHPMD を使ったコードメトリクスの解析や PHPCPD によるコピペの抽出など、静的解析を導入することによってさらに改善が加速する可能性もあります。

まとめ

YappliにおけるPHPのテストと現状の課題について紹介しました。

実は3年前は独自フレームワークの方はテストが無かったりしたのですが、その時点と比べるとこの数年で大幅に内部品質が改善されています。 また、今回はPHPにフォーカスした話でしたが、PHP以外にもこのような改善が日々行われています。

今回の記事でヤプリに興味を持たれた方がいましたら、ぜひカジュアル面談しましょう! open.talentio.com

*1:フロントエンドの文脈で説明されることが多いと思いますが、サーバーサイドにもアリな考え方だと個人的には思っています。