Laravel DuskでE2Eテスト(インストール、使い方、Docker/Vagrant環境別のtipsなど)
- 公開日
- 更新日
- カテゴリ:Laravel
- タグ:PHP,Laravel,Vagrant,Docker,Dusk,E2E
Laravel など PHP で web アプリケーション開発を行う際に、ユニットテストや HTTP テストに加えて、 実際にブラウザ上での動きを確認する E2E( End To End)テストも行っていきたいところです。
Laravel には、Laravel Dusk という、E2E テストを行う事の出来る公式パッケージが提供されています。
今回は、Dusk の導入から、実際にブラウザテストを行うまでを見ていきます。
Contents
- 開発環境
- Laravel Dusk
- インストール
- 動作確認
- E2E テストを考える
- ログイン・ログアウト・認証まわりの E2E テスト
- Docker コンテナ&テスト DB で Dusk を実行する時のジレンマと解決法
開発環境
今回の開発環境は以下の通りです。
- CentOS 7.6
- MySQL 8.0
- PHP 7.3
- Composer 1.8
- Laravel 5.8
- Docker 18.9/19
- docker-compose 1.23/24
- Vagrant 2.2
検証用で Docker と Vagrant を使用していますが、ローカル環境( Linux/Mac)でも進めていけます。
Laravel のルートディレクトリを「 laravel/」としています。
記事中に「 E2E テスト」と「ブラウザテスト」という2つの語が出てきますが、同じ意味で使っています。
Laravel Dusk
Laravel Dusk は、E2E テストの自動化を行う公式のパッケージです。
Laravel Dusk
https://github.com/laravel/dusk
最大の特徴は簡単に E2E テストを導入できる点です。 Dusk ではスタンドアローンの ChromeDriver を使用するので、 ミニマムで動作させる( Chrome ブラウザでのテスト)場合には Selenium などの導入は不要です。
インストール
まずは Dusk のインストールと環境整備を行っていきます。
以下の composer コマンドを叩いて、Dusk をインストールします。
# プロジェクトルートへ移動
cd /path/to/laravel
# Dusk インストール
composer require --dev laravel/dusk
※認証をスキップできてしまうなどあるので本番環境には Dusk をインストールしないでください。
インストール出来たら、以下の artisan コマンドを叩いて Dusk で使用する諸々のクラスやディレクトリを生成します。
php artisan dusk:install
自身のマシン(ローカル環境)に直接インストールを行っていて、GoogleChrome がインストール済みの場合はこの時点で Dusk が動作します。
Docker Compose
Docker コンテナで環境構築をしている場合の追加作業です。
Chrome インストール
別途コンテナに Chrome を導入してあげる必要があるので、インストールします。 まずは yum リポジトリを作成します。
touch /etc/yum.repos.d/google-chrome.repo
以下を定義します。
- /etc/yum.repos.d/google-chrome.repo
-
[google-chrome] name=google-chrome baseurl=http://dl.google.com/linux/chrome/rpm/stable/$basearch enabled=1 gpgcheck=1 gpgkey=https://dl-ssl.google.com/linux/linux_signing_key.pub
Chrome をインストールします。
# Chrome インストール
yum -y install google-chrome-stable
もちろんこれらはイメージ作成の時点で済ませてしまえば一回で済みます。
ChromeDriver と Chrome のバージョンについて
php artisan dusk:install でインストールされる ChromeDriver と、手動でインストールした google-chrome-stable のバージョンが合っていないと Dusk は動作しません。 Dusk 実行時に以下のようなエラーが出たら各々のバージョンを確認し、双方のバージョンを合わせてあげます。
# Dusk 実行
$ php artisan dusk
# エラー
1) Tests\Browser\ExampleTest::testBasicExample
Facebook\WebDriver\Exception\SessionNotCreatedException: session not created: This version of ChromeDriver only supports Chrome version 76
(Driver info: chromedriver=76.0.3809.68 (420c9498db8ce8fcd190a954d51297672c1515d5-refs/branch-heads/3809@{#864}),platform=Linux 4.9.184-linuxkit x86_64)
# Chrome のバージョンを確認
$ google-chrome-stable --version
Google Chrome 75.0.3770.142
# 合ってない
chromedriver 76.0.3809.68
Google Chrome 75.0.3770.142
尚、今回の手順では最新版が入るので、バージョン違いは起こらないはずです。 ( GoogleChrome がインストール済みのイメージを持っていて、新しく Dusk を入れた時とかにそっちだけ最新版でバージョンが上がっててズレる。みたいな事はありそうです)
バージョンを合わせて上げれば問題なく動作するので、このエラーメッセージにピンときたら一度双方のバージョンを確認してみてください。
# GoogleChrome アンインストール
yum remove google-chrome-stable
# GoogleChrome インストール
yum install google-chrome-stable
Installed:
google-chrome-stable.x86_64 0:76.0.3809.87-1
# バージョン確認
$ google-chrome --version
Google Chrome 76.0.3809.87
# Dusk 実行
$ php artisan dusk
=> OK (1 test, 1 assertion)
DuskTestCase.php
Dusk テストの抽象クラスを編集し、オプションに --no-sandbox を追加します。
- laravel/tests/DuskTestCase.php
-
<?php namespace Tests; use Laravel\Dusk\TestCase as BaseTestCase; use Facebook\WebDriver\Chrome\ChromeOptions; use Facebook\WebDriver\Remote\RemoteWebDriver; use Facebook\WebDriver\Remote\DesiredCapabilities; abstract class DuskTestCase extends BaseTestCase { use CreatesApplication; /** * Prepare for Dusk test execution. * * @beforeClass * @return void */ public static function prepare() { static::startChromeDriver(); } /** * Create the RemoteWebDriver instance. * * @return \Facebook\WebDriver\Remote\RemoteWebDriver */ protected function driver() { $options = (new ChromeOptions)->addArguments([ '--disable-gpu', '--headless', '--no-sandbox' // 追加 ]); return RemoteWebDriver::create( 'http://localhost:9515', DesiredCapabilities::chrome()->setCapability( ChromeOptions::CAPABILITY, $options ) ); } }
.env.dusk.local
Dusk 用の env ファイルを作成します。
尚、Dusk を実行する際に環境変数を変更したい場合は作成すれば良いので、変更箇所がなければここはスキップしてしまって大丈夫です。
現在稼働している.env をコピーし、.env.dusk.local へリネームします。
例として、php artisan serve でローカルサーバを立ち上げている場合はデフォルトでポートが 8000 番になりますが、 そんな感じでコンテナ作成時にポートフォワーディングしているならフォワードする前の指定が必要なので APP_URL にポートを付け足してやるなどします。
例えば、docker-compose.yml で以下の様にしている場合です。
web:
ports:
- 80:8000
この場合はポートを付け足してあげます。
- laravel/.env.dusk.local
-
# .env の場合 # APP_URL=http://localhost # ↓ # .env.dusk.local では変更 APP_URL=http://localhost:8000
あくまでも設定例なので、この辺は自身の環境に合わせて行ってください。
selenium コンテナを導入するなら
アプリケーション側に Chrome を導入するのではなく別途 selenium コンテナを導入して Dusk を動かしたい場合、ミニマムでは以下のセッティングになります。
- docker-compose.yml
-
version: '3' services: # # 省略 # selenium: image: selenium/standalone-chrome ports: - 4444:4444
- laravel/tests/DuskTestCase.php
-
<?php namespace Tests; use Laravel\Dusk\TestCase as BaseTestCase; use Facebook\WebDriver\Remote\RemoteWebDriver; use Facebook\WebDriver\Remote\DesiredCapabilities; abstract class DuskTestCase extends BaseTestCase { use CreatesApplication; /** * Prepare for Dusk test execution. * * @beforeClass * @return void */ public static function prepare() { // static::startChromeDriver(); } /** * Create the RemoteWebDriver instance. * * @return \Facebook\WebDriver\Remote\RemoteWebDriver */ protected function driver() { return RemoteWebDriver::create( 'http://selenium:4444/wd/hub', DesiredCapabilities::chrome() ); } }
アプリケーション側のイメージに含めてしまうのか別のコンテナとして持っておくのかは好みやリソースによってだと思うので、好きな方で良いと思います。 (本記事では前者の環境で進めていきます)
Vagrant
VM など仮想環境で環境を構築している場合にも、追加作業が必要です。
前章のDocker Compose で紹介している以下の作業を行ってください。
この辺も、Vagrantfile に書いてしまえば一度で済みます。
動作確認
これで一通り Dusk の導入が完了したので、デフォルトで設置されているサンプルテストを走らせてみます。 以下の artisan コマンドを叩きます。
# プロジェクトルートへ移動
cd /path/to/laravel
# Dusk 実行
php artisan dusk
# 実行結果
PHPUnit 7.5.14 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 4.02 seconds, Memory: 16.00 MB
OK (1 test, 1 assertion)
問題なく Dusk でブラウザテストが実行されました。
さて、ここでコンソールの実行部分を見てみると、PHPUnit が起動している事がわかります。 Dusk は PHPUnit を拡張したもので、PHPUnit 拡張+ php-webdriver+ ChromeDriver を Laravel 用にワンパッケージにして提供したものというのがここで垣間見えます。
ちなみにわざと失敗してみます。サンプルテストを少し変更します。
- laravel/tests/Browser/ExampleTest.php
-
public function testBasicExample() { $this->browse(function (Browser $browser) { $browser->visit('/') ->pause(2000) // 追加 ->assertSee('Laravel is PHP Framework'); // 変更 }); }
2秒ほどステイ(スクリーンショットの関係で敢えてのステイです)した後にテキストを探しますが、ドメインルート( welcome ページ)に「 Laravel is PHP Framework 」という文言は存在しないのでエラーになります。
Dusk ではエラーになった際に、そのテストのスクリーンショットを撮ってくれます。
( laravel/tests/Browser/screenshots/ 配下へ出力されます)
headless モードで実行しているので目の前でブラウザが勝手に動いてテストされるわけではありませんが、スクリーンショットを見るとブラウザが立ち上がってテストされている事が確認できます。
E2E テストを考える
テストを定義する前に、ブラウザテストではどんな事を定義したら良いのか考えます。
E2E テストは「 End To End 」の略で「端から端までをテストする」という意味ですが、工程的にはインテグレーションテスト(統合テスト)に相当します。
つまり、「シナリオ(業務の流れ)があって、それを(ブラウザ上で)なぞるテスト」が E2E テストという事になります。
- ログインページへアクセスした
- 管理者ユーザー A でログインした
- ダウンロードページへアクセスした
- ユーザー A が管理するチームの、今週一週間分の業務報告 CSV ファイルをダウンロードした
- ログアウトした
- ログインページへリダイレクトした(終了)
みたいなやつですね。
かっちり「統合テスト!」みたいな感じならこうですが、今回はシナリオ云々できるようなサンプルアプリケーションを用意していない事もありライトな感じで定義していきます。なので E2E テストを実戦投入する際にはその辺にも一度立ち返っていただき、最適なテストを定義してください。 (とはいえ個人的には細かい粒度のものもいいと思っています。規模やアプリの形態によりけりですね)
ログイン・ログアウト・認証まわりの E2E テスト
Laravel Dusk関連の記事にはお約束です。公式ドキュメントでも紹介されていますし、認証機能が一瞬で導入出来るのでテストしてみるにはちょうど良いという事で、やってみます。
まずはダイジェストで。認証機能の導入と Dusk テストクラスの生成を以下の artisan コマンドを叩いて行います。
# users テーブル作成
php artisan migrate
# 認証まわりスカフォールディング
php artisan make:auth
# テストクラス生成
php artisan dusk:make LoginTest
php artisan dusk:make LogoutTest
php artisan dusk:make AuthenticatedTest
php artisan dusk:make UserRegisterTest
それぞれにテストケースを定義していきます。
- laravel/tests/Browser/LoginTest.php
-
<?php namespace Tests\Browser; use App\User; use Illuminate\Support\Facades\Hash; use Tests\DuskTestCase; use Laravel\Dusk\Browser; use Illuminate\Foundation\Testing\DatabaseMigrations; class LoginTest extends DuskTestCase { use DatabaseMigrations; /** * @test * @group auth * @group login */ public function ログインが成功すること() { $password = 'Password@1234'; $user = factory(User::class)->create([ 'password' => Hash::make($password), 'remember_token' => null ]); $this->browse(function (Browser $browser) use ($user, $password) { $browser->visit('/login') ->type('#email', $user->email) ->type('#password', $password) ->press('Login') ->assertPathIs('/home'); }); } }
- Factory でダミーデータを生成し登録してからログイン動作をテストしています。
- パスワードは、登録時はハッシュ化し、入力時はそのままの文字列を入力します。
- laravel/tests/Browser/LogoutTest.php
-
<?php namespace Tests\Browser; use App\User; use Tests\DuskTestCase; use Laravel\Dusk\Browser; use Illuminate\Foundation\Testing\DatabaseMigrations; class LogoutTest extends DuskTestCase { use DatabaseMigrations; /** * @test * @group auth * @group logout */ public function ログアウトが成功すること() { $user = factory(User::class)->create(); $this->browse(function (Browser $browser) use ($user) { $browser->loginAs($user)->visit('/home') ->click('#navbarDropdown') ->assertSee('Logout') ->clickLink('Logout') ->assertPathIs('/'); }); } }
- ダミーデータを生成し、それを loginAs() で認証済みとしてからブラウザ操作を行っています。
- Laravel がデフォルトで用意している画面では、ログアウトを行う場合は一度画面右上のユーザー名を押下しドロップダウンメニューを表示させる( JavaScript) 必要があるので、ここの辺りの操作も含めてテストできるのは E2E テストならではです。
- laravel/tests/Browser/AuthenticatedTest.php
-
<?php namespace Tests\Browser; use App\User; use Tests\DuskTestCase; use Laravel\Dusk\Browser; use Illuminate\Foundation\Testing\DatabaseMigrations; class AuthenticatedTest extends DuskTestCase { use DatabaseMigrations; /** * @test * @group auth * @group authenticated */ public function 認証済みでなければログイン後ページへのアクセスはログインページへリダイレクトされること() { $this->browse(function (Browser $browser) { $browser->visit('/home') ->assertPathIs('/login'); }); } /** * @test * @group auth * @group authenticated */ public function 認証済みであればログイン後ページへアクセスできること() { $user = factory(User::class)->create(); $this->browse(function (Browser $browser) use ($user) { $browser->loginAs($user)->visit('/home')->assertPathIs('/home'); }); } }
このテストに関しては特筆事項はありません。次に定義するユーザー登録のテストでは、Factory を使って先にダミーデータの定義を行っておきます。
- laravel/database/factories/UserFactory.php
-
$factory->define(User::class, function (Faker $faker) { return [ 'name' => $faker->name, 'email' => $faker->unique()->safeEmail, 'password' => $faker->password(10), ]; }, 'dusk_register_1');
ユーザ登録用のダミーデータを定義しました。こうしておくとテストメソッド内が簡潔になって良い感じになります。
- laravel/tests/Browser/UserRegisterTest.php
-
<?php namespace Tests\Browser; use App\User; use Tests\DuskTestCase; use Laravel\Dusk\Browser; use Illuminate\Foundation\Testing\DatabaseMigrations; class UserRegisterTest extends DuskTestCase { use DatabaseMigrations; /** * @test * @group auth * @group register */ public function ユーザー登録が成功すること() { $user = factory(User::class, 'dusk_register_1')->make(); $this->browse(function (Browser $browser) use ($user) { $browser->visit('/register') ->type('#name', $user->name) ->type('#email', $user->email) ->type('#password', $user->password) ->type('#password-confirm', $user->password) ->press('Register') ->assertPathIs('/home'); }); } }
ダミーデータは生成時には DB への登録を行わずにモデルとして取得のみ行っています。
E2E テスト実施
一通りのテストを定義したのでブラウザテストを実施してみます。
# プロジェクトルートへ移動
cd /path/to/laravel
# Dusk 実行
php artisan dusk
# 実行結果
PHPUnit 7.5.14 by Sebastian Bergmann and contributors.
..... 5 / 5 (100%)
Time: 21.47 seconds, Memory: 28.00 MB
OK (5 tests, 6 assertions)
問題なくテストに通った事を確認できました。
Docker コンテナ&テスト DB で Dusk を実行する時のジレンマと解決法
先程定義し実施した E2E テストにおいて、データベースはテスト用には切り替えずそのまま開発用の DB を用いました。
ですが場合によってはユニットテストの様に、テスト用の DB に切り替えて開発で使用している DB とは別のスキーマを使いたい場合もあるかもしれません。
その場合には、.env.dusk.local を用意して、 そこの DB_DATABASE をテスト用の DB にすれば良いわけですが、Docker コンテナ環境の場合に、それが上手くいきません。
例えば今回定義したテストでは Factory を用いてダミーデータを予め登録しています。その場合にはテスト用 DB に登録されますが、Dusk 実行時にブラウザ上で行われる操作への DB の参照がテスト用 DB ではなく開発用に向いてしまう現象が確認されています。 ( selenium コンテナの場合も同じです)
Dusk はこれが仕様なのかバグなのかがちょっとわかりませんが(Vagrant の場合は問題なくテスト DB で動作します)、 細かく処理を追って行くと、どうやらフレームワーク上での環境切り替え自体は問題なく行われている事は確認しました。
という事で、非公式ですが解決法を以下に記します。
- laravel/tests/DuskTestCase.php
-
<?php namespace Tests; use Laravel\Dusk\TestCase as BaseTestCase; use Facebook\WebDriver\Chrome\ChromeOptions; use Facebook\WebDriver\Remote\RemoteWebDriver; use Facebook\WebDriver\Remote\DesiredCapabilities; use Illuminate\Support\Facades\Artisan; abstract class DuskTestCase extends BaseTestCase { use CreatesApplication; private $dusk_env = '.env.dusk.local'; public function setUp(): void { parent::setUp(); $this->app = $this->createApplication(); if(file_exists($this->app->basePath($this->dusk_env))) { $this->app->loadEnvironmentFrom($this->dusk_env); Artisan::call('config:cache'); } } public function tearDown(): void { Artisan::call('config:clear'); parent::tearDown(); } /** * Prepare for Dusk test execution. * * @beforeClass * @return void */ public static function prepare() { static::startChromeDriver(); } /** * Create the RemoteWebDriver instance. * * @return \Facebook\WebDriver\Remote\RemoteWebDriver */ protected function driver() { $options = (new ChromeOptions)->addArguments([ '--disable-gpu', '--headless', '--no-sandbox' ]); return RemoteWebDriver::create( 'http://localhost:9515', DesiredCapabilities::chrome()->setCapability( ChromeOptions::CAPABILITY, $options ) ); } }
追加したのは setUp() メソッドと tearDown() メソッド、そしてメンバ変数1つです。 コンストラクタ( setUp)で改めて Dusk 用の環境変数をロードし、キャッシュします。そして、デストラクタ( tearDown)でキャッシュをクリア(原状復帰)しています。
単純にキャッシュをクリアだけしても解決はしませんでしたが、キャッシュ自体はしっかり読んでいたので、明示的にキャッシュを再構築して食わせてやる事で ブラウザ動作側でもテスト用 DB に向くようにしています。
ということは、仕様としては「 Dusk は設定ファイルによって DB は切り替えている」という事になるので、バグではない。という事になりますね。 ドライバ側で上手くやれるとここの記述も無くせるので、また別の機会に調査してみます。
まとめ
以上で作業は終了です。
そういえば、Google のTesting Blog をご存知でしょうか。 グーグルのテストエンジニアの方々が、社内のソフトウェア品質に関する知見を書き連ねているブログです。
その中に、 Just Say No to More End-to-End Tests というエントリがあります。
2015 年の記事なのですが、エンドツーエンドテストと他のテストとのバランスの事などが書かれていて、とても有用な記事です。 なんだかんだ、
- ユニットテスト 70%
- 統合テスト 20%
- エンドツーエンドテスト 10%
くらいが良いよね。みたいなやつです。(ピラミッドのやつ、有名ですよね。)
なぜ E2E テストを行うのか
ユニットテスト(単体テスト)では小さい単位での機能をテストし、HTTP テスト(結合テスト)では一連の処理の流れや結果をテストし、E2E テスト(統合テスト)ではブラウザ上でのシナリオや操作をテストしますが、どのテストかに関わらず大切なのは、 「テストが何度でも同一精度で反復可能である」事です。
例えばある程度の規模のアプリケーションの統合テストや受け入れテストを行う場合に、人が実際にブラウザから操作を行い、要件を満たした挙動であるか、操作が行えるかを確認しますが、 そのテスト項目は非常に多く、実際とてもしんどい作業だったりします。
とはいえ必要な工程なので行うしかありませんが、これを何回も行うとなると、人間ですから場合によって精度が一定ではなくなったりします。
これをいつでも必要な時に同一の精度でテストを実施出来る事がテストを自動化する事のメリットなわけですが、そもそもユニットテストなど、ファンクション単位での「機能」 に関してはブラウザ上にはそれ単体として現れているわけではないので、一定ここを自動化していく事の流れは既に出来上がっているかなと思います。 ですが、E2E テスト、いわゆるブラウザ上からのテストの自動化についてはまだまだ後回しになってしまっている感は否めません。
ただそれにはそれ相応の理由もあると思っていて、実際に人が操作し、目視でしっかりと確認しながら進めていくテストにも、一定の価値があるのも確かだからです。
また、UI に関しては変更が頻繁に起きたりするので、その度にテストも変更しなくてはいけなかったり、そんなこんなで UI のテスト周りがどうしても最後の方になりがちなので どうしても、、、という感じになってしまうのかなと感じています。
とはいえ、シンプルに「面倒な事を楽に。そして確実に、安全に、品質を担保する」という点では E2E テストを自動化する事にも大きなメリットがあると思っている(人の手と目で行うからこそ)ので、 開発現場の状況を鑑みつつ、適切に導入して少しでも楽出来るとこはしていきたいところですね。