PHPUnitでPowerCMS Xのプラグインのテストを書く

公開

PowerCMS Xのプラグインを書いた際にPHPUnitでテストを行うことができたら良いのでは?と最近考えていました。機能が設計通り動作しているかを確認できますし、PowerCMS X本体がバージョンアップした時の動作確認も容易になると考えます。そこで、テストを書くことは可能なのか調べてみました。題材は引き続きAlgolia + PowerCMSシリーズです。

事前準備

プラグインのディレクトリでcomposer require --dev phpunit/phpunitを実行します。題材となるAlgoliaSupportプラグインでは、既にAlgolia Search API Clientをcomposerでインストールしていましたが、まだcomposerを使用していないプロジェクトでは事前にcomposer initを実行してください。PHP Archive (PHAR)を使う方法等もあるようですので詳細は「1. PHPUnit のインストール — PHPUnit latest Manual」を参照してください。

テストの準備・PowerCMS Xのインスタンス生成

簡単そうなことからテストを書いて、テストが実現できるかを探りました。PHPUnitを使用したテストの書き方は「2. PHPUnit 用のテストの書き方 — PHPUnit latest Manual」に解説がありますので、それに倣って記述します。

ここでまず気になることは、どのようにすればPowerCMS Xをコマンドラインで動かすことができるのか?、ではないでしょうか。これはtoolsの中のスクリプトが参考になると思いますし、「PHPによるプログラミング・ガイド (クラス Prototype編) | PowerCMS X」にも解説があります。テストの前にPowerCMS Xのインスタンスを生成するためにsetUp()を記述します。setup()の解説は「4. フィクスチャ — PHPUnit latest Manual」にあります。

余談になりますが、AlgoliaSupportではまだ.envに設定を書く仕様が残っているので、.envも使用できるようにします。

protected function setUp(): void {
    $app = new Prototype();
    $app->init_tags = true;
    $app->plugin_paths = [ '/var/www/powercmsx/customized_files/plugins' ];
    $app->use_plugin = true;
    $app->init();
    $this->app = $app;

    $dotenv = Dotenv\Dotenv::createImmutable( __DIR__ . '/../' );
    $dotenv->load();
}

init_tagstrueにするとダイナミック・タグの初期化が行われます。またuse_plugintrueにするとプラグインの初期化が行われます。私は自作プラグインを独自のディレクトリに入れているので、plugin_pathsでその場所を示します。

ここまでの内容でPowerCMS Xの実態であるPrototypeインスタンスが生成できました。なお、new Prototype()の時に何も指定していないので、$app->idPrototypeになります。このidはWorkerを実行したときはWorkerpt-view.php経由で動的にページを表示した場合はBootstrapperにする必要があるので注意しましょう。

モデルのオブジェクトを取得して値を確認する

Prototypeのインスタンスが準備できたならモデルのオブジェクトを取得するのは容易なはずなので、AlgoliaSupportプラグインには全く関係無いのですがschool(学校)モデルのオブジェクトの取得を試行し、意図したオブジェクトが取得できたかを確認します。以下のコードはid1のオブジェクトの学校名が盈進中学高等学校であることを確認します。

public function test_オブジェクトの取得() {
    $db = $this->app->db;
    $object = $db->model( 'school' )->load( 1 );
    $this->assertSame( '盈進中学高等学校', $object->name );
}

php ./vendor/bin/phpunit test/AlgoliaSupportTest.phpを実行してテスト結果を確認します。このテストは無事成功しました。値を確認するアサーションメソッドの一覧は「1. アサーション — PHPUnit latest Manual」にあります。

テンプレートタグの出力を確認する

テンプレートタグのテストが書けるのか?はみなさんがきっと気になることだと思います。私もとても興味がありました。

以下のコードはMTAlgoliaSupportAppIDという独自のファンクションタグが正しい値を出力するかをテストします。テンプレート文字列をビルドするには、「PHPによるプログラミング・ガイド (クラス Prototype編) | PowerCMS X」にあるように$app->build( $tmpl );とします。$tmplはテンプレートの内容です。簡単ですね。

public function test_AppIDの取得() {
    $app = $this->app;
    $tmpl = '<mt:algoliasupportappid />';
    $out = $app->build( $tmpl );
    $this->assertSame( $_ENV['APP_ID'], $out );
}

もちろん<mt:schools id="10" workspace_id="0"><mt:schoolname /></mt:schools>のようなブロックタグとファンクションタグを組み合わせたテンプレート文字列でも大丈夫です。テンプレートタグに関するテストも無事成功しました。

post_saveコールバックが正しく動作するかを確認する

さらにハードルが上がります。AlgoliaSupportプラグインではschool(学校)モデルを保存した時(post_save)、AlgoliaのAPIと通信をして検索データを保存し、返値でobjectIDを受け取ります。そして、そのobjectIDをモデルに保存する処理を記述しています。プラグインで実装したpost_saveコールバックが正しく実行されるかを確かめます。

コツはオブジェクトを新規作成・値をセットした後、保存した時に$app->run_callbacks()を実行することでした。管理画面を通して操作していると何もしなくてもpost_saveコールバックが実行されますが、コマンドからだと少し違うようです。$objectはPADOMySQLのインスタンスであることが関係していると想像しています。

public function test_記事保存時にAlgoliaのobjectIDの取得して保存する() {
    $app = $this->app;
    $db = $app->db;
    $model = 'school';
    $app->get_scheme_from_db( $model );
    $app->init_callbacks( $model, 'post_save' );
    $object = $db->model( $model )->new([
        'name'     => 'テスト高校',
        'status'   => $app->status_published( $model ),
        'rev_type' => 0,
    ]);
    $object->save();
    $callback = [ 'name' => 'post_save' ];
    $app->run_callbacks( $callback, $model, $object );
    $this->assertIsNumeric( $object->algolia_object_id );
}

上記の内容でAlgoliaのobjectIDはモデルに保存されており、post_saveコールバックに関するテストも無事成功しました。テストに使用したオブジェクトを消すコードを入れても良いかもしれません。

CSVのインポート

まだこれはテスト可能か調査中です。コマンドラインからPOSTして実行できるのか、とても興味があります。また改めて検証・レポートします。

※1/28追記: テスト対象はpost_importが呼ばれた時の動作なので、CSVをアップロードする必要はなくいくつか適当にオブジェクトを作りながらpost_importを呼べば良いだけかもしれません。そうであれば、post_saveコールバックと同じく$app->run_callbacks()を実行するだけですね。

まとめ

PowerCMS XのプラグインもPHPUnitでテストが可能なことが分かりました。ここまでご紹介した内容で全ての機能がテスト可能かと言えば、まだ難しいかもしれません。しかし、テストを書きやすい部分からテストを書いて品質の維持向上に役立てていきたいと考えています。
画面キャプチャ:コマンドラインでPHPUnitを実行した画面

テストの全貌

最後にテストファイルの全貌を掲載します。プライベートメソッドのテストについては「privateとprotectedメソッドをPHPUnitでテストする方法 - Qiita」を参考にさせて頂きました。情報ありがとうございます。

<?php
use PHPUnit\Framework\TestCase;
require_once 'vendor/autoload.php';
require_once '/var/www/powercmsx/class.Prototype.php' ;

class AlgoliaSupportTest extends TestCase {
    protected $app;

    protected function setUp(): void {
        $app = new Prototype();
        $app->init_tags = true;
        $app->plugin_paths = [ '/var/www/powercmsx/customized_files/plugins' ];
        $app->use_plugin = true;
        $app->init();
        $this->app = $app;

        $dotenv = Dotenv\Dotenv::createImmutable( __DIR__ . '/../' );
        $dotenv->load();
    }

    /**
     * privateメソッドを実行する.
     * @param string $method_name privateメソッドの名前
     * @param array $params privateメソッドに渡す引数
     * @return mixed 実行結果
     * @throws \ReflectionException 引数のクラスがない場合に発生.
     *
     * 参考: https://qiita.com/ponsuke0531/items/6dc6fc34fff1e9b37901
     */
    private function do_method( string $method_name, array $params = [] ) {
        $algolia_support = new AlgoliaSupport();
        $reflection = new \ReflectionClass( $algolia_support );
        $method = $reflection->getMethod( $method_name );
        $method->setAccessible( true );
        return $method->invokeArgs( $algolia_support, $params );
    }

    public function test_オブジェクトの取得() {
        $db = $this->app->db;
        $object = $db->model( 'school' )->load( 1 );
        $this->assertSame( '盈進中学高等学校', $object->name );
    }

    public function test_インデックスへの接続() {
        $db = $this->app->db;
        $object = $db->model( 'school' )->load( 1 );
        $algolia_object_id = $object->algolia_object_id;

        $index = $this->do_method( 'init_algolia_client', [ $_ENV['INDEX_NAME'] ] );
        $result = $index->getObject( $algolia_object_id );
        $this->assertSame( '福山市千田町千田487-4', $result['address'] );
    }

    public function test_AppIDの取得() {
        $app = $this->app;
        $tmpl = '<mt:algoliasupportappid />';
        $out = $app->build( $tmpl );
        $this->assertSame( $_ENV['APP_ID'], $out );
    }

    public function test_記事保存時にAlgoliのaobjectIDの取得して保存する() {
        $app = $this->app;
        $db = $app->db;
        $model = 'school';
        $app->get_scheme_from_db( $model );
        $app->init_callbacks( $model, 'post_save' );
        $object = $db->model( $model )->new([
            'name'     => 'テスト高校',
            'status'   => $app->status_published( $model ),
            'rev_type' => 0,
        ]);
        $object->save();
        $callback = [ 'name' => 'post_save' ];
        $app->run_callbacks( $callback, $model, $object );
        $this->assertIsNumeric( $object->algolia_object_id );
    }

}