Laravel DuskでE2Eテスト(インストール、使い方、Docker/Vagrant環境別のtipsなど)
- 公開:
- 更新:
- カテゴリ: PHP Laravel
- タグ: PHP,Laravel,Vagrant,Docker,Dusk,Testing,E2E
LaravelなどPHPでwebアプリケーション開発を行う際に、ユニットテストやHTTPテストに加えて、 実際にブラウザ上での動きを確認するE2E(End To End)テストも行っていきたいところです。
Laravelには、Laravel Duskという、E2Eテストを行う事の出来る公式パッケージが提供されています。
今回は、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コンテナの場合も同じです)
「統合テストそもそもの本質を考えたら、テストDBなんて使う必要ないですよね」
という理屈もわからなくはないので(けど波平なみの頭の固さ!)、
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テストを自動化する事にも大きなメリットがあると思っている(人の手と目で行うからこそ)ので、 開発現場の状況を鑑みつつ、適切に導入して少しでも楽出来るとこはしていきたいところですね。