[symfony/panther] いつもの機能テストの延長ぐらいの気分で気軽にe2eテストを導入しよう
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
bdi(browser-driver-installer)によって、ローカルにインストールされているブラウザを検知して対応するドライバーをインストールます。これがe2eテスト時にヘッドレスブラウザとして使用されます。
phpunit.xml.dist
でPantherのServerExtensionを有効化
3. 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>
- -->
.env.test
で PANTHER_APP_ENV
を panther
から test
に変更する
4. (任意) デフォルトだと、.env.test
に PANTHER_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_ENV
を test
に変更している場合は、普段の機能テストとまったく同じ手順でフィクスチャをロードできます。
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
Discussion