🎻

[symfony/panther] いつもの機能テストの延長ぐらいの気分で気軽にe2eテストを導入しよう

2022/01/28に公開

symfony/panther はPHPでWebスクレイピングやe2eテストを行うためのライブラリです。

READMEに

It will sound familiar if you have ever created a functional test for a Symfony app: as the API is exactly the same!

と書いてあるとおり、特にSymfonyアプリケーションへのe2eテストの導入が簡単に行えます。

普段 WebTestCase を使って機能テストを書いている人なら、ほとんど新しい知識なしでめっちゃ気軽にe2eテストを導入できるので、今回は簡単な例を示しながら具体的な導入手順を解説してみたいと思います👌

1. symfony/pantherをインストール

$ composer require --dev symfony/panther

2. ブラウザドライバーをインストール

$ composer require --dev dbrekelmans/bdi
$ vendor/bin/bdi detect drivers

bdibrowser-driver-installer)によって、ローカルにインストールされているブラウザを検知して対応するドライバーをインストールます。これがe2eテスト時にヘッドレスブラウザとして使用されます。

3. phpunit.xml.dist でPantherのServerExtensionを有効化

phpunit/phpunit をComposerでインストールした際に、すでにPantherのServerExtensionの記述が phpunit.xml.dist にコメントアウトされた状態で書かれているはずなので、これをコメントインします。(もし書かれていなければ追記してください)

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

4. (任意) .env.testPANTHER_APP_ENVpanther から test に変更する

デフォルトだと、.env.testPANTHER_APP_ENV=panther という記述があり、Pantherのテストは APP_ENV=test ではなく APP_ENV=panther として実行されます。

services_test.yaml を作ってテスト環境のサービスコンテナをカスタマイズしている場合などは、e2eテストも APP_ENV=test で実行してくれたほうが余計な設定を作る必要がなくて嬉しいので、必要なら以下のように変更しておくとよいです。

# .env.test
  
- PANTHER_APP_ENV=panther
+ PANTHER_APP_ENV=test

5. (任意)LiipTestFixturesBundleを使ってYAMLフィクスチャをロードする

self::getContainer() すれば APP_ENV=test のコンテナを取得できるので、ステップ4で PANTHER_APP_ENVtest に変更している場合は、普段の機能テストとまったく同じ手順でフィクスチャをロードできます。

protected function setUp(): void
{
    parent::setUp();
    self::getContainer()->get(DatabaseToolCollection::class)->get()->loadAliceFixture([
        '/path/to/fixture.yaml',
    ]);
}

6. 既存の機能テストのコードにe2eテストを追記

さて、ここではすでに以下のような機能テストがあるとしましょう。

FooController に対する機能テストで、フィクスチャを読み込んだ上で /foo/new にアクセスして新規作成が正常にできることを確認しているテストです。

/foo/new にはアクセス制限がかかっていて、認証済ユーザーでないとアクセスできない仕様になっています。

<?php

namespace App\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class FooControllerTest extends WebTestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        self::getContainer()->get(DatabaseToolCollection::class)->get()->loadAliceFixture([
            __DIR__.'/../fixtures/Controller/FooControllerTest.yaml',
        ]);
    }

    public function testNew()
    {
        // 未認証ユーザーは新規作成画面にアクセスしようとするとログイン画面にリダイレクトされる
        $client = self::createClient();
        $client->request('GET', '/foo/new');
        $this->assertResponseRedirects('/login');

        // 認証済ユーザーは新規作成画面にアクセスできる
        $client = self::createAuthorizedClient('user');
        $crawler = $client->request('GET', '/foo/new');
        $this->assertResponseIsSuccessful();

        // 認証済ユーザーは新規作成ができる
        $form = $crawler->selectButton('作成')->form();
        $client->submit($form, [
            'foo[zipCode]' => '1008111',
            'foo[address]' => '東京都千代田区千代田1-1宮内庁',
        ]);
        $crawler = $client->followRedirect();
        $this->assertStringContainsString('作成されました', $crawler->filter('.alert-success')->text(null, true));
    }

    private static function createAuthorizedClient(string $username): KernelBrowser
    {
        $userRepository = static::getContainer()->get(UserRepository::class);
        $user = $userRepository->findOneBy(['username' => $username]);

        return self::createClient()->loginUser($user);
    }
}

ユーザーを認証済にする方法として、古典的なBASIC認証を使った方法 ではなく、Symfony 5.1から導入された $client->loginUser() を使用しています。

まずは、このテストファイル内に機能テストだけでなくe2eテストも書けるように下準備をしてみます。

  <?php
  
  namespace App\Tests\Controller;
  
  use Symfony\Bundle\FrameworkBundle\KernelBrowser;
- use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
+ use Symfony\Component\Panther\PantherTestCase;
  
- class FooControllerTest extends WebTestCase
+ class FooControllerTest extends PantherTestCase
  {
      protected function setUp(): void
      {
          parent::setUp();
          self::getContainer()->get(DatabaseToolCollection::class)->get()->loadAliceFixture([
              __DIR__.'/../fixtures/Controller/FooControllerTest.yaml',
          ]);
      }
  
      public function testNew()
      {
          // 未認証ユーザーは新規作成画面にアクセスしようとするとログイン画面にリダイレクトされる
          $client = self::createClient();
          $client->request('GET', '/foo/new');
          $this->assertResponseRedirects('/login');
  
          // 認証済ユーザーは新規作成画面にアクセスできる
          $client = self::createAuthorizedClient('user');
          $crawler = $client->request('GET', '/foo/new');
          $this->assertResponseIsSuccessful();
  
          // 認証済ユーザーは新規作成ができる
          $form = $crawler->selectButton('作成')->form();
          $client->submit($form, [
              'foo[zipCode]' => '1008111',
              'foo[address]' => '東京都千代田区千代田1-1宮内庁',
          ]);
          $crawler = $client->followRedirect();
          $this->assertStringContainsString('作成されました', $crawler->filter('.alert-success')->text(null, true));
      }
  
      private static function createAuthorizedClient(string $username): KernelBrowser
      {
          $userRepository = static::getContainer()->get(UserRepository::class);
          $user = $userRepository->findOneBy(['username' => $username]);
      
          return self::createClient()->loginUser($user);
      }
  }

このように、単にテストクラスの 基底クラスを WebTestCase から PantherTestCase に変更するだけ でOKです。

実は、PantherTestCase はSymfony環境下では WebTestCase の派生クラスとなっていてWebTestCase が元々持っている機能を何ら破壊していないので、基底クラスを差し替えてしまっても何も問題なくそのまま機能テストは動作するのです。

この上で、同じテストファイル内にe2eテストを実行するコードを追記します。

今回の例ではログイン後にしか操作できない画面をe2eテストしたいわけですが、PantherのClientでは $client->loginUser() は使えないので、実際にログイン画面にアクセスしてログインするようにします。

また、何もしないとテストメソッドを跨いでログインセッションが維持されてしまうため、使い終わったら明示的にログアウトさせておく必要があります。

まずは、ログインする処理・ログアウトする処理をそれぞれメソッド化して使い回せるようにしておきましょう。

  <?php
  
  namespace App\Tests\Controller;
  
  use Symfony\Bundle\FrameworkBundle\KernelBrowser;
+ use Symfony\Component\Panther\Client as PantherClient;
  use Symfony\Component\Panther\PantherTestCase;
  
  class FooControllerTest extends PantherTestCase
  {
      protected function setUp(): void
      {
          parent::setUp();
          self::getContainer()->get(DatabaseToolCollection::class)->get()->loadAliceFixture([
              __DIR__.'/../fixtures/Controller/FooControllerTest.yaml',
          ]);
      }
  
      public function testNew()
      {
          // 未認証ユーザーは新規作成画面にアクセスしようとするとログイン画面にリダイレクトされる
          $client = self::createClient();
          $client->request('GET', '/foo/new');
          $this->assertResponseRedirects('/login');
  
          // 認証済ユーザーは新規作成画面にアクセスできる
          $client = self::createAuthorizedClient('user');
          $crawler = $client->request('GET', '/foo/new');
          $this->assertResponseIsSuccessful();
  
          // 認証済ユーザーは新規作成ができる
          $form = $crawler->selectButton('作成')->form();
          $client->submit($form, [
              'foo[zipCode]' => '1008111',
              'foo[address]' => '東京都千代田区千代田1-1宮内庁',
          ]);
          $crawler = $client->followRedirect();
          $this->assertStringContainsString('作成されました', $crawler->filter('.alert-success')->text(null, true));
      }
  
      private static function createAuthorizedClient(string $username): KernelBrowser
      {
          $userRepository = static::getContainer()->get(UserRepository::class);
          $user = $userRepository->findOneBy(['username' => $username]);
      
          return self::createClient()->loginUser($user);
      }
+ 
+     private function createAuthorizedPantherClient(string $username, string $password): PantherClient
+     {
+         $client = self::createPantherClient();
+         $crawler = $client->request('GET', '/login');
+         $form = $crawler->selectButton('ログイン')->form();
+         $client->submit($form, [
+             'username' => $username,
+             'password' => $password,
+         ]);
+ 
+         return $client;
+     }
+ 
+     private function destroyAuthorizedPantherClient(PantherClient $client): void
+     {
+         $client->executeScript('window.onbeforeunload = null');
+         $client->request('GET', '/logout');
+     }
  }

明示的なログアウト処理を実装している destroyAuthorizedPantherClient() メソッドで、ログアウト前にJavaScriptで window.onbeforeunload = null を実行して onbeforeunload のイベントリスナーを明示的に無効化している点にご留意ください。

これは、beforeunload イベントをlistenしてアラートを表示するようになっている画面にいるときにログアウトしようとすると

Facebook\WebDriver\Exception\UnexpectedAlertOpenException: unexpected alert open: {Alert text : }

というエラーになりテストが失敗してしまうので、その対策として思考停止で入れてあります。(参考

さて、あとはこれらのメソッドを使って実際にe2eテストを実行するコードを追記します。

ここでは、昨日の記事 [Symfony] EasyAdminのフォームフィールドにStimulusの処理を当てる で作った 「郵便番号を入力したら非同期でAPI通信が走って住所入力欄を補完してくれる」 という画面をイメージしてe2eテストを書いてみることにします。

  <?php
  
  namespace App\Tests\Controller;
  
  use Symfony\Bundle\FrameworkBundle\KernelBrowser;
  use Symfony\Component\Panther\Client as PantherClient;
  use Symfony\Component\Panther\PantherTestCase;
  
  class FooControllerTest extends PantherTestCase
  {
      protected function setUp(): void
      {
          parent::setUp();
          self::getContainer()->get(DatabaseToolCollection::class)->get()->loadAliceFixture([
              __DIR__.'/../fixtures/Controller/FooControllerTest.yaml',
          ]);
      }
  
      public function testNew()
      {
          // 未認証ユーザーは新規作成画面にアクセスしようとするとログイン画面にリダイレクトされる
          $client = self::createClient();
          $client->request('GET', '/foo/new');
          $this->assertResponseRedirects('/login');
  
          // 認証済ユーザーは新規作成画面にアクセスできる
          $client = self::createAuthorizedClient('user');
          $crawler = $client->request('GET', '/foo/new');
          $this->assertResponseIsSuccessful();
  
          // 認証済ユーザーは新規作成ができる
          $form = $crawler->selectButton('作成')->form();
          $client->submit($form, [
              'foo[zipCode]' => '1008111',
              'foo[address]' => '東京都千代田区千代田1-1宮内庁',
          ]);
          $crawler = $client->followRedirect();
          $this->assertStringContainsString('作成されました', $crawler->filter('.alert-success')->text(null, true));
+ 
+         // 郵便番号からの住所補完が正常に動作する
+         $client = $this->createAuthorizedPantherClient('user', 'password');
+         $crawler = $client->request('GET', '/foo/new');
+         $crawler->filter('#foo_zipCode')->sendKeys('1008111');
+         $crawler->filter('body')->click(); // blur from #foo_zipCode
+         $client->waitForEnabled('#foo_address', 5);
+         $this->assertSelectorAttributeContains('#foo_address', 'value', '東京都千代田区千代田1-1宮内庁');
+         $this->destroyAuthorizedPantherClient($client);
      }
  
      private static function createAuthorizedClient(string $username): KernelBrowser
      {
          $userRepository = static::getContainer()->get(UserRepository::class);
          $user = $userRepository->findOneBy(['username' => $username]);
      
          return self::createClient()->loginUser($user);
      }
   
     private function createAuthorizedPantherClient(string $username, string $password): PantherClient
     {
         $client = self::createPantherClient();
         $crawler = $client->request('GET', '/login');
         $form = $crawler->selectButton('ログイン')->form();
         $client->submit($form, [
             'username' => $username,
             'password' => $password,
         ]);
   
         return $client;
     }
 
     private function destroyAuthorizedPantherClient(PantherClient $client): void
     {
         $client->executeScript('window.onbeforeunload = null');
         $client->request('GET', '/logout');
     }
  }

追記したテストコードだけを抜き出して何をやっているかをコメントで書いておきます。

// ユーザー認証済のクライアントを作成
$client = $this->createAuthorizedPantherClient('user', 'password');

// 新規作成画面にアクセス
$crawler = $client->request('GET', '/foo/new');

// #foo_zipCode 要素(郵便番号入力欄)に 1008111 と打ち込む
$crawler->filter('#foo_zipCode')->sendKeys('1008111');

// body 要素をクリックして、郵便番号入力欄からフォーカスを外す
$crawler->filter('body')->click(); // blur from #foo_zipCode

// #foo_address 要素(住所入力欄)が一時的にdisabledになって、その後(自動入力が完了したのち)再度enabledになるのを待つ。ただし最大で5秒しか待たない
$client->waitForEnabled('#foo_address', 5);

// #foo_address 要素に '東京都千代田区千代田1-1宮内庁' が入力されていることを確認
$this->assertSelectorAttributeContains('#foo_address', 'value', '東京都千代田区千代田1-1宮内庁');

// 明示的にログアウト
$this->destroyAuthorizedPantherClient($client);

直感的で簡単ですね!

今回の例では既存の機能テストのテストファイルに追記する形でe2eテストを書きましたが、もっとユースケースが複雑になってきたらもちろん機能テストとe2eテストでファイルを分けて管理してもよいですし、その辺はプロジェクトに合わせてお好みでよいと思います👌

おまけ:GitHub Actionsで実行できるようにする

GitHub Actionsを使ってCIしている場合、e2eテストを実行するには

  • npm install を実行する
  • webpack-encore でアセットをビルドする
  • ブラウザドライバーをインストールする

の3つが事前に必要となる(ブラウザドライバーはGitコミットしてしまっている場合は不要ですが)ので、ワークフローに以下のような処理を追加することになるかと思います✋

  - name: Install Dependencies
    run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
+ 
+ - name: Prepare for e2e tests
+   run: |
+     npm install
+     npm run dev
+     vendor/bin/bdi detect drivers
  
  - name: Execute tests
    run: composer test

参考サイト

symfony/panther で実用的なテストを書く | QUARTETCOM TECH BLOG

GitHubで編集を提案

Discussion