PowerCMS Xのテンプレートのテストについて検討してみた

公開

例えば「アセットの登録がある場合はその画像を、ない場合は別に準備したデフォルト画像を表示する」という設計・実装にした後、「前見た時はちゃんと画像が出ていたのに今は出ていない」みたいなことがなぜだか起こります。このような事態に備え、2年前に「ウェブサイトのフロントエンドのテスト | PowerCMS X R&D Website」にまとめたようにテストをしたいと考えるのですが、MTMLでテンプレート(ビュー)を書いている場合にテストが書けるでしょうか? 試してみました。

テスト対象のテンプレート

MTEntriesで記事リストを表示するテンプレートです。画像の有無・概要の有無で表示が変わります。

<mt:if name="__first__"><ul></mt:if>
  <li class="p-entryList__item">
    <a href="<mt:entrypermalink escape />" class="p-entryList__link">
      <div class="p-entryList__image"><mt:entryassets limit="1"><img src="<mt:assetfileurl escape />" alt=""><mt:else><img src="/assets/images/pic_default_01.webp" alt=""></mt:entryassets></div>
      <div class="p-entryList__title"><mt:entrytitle escape /></div>
      <mt:if tag="entryexcerpt"><p class="p-entryList__excerpt"><mt:entryexcerpt escape /></p></mt:if>
    </a>
  </li>
<mt:if name="__last__"></ul></mt:if>

テストの方法

PowerCMS XのテンプレートはPHPにコンパイルされ、それを実行することで値が出力されるので、PHPでテストを書く必要がありそうです。そこで「PHPUnit」を利用することにしました。生成されたHTMLをパースするために「DomCrawler」も利用します。

テストの冒頭は以下の通りです。記事リストテンプレートモジュールをロードしています。

<?php
use PHPUnit\Framework\TestCase;
use Symfony\Component\DomCrawler\Crawler;

require_once 'vendor/autoload.php';
require_once '/Users/Shared/Sites/powercmsx/app/class.Prototype.php' ;

class EntryListTest extends TestCase
{
    protected $app;
    protected $tmplObj;

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

        $tmplObj = $app->db->model('template')->get_by_key([
            'basename' => 'entry_list',
            'workspace_id' => 12,
        ]);
        if ($tmplObj->id) {
            $this->tmplObj = $tmplObj;
        } else {
            throw new Exception('テンプレートの読み込みに失敗しました。', 1);
        }
    }

mt_entryテーブルのカラムに値が保存されている場合

記事タイトル

mt_entryテーブルのカラムに値が保存されている場合、つまりタイトルや概要などカラムのタイプがリレーション・バイナリ以外のほとんどのものです。言い換えれば$obj->column_nameで値が取得できるものです。

MTEntryTitle$ctx->stash('entry');(実際にはcurrent_context)からオブジェクトを取り出して値を取得しているので、テスト用のオブジェクトを作成した後stashに入れテンプレートをビルドします。

public function test_タイトルが正しいこと()
{
    $app = $this->app;
    $entry = $app->db->model('entry')->new();
    $entry->title = 'テスト記事です';
    $app->ctx->stash('entry', $entry);

    $html = $app->build($this->tmplObj->text);
    $crawler = new Crawler($html);
    $titleText = $crawler->filter('.p-entryList__title')->text();
    $this->assertSame('テスト記事です', $titleText);
}

これで記事タイトルが正しく出力されることが確認できました。

分岐を伴う記事概要

記事概要(excerptカラム)は入力がある場合のみ表示するようにMTIfタグが入っていますが、テストするには特別な処理は必要はなく、$entry->excerptを空にしたり値をセットしたりするだけです。

public function test_概要欄が出力されないこと()
{
    $app = $this->app;
    $entry = $app->db->model('entry')->new();
    $entry->excerpt = '';
    $app->ctx->stash('entry', $entry);

    $html = $app->build($this->tmplObj->text);
    $crawler = new Crawler($html);
    $existExcerpt = $crawler->filter('.p-entryList__excerpt')->count() > 0;
    $this->assertSame(false, $existExcerpt);
}

public function test_概要欄が出力されること()
{
    $app = $this->app;
    $entry = $app->db->model('entry')->new();
    $entry->excerpt = '記事の概要が入ります。';
    $app->ctx->stash('entry', $entry);

    $html = $app->build($this->tmplObj->text);
    $crawler = new Crawler($html);
    $excerptText = $crawler->filter('.p-entryList__excerpt')->text();
    $this->assertSame('記事の概要が入ります。', $excerptText);
}

これで記事概要の有無に応じて正しく出力されることが確認できました。

アセットの場合

アセットの場合は大変でした。アセットカラムはリレーションなのでmt_entryテーブルに値は保存されておらず、$obj->assetsで値やオブジェクトを取得することはできません。

MTEntryAssets周辺がどのような処理になるのか、コンパイルされたテンプレートを確認するとループの中でhdlr_get_relatedobjs・hdlr_get_objecturlメソッドが実行されることが分かりました。

<?php $c_1d989c=null;$_badff8_old_params['_1d989c']=$_badff8_local_params;$_badff8_old_vars['_1d989c']=$_badff8_local_vars;$a_1d989c=$this->setup_args(['limit'=>'1','this_tag'=>'entryassets'],null,$this);$_1d989c=-1;$r_1d989c=true;while($r_1d989c===true):$r_1d989c=($_1d989c!==-1)?false:true;echo $this->component('PTTags')->hdlr_get_relatedobjs($a_1d989c,$c_1d989c,$this,$r_1d989c,++$_1d989c,'_1d989c');ob_start();?>
<?php $c_1d989c = true; if(isset($this->local_vars['__total__'])&&isset($this->local_vars['__counter__'])&&$this->local_vars['__total__']<$this->local_vars['__counter__']){$c_1d989c=false;}if($c_1d989c ):?>
<div class="p-entryList__image"><img src="<?php echo paml_htmlspecialchars($this->component('PTTags')->hdlr_get_objecturl($this->setup_args(['escape'=>'','this_tag'=>'assetfileurl'],null,$this),$this),ENT_QUOTES)?>
" alt=""></div><?php endif;$c_1d989c=ob_get_clean();endwhile; $_badff8_local_params=$_badff8_old_params['_1d989c'];$_badff8_local_vars=$_badff8_old_vars['_1d989c'];?>

つまり、PTTagsクラスのhdlr_get_relatedobjsメソッドやhdlr_get_objecturlメソッドをどうにかしなければならないのですが、そもそもテスト用のデータをデータベースには保存していないのでこのままではどうにもなりません。テストダブルを用意する必要があります。

画像が指定されているケースのテスト

hdlr_get_relatedobjsメソッドはデータベースにアクセスせずそのまま$contentを返す、hdlr_get_objecturlメソッドはテスト用の値を返すようにスタブを作成しました。(実はモックとスタブの理解がまだまだです…)

public function test_画像が出力されること()
{
    $app = $this->app;

    $mockMethods = ['hdlr_get_relatedobjs', 'hdlr_get_objecturl']; // NOTE: モックとスタブの理解がまだまだです…
    $ptTags = $this->createPartialMock(PTTags::class, $mockMethods);
    $ptTags->init_tags();
    $ptTags->method('hdlr_get_relatedobjs')
        ->will($this->returnCallback(function ($args, $content, $ctx, &$repeat, $counter) {
            // $ctx->stash( 'current_context', $asset );
            return ($counter > 1 && isset($args['glue'])) ? $args['glue'] . $content : $content;
        }));
    $ptTags->method('hdlr_get_objecturl')
        ->willReturn('/assets/images/pic_test_01.webp');
    $app->ctx->components['pttags'] = $ptTags;

    $html = $app->build($this->tmplObj->text);
    $crawler = new Crawler($html);
    $imageSrc = $crawler->filter('.p-entryList__image img')->attr('src');
    $this->assertSame('/assets/images/pic_test_01.webp', $imageSrc);
}

コンパイルしたテンプレートの理解、メソッドの処理内容の理解、作成したスタブをどこにセットするかなど、CMSの実装をかなり知っておく必要があるので挫折しそうです。明らかにフロントエンドの領域を越えていますね。ひとまずこれを実行すると以下のようなHTMLが得られ、画像を指定した場合はその画像が出力されることが確認できました。

<li class="p-entryList__item">
  <a href="" class="p-entryList__link">
    <div class="p-entryList__image"><img src="/assets/images/pic_test_01.webp" alt=""></div>
    <div class="p-entryList__title"></div>
    
  </a>
</li>

画像が指定されていないケースのテスト

画像が指定されていない場合のテストはブロックタグ作成の経験があれば分かりやすいと思うのですが、hdlr_get_relatedobjsメソッド内で$repeat = falseをセットします。

public function test_ダミー画像が出力されること()
{
    $app = $this->app;

    $mockMethods = ['hdlr_get_relatedobjs']; // NOTE: モックとスタブの理解がまだまだです…
    $ptTags = $this->createPartialMock(PTTags::class, $mockMethods);
    $ptTags->init_tags();
    $ptTags->method('hdlr_get_relatedobjs')
        ->will($this->returnCallback(function ($args, $content, $ctx, &$repeat, $counter) {
            $repeat = false;
            return ($counter > 1 && isset($args['glue'])) ? $args['glue'] . $content : $content;
        }));
    $app->ctx->components['pttags'] = $ptTags;

    $html = $app->build($this->tmplObj->text);
    $crawler = new Crawler($html);
    $imageSrc = $crawler->filter('.p-entryList__image img')->attr('src');
    $this->assertSame('/assets/images/pic_default_01.webp', $imageSrc);
}

結果、以下のようなHTMLが得られ、画像がない場合は指定したデフォルト画像が出力されることが確認できました。

<li class="p-entryList__item">
  <a href="" class="p-entryList__link">
    <div class="p-entryList__image"><img src="/assets/images/pic_default_01.webp" alt=""></div>
    <div class="p-entryList__title"></div>
    
  </a>
</li>

まとめ

テンプレートのテストは可能だが大変、フロントエンドだけでは完結しないということが分かりました。辻褄合わせをしてテストを通している感も少しあります…。スタブを作成したリレーション関連のところなどについて、テストをサポートするクラスが提供されると楽になるかもしれませんね。
画面キャプチャ:コマンドラインでPHPUnitを実行した画面

サンプルコード

<?php
use PHPUnit\Framework\TestCase;
use Symfony\Component\DomCrawler\Crawler;

require_once 'vendor/autoload.php';
require_once '/Users/Shared/Sites/powercmsx/app/class.Prototype.php' ;

class EntryListTest extends TestCase
{
    protected $app;
    protected $tmplObj;

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

        $tmplObj = $app->db->model('template')->get_by_key([
            'basename' => 'entry_list',
            'workspace_id' => 12,
        ]);
        if ($tmplObj->id) {
            $this->tmplObj = $tmplObj;
        } else {
            throw new Exception('テンプレートの読み込みに失敗しました。', 1);
        }
    }

    public function test_タイトルが正しいこと()
    {
        $app = $this->app;
        $entry = $app->db->model('entry')->new();
        $entry->title = 'テスト記事です';
        $app->ctx->stash('entry', $entry);

        $html = $app->build($this->tmplObj->text);
        $crawler = new Crawler($html);
        $titleText = $crawler->filter('.p-entryList__title')->text();
        $this->assertSame('テスト記事です', $titleText);
    }

    public function test_概要欄が出力されないこと()
    {
        $app = $this->app;
        $entry = $app->db->model('entry')->new();
        $entry->excerpt = '';
        $app->ctx->stash('entry', $entry);

        $html = $app->build($this->tmplObj->text);
        $crawler = new Crawler($html);
        $existExcerpt = $crawler->filter('.p-entryList__excerpt')->count() > 0;
        $this->assertSame(false, $existExcerpt);
    }

    public function test_概要欄が出力されること()
    {
        $app = $this->app;
        $entry = $app->db->model('entry')->new();
        $entry->excerpt = '記事の概要が入ります。';
        $app->ctx->stash('entry', $entry);

        $html = $app->build($this->tmplObj->text);
        $crawler = new Crawler($html);
        $excerptText = $crawler->filter('.p-entryList__excerpt')->text();
        $this->assertSame('記事の概要が入ります。', $excerptText);
    }

    public function test_画像が出力されること()
    {
        $app = $this->app;

        $mockMethods = ['hdlr_get_relatedobjs', 'hdlr_get_objecturl']; // NOTE: モックとスタブの理解がまだまだです…
        $ptTags = $this->createPartialMock(PTTags::class, $mockMethods);
        $ptTags->init_tags();
        $ptTags->method('hdlr_get_relatedobjs')
            ->will($this->returnCallback(function ($args, $content, $ctx, &$repeat, $counter) {
                // $ctx->stash( 'current_context', $asset );
                return ($counter > 1 && isset($args['glue'])) ? $args['glue'] . $content : $content;
            }));
        $ptTags->method('hdlr_get_objecturl')
            ->willReturn('/assets/images/pic_test_01.webp');
        $app->ctx->components['pttags'] = $ptTags;

        $html = $app->build($this->tmplObj->text);
        $crawler = new Crawler($html);
        $imageSrc = $crawler->filter('.p-entryList__image img')->attr('src');
        $this->assertSame('/assets/images/pic_test_01.webp', $imageSrc);
    }

    public function test_ダミー画像が出力されること()
    {
        $app = $this->app;

        $mockMethods = ['hdlr_get_relatedobjs']; // NOTE: モックとスタブの理解がまだまだです…
        $ptTags = $this->createPartialMock(PTTags::class, $mockMethods);
        $ptTags->init_tags();
        $ptTags->method('hdlr_get_relatedobjs')
            ->will($this->returnCallback(function ($args, $content, $ctx, &$repeat, $counter) {
                $repeat = false;
                return ($counter > 1 && isset($args['glue'])) ? $args['glue'] . $content : $content;
            }));
        $app->ctx->components['pttags'] = $ptTags;

        $html = $app->build($this->tmplObj->text);
        var_dump($html);
        $crawler = new Crawler($html);
        $imageSrc = $crawler->filter('.p-entryList__image img')->attr('src');
        $this->assertSame('/assets/images/pic_default_01.webp', $imageSrc);
    }
}