Symfony Advent Calendar 2021 最終日25日の記事です。
SymfonyWorld 2021 Winterの最後のkeynote The new Testing Landscape: Panther, Foundry and More! で紹介されていた symfony/panther を使って実用的なe2eテストをPHPで書く方法について、実際に試した内容をまとめました。
試したコードはすべてgithubに公開していますので、実際に手元で動かしてみたい方はご利用ください。 https://github.com/77web/symfony-panther-playground-2021

symfony/panther自体は1年以上前から存在していて、私は1年前のSymfonyWorld 2021で初めて存在に気づいたものの、実際のプロジェクトで使うこともなく今日に至っています。
しかし、最近AngularとSymfonyを統合したテストをなんとか自動テストしたい(手動でポチポチやりたくない)という需要が再燃したため、真面目に試してみようと思った次第です。

symfony/pantherを使えるようにする

まず、composerで依存として追加し、ブラウザドライバーをインストールします。

# Pantherを追加
composer require --dev symfony/panther
# chromedriverをインストール・設定
composer require --dev dbrekelmans/bdi
vendor/bin/bdi detect drivers

次に phpunit.xml.dist でPantherのServerExtensionを有効にします。通常はphpunitをcomposerで追加したときに既にコメントアウトされた形で書き込まれているので、コメントを外して有効化するだけで良いはずです。

-    <!-- Run `composer require symfony/panther` before enabling this extension -->
-    <!--
    <extensions>
        <extension class="Symfony\Component\Panther\ServerExtension" />
    </extensions>
-    -->

これでPantherを使ってテストする準備は整いました。

symfony/pantherを使ってJavaScriptで動きのある画面をテストする

テスト用に簡単なAPIとAPIを利用してDOMを動的に書き換える画面を用意しました。
広告のタイプを選ぶと、対応している媒体が出るだけという簡単なアプリです(PHPer老人会ネタになってしまいますが、昔PEARにHierSelectというコンポーネントがありましたね :grin:

app

この画面を実際にPantherでテストしてみます。
Pantherの前にまず、普通のWebTestCaseでテストを書いてみました。

<?php
// ...
    public function test()
    {
        $client = static::createClient();
        $client->request('GET', '/');

        $this->assertResponseIsSuccessful();
    }

当然ながらDOMの動的な変化はテストできないので、「画面が正常に表示されたこと」までしか調べることができません。
では次にPantherを使ってテストを書いてみます。

<?php

// ...
    public function testPanther()
    {
        $client = static::createPantherClient();
        $client->request('GET', '/');

        // index.jsが "/vendorTypes" APIを呼んでDOMを変化させるのを待ち、変化後のDOMに対してCrawlerを作る
        $crawler = $client->waitForElementToContain('#type', 'search', 10);
        // DOMが変化した内容を確認する
        $this->assertCount(4, $crawler->filter('#type option'), '1+3 options in types');
        $this->assertSelectorIsDisabled('#vendor');

        $crawler->filter('#type')->children()->eq(2)->click();
        $client->takeScreenshot(__DIR__.'/../../../selectDisplayInType.png');
        // index.jsが "/vendors" APIを呼んでDOMを変化させるのを待ち、変化後のDOMに対してCrawlerを作る
        $crawler = $client->waitForElementToContain('#vendor', 'Googleディスプレイ広告', 10);
        // DOMが変化した内容を確認する
        $this->assertCount(3, $crawler->filter('#vendor option'), '3 options in vendors');
        $this->assertSelectorIsEnabled('#vendor');
        $this->assertSelectorAttributeContains('#vendor option', 'value', 'gdn');
    }

PHPのコードだけでなく、jQueryと結合した挙動に対して検証するテストができていることがわかります。

symfony/pantherでデータベースfixtureを利用してテストする

Pantherのテストは APP_ENV=test ではなく APP_ENV=panther で実行されます。
そのまま実行するとDATABASE_URLは .env.test のものでなく .env のものが使われてしまうので .env.panther を作って.env.testと同じものを書き込んでおきましょう。
いろいろ試したのですが、PantherTestCaseの中で APP_ENV=panther のContainerを取得することができなかった(取得できたContainerがどうしても APP_ENV=test でした)ので、 pantherのDBはtestのDBと同じ設定にしておくと良いようです。 testと同じDBを使うことで、LiipTestFixturesBundle(v2系)を使ってyamlで書いたfixtureを利用することもできます。

<?php

// ...

class IndexPantherTest extends PantherTestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        $client = static::createPantherClient();

        $databaseTool = self::getContainer()->get(DatabaseToolCollection::class)->get();
        $databaseTool->loadAliceFixture([__DIR__.'/../../fixtures/user.yaml']);

        static::ensureKernelShutdown();
    }
    // ...
}

symfony/pantherでログイン後の画面をテストする

PantherのClientを使っている場合、 $client->loginUser() は利用できません。
PantherのテストでのログインについてはPantherのレポジトリにいくつかissueがあり、様々なworkaroundが提案されていますが、現時点で一番堅実だったのは実際にログインフォームにアクセスしてログインしてしまうことでした。

<?php
// ...
    public function testMember()
    {
        $client = static::createPantherClient();
        $crawler = $client->request('GET', '/login');
        $form = $crawler->selectButton('Sign in')->form();
        $form['email'] = 'member-email@quartetcom.co.jp';
        $form['password'] = 'password';
        $client->submit($form);

        $client->request('GET', '/private');
        $this->assertSelectorTextContains('body', 'member-email@quartetcom.co.jp');

        // ここでログアウトしておかないと、次のテストメソッドにもログイン状態が引き継がれる
        $client->request('GET', '/logout');
    }

    public function testNonMember()
    {
        $client = static::createPantherClient();
        $crawler = $client->request('GET', '/private');
        $this->assertTrue(str_contains($crawler->getUri(), '/login'), $crawler->getUri()); // $client->getResponse() is not available in PantherClient
    }

また、1個めのテストメソッドでログインしてしまうとクッキーが次のテストメソッド・他のテストケースにも引き継がれてしまうようなので、Pantherでログインを伴うテストをした場合は最後でログアウトしたほうが良いでしょう。

まとめ

フロントエンドとの結合テストは、慣れればcypressより簡単に書けそうな気がします!
一方、ログイン周りはまだちょっと課題かなと思います…。

※ Pantherの読み方について
Pantherのレポジトリトップには豹のロゴがついており、おそらく豹の「パンサー」と読むので合っていると思います。
某戦車アニメにはまったことのある私はついついパンターと読んでしまいます… :sweat_smile:

今年もSymfonyアドベントカレンダー全部埋まりましたね!お付き合いいただいた皆さんありがとうございました!
また来年もよろしくお願いします!!