カテゴリー: BackEnd

Laravelのブラウザテスト「Dusk」をシンプルなCRUDで始めてみよう

はじめに

Laravelでは標準で単体テストを実行できますが、公式パッケージ Laravel Duskを使用すると、ブラウザテスト作成・実行ができるようになります。今回は、CRUDを例に、セットアップと基本的なアクション・アサーションを紹介します。

Laravel Duskのセットアップ

まずは、composerでインストールします。

$ composer require --dev laravel/dusk

次に、artisanコマンドで必要なファイルを配置します。

$ php artisan dusk

tests/Browswerディレクトリが作成され、ブラウザテストに必要なファイルが配置されます。構造は次のようになっています。

.
├── Components  // コンポーネント定義
├── Pages // ページ定義
├── console // コンソール出力結果
└── screenshots // スクリーンショット出力結果

今回は触れませんが、ページやコンポーネントを使用すると、テストをより構造的に実装することができ、同じUIパーツやページをテストする際に、コードを使いまわせるようになります。
また、consoleはブラウザコンソールの内容、screenshotsにはテスト失敗時や、任意のタイミングでスクリーンショットを撮影したときの画像が入ります。

新規登録のテスト

テスト対象となるコード

前提となる機能を実装します。今回は、書籍を登録・更新・削除できるサンプルコードになります。まずは、一覧と新規登録を作成します。

web.php

<?php

use Illuminate\Support\Facades\Route;


Route::resource('books', \App\Http\Controllers\BookController::class);

BookController

<?php

namespace App\Http\Controllers;

use App\Http\Requests\BookRequest;
use App\Models\Book;
use Illuminate\Http\Request;

class BookController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @return \Illuminate\Http\Response
     */    public function index()
    {
        $books = Book::all();
        return view('books.index', compact('books'));
    }

    /**
     * Show the form for creating a new resource.
     *
     * @return \Illuminate\Http\Response
     */    public function create()
    {
        $book = new Book();
        return view('books.create', compact('book'));
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */    public function store(BookRequest $request)
    {
        $book = new Book();
        $book->title = $request->title;
        $book->author = $request->author;
        $book->save();
        return response()->redirectToRoute('books.index');
    }
}

BookRequest

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class BookRequest extends FormRequest
{
    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */    public function rules()
    {
        return [
            'title' => 'required',
            'author' => 'required'
        ];
    }

    public function attributes()
    {
        return [
            'title' => 'タイトル',
            'author' => '著者'
        ];
    }
}

index.blade

<h1>図書一覧</h1>

<a href="{{ route('books.create') }}">新規作成</a>

<table>
    <tr>
        <th>ID</th>
        <th>タイトル</th>
        <th>著者</th>
        <th colspan="2"></th>
    </tr>
    @foreach($books as $book)
        <tr>
            <td>{{ $book->id }}</td>
            <td>{{ $book->title }}</td>
            <td>{{ $book->author }}</td>
        </tr>
    @endforeach
</table>

edit.blade

<h1>図書の編集</h1>

<form method="post" action="{{ route('books.update', $book) }}">
    @include('books.form')
    @method('put')
    <br>
    <button type="submit" id="register">更新する</button>
</form>

form.blade

@csrf
タイトル: <input type="text" name="title" value="{{ old('title', optional($book)->title) }}">
@error('title')
    <br>
    {{ $message }}
@enderror
<br>
著者: <input type="text" name="author" value="{{ old('author', optional($book)->author) }}">
@error('author')
    <br>
    {{ $message }}
@enderror

Duskのテストコード

まずは、テスト用のクラスを作成します。

$ php artisan dusk:make BookCreateTest

すると、 tests/Browser/BookCreateTest.phpが生成されます。ここに、テストコードを書いていきます。

<?php

namespace Tests\Browser;

use Illuminate\Foundation\Testing\DatabaseMigrations;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;

class BookCreateTest extends DuskTestCase
{
    public function testCreateBookOK()
    {
        $this->browse(function (Browser $browser) {
            $browser->visit('/books') // 一覧画面に遷移
                    ->clickLink('新規作成') // 一覧画面で新規作成リンクをクリック
                    ->type('title', 'タイトルテスト') // タイトルを入力する
                    ->type('author', '著者テスト')  // 著者を入力する
                    ->click('button[type="submit"]') // 送信ボタンをクリック
                    ->assertPathIs('/books') // 一覧画面に遷移を確認
                    ->assertSee('タイトルテスト') // 「タイトルテスト」というテキストが含まれていること
                    ->assertSee('著者テスト'); // 「著者テスト」というテキストが含まれていること
        });
    }

    public function testCreateBookValidationError()
    {
        $this->browse(function (Browser $browser) {
            $browser->visit('/books') // 一覧画面に遷移
                ->clickLink('新規作成') // 一覧画面で新規作成リンクをクリック
                ->click('button') // 何も入力せずにクリック
                ->assertPathIs('/books/create')
                ->assertSee('タイトルは必ず指定してください。') // バリデーションエラーの文言が表示されていること
                ->assertSee('著者は必ず指定してください。');// バリデーションエラーの文言が表示されていること
        });
    }
}

PHPUnitを使用しているので、メソッド名のプレフィックスに testを付けるか、アノテーションを設定します。
基本的には、 $browserに続けて、動作とアサーションを記述していきます。最初の例では、新規作成画面に遷移し、必要なパラメータを入力します。
2つめの例では、入力せずに送信し、バリデーションエラーの文言をチェックしています。
ここで使用しているメソッドを紹介します。

  • type(テキストボックスのname, 入力内容文字列)
    テキストボックスに入力します。
  • click(セレクタ)
    ボタンをクリックします。セレクタにはCSSセレクタが使用できるので、例えばid等でclick('#register') と指定することもできます。
  • clickLink(テキスト)
    テキストリンクをクリックします。
  • assertPathIs(パス)
    表示されているページのパスが、指定のものと一致するか検証します。読み込みに時間のかかるページの場合は、waitする必要はありませんでした。一方で、JavaScriptで重い処理の直後にこの関数を実行すると、アサーションに失敗しました。(JavaScriptに絡むものは次回以降紹介したいと思います。)
  • assertSee(文字列)
    表示されているページに指定の文字列があるか確認します。

テストの実行

テストを実行するには $ php artisan duskコマンドを使用します。実際にテストを動作させると、次のように出力されます。

$ php artisan dusk
PHPUnit 9.3.11 by Sebastian Bergmann and contributors.

.......                                                             7 / 7 (100%)

Time: 00:03.209, Memory: 20.00 MB

OK (7 tests, 20 assertions)

失敗すると、エラー内容が表示されます。また、 tests/Browser/screenshots配下に、エラー時のスクリーンショットが保存されます。

更新のテスト

更新のテストでは、更新前の値が正しく入っていること、正しく更新できることをテストします。

テスト対象となるコード

新規作成のテストで作ったコードに追記します。

BookController

<?php

namespace App\Http\Controllers;

use App\Http\Requests\BookRequest;
use App\Models\Book;
use Illuminate\Http\Request;

class BookController extends Controller
{
・・・中略・・・
    /**
     * Show the form for editing the specified resource.
     *
     * @param  \App\Models\Book  $book
     * @return \Illuminate\Http\Response
     */    public function edit(Book $book)
    {
         return view('books.edit', compact('book'));
    }

    /**
     * Update the specified resource in storage.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \App\Models\Book  $book
     * @return \Illuminate\Http\Response
     */    public function update(BookRequest $request, Book $book)
    {
        $book->title = $request->title;
        $book->author = $request->author;
        $book->save();
        return response()->redirectToRoute('books.index');
    }
}

index.blade.php

<h1>図書一覧</h1>

<a href="{{ route('books.create') }}">新規作成</a>

<table>
    <tr>
        <th>ID</th>
        <th>タイトル</th>
        <th>著者</th>
        <th colspan="2"></th>
    </tr>
    @foreach($books as $book)
        <tr>
            <td>{{ $book->id }}</td>
            <td>{{ $book->title }}</td>
            <td>{{ $book->author }}</td>
<!-- 追加を追記 -->
            <td><a href="{{ route('books.edit', $book) }}">詳細</a></td>
        </tr>
    @endforeach
</table>

edit.blade.php (新規追加)

<h1>図書の編集</h1>

<form method="post" action="{{ route('books.update', $book) }}">
    @include('books.form')
    @method('put')
    <br>
    <button type="submit">更新する</button>
</form>

Duskのテストコード

新規作成のテストの応用で実装していきます。先ほどの BookCreateTest.phpに追記します。

<?php

namespace Tests\Browser;

use Illuminate\Foundation\Testing\DatabaseMigrations;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;

class BookCreateTest extends DuskTestCase
{
・・・中略・・・
    public function testEditBook()
    {
        $this->browse(function (Browser $browser) {
            $browser->visit('/books') // 一覧画面に遷移
                ->click('detail-link:first-of-type') // 一覧画面で1番始めの詳細リンクをクリック
                ->assertValue('input[name="title"]', 'タイトルテスト') // テキストボックスの値をチェック
                ->assertValue('input[name="author"]', '著者テスト');
        });
    }

    public function testUpdateBook()
    {
        $this->browse(function (Browser $browser) {
            $browser->visit('/books') // 一覧画面に遷移
                ->click('detail-link:first-of-type') // 一覧画面で1番始めの詳細リンクをクリック
                ->type('title', 'タイトルテスト2') // テキストボックスの値を書き換え
                ->type('author', '著者テスト2')
                ->click('button')
                ->assertPathIs('/books')
                ->assertSee('タイトルテスト2') // 一覧画面で、書き換え後のテキストになっているか確認する
                ->assertSee('著者テスト2');
        });
    }
}

ここで使用しているメソッドを紹介します。

  • assertValue(セレクタ)
    セレクタの inputタグに、値を指定します。 click()と同じように、CSSセレクタで指定します。
  • click('detail-link:first-of-type')
    先ほども紹介したメソッドですが、CSSセレクタの使い方が異なるので紹介します。一覧画面では複数の詳細リンクがあります。そこで、CSS擬似クラス :first-of-typeを使用して、1つ目の詳細リンクをクリックするようにしています。

JavaScriptを組み合わせた例

ブラウザテストなので、JavaScriptの動作も検証することができます。削除のテストを例にとって、説明します。

テスト対象となるコード

新規作成のテストで作ったコードに追記します。

BookController

<?php

namespace App\Http\Controllers;

use App\Http\Requests\BookRequest;
use App\Models\Book;
use Illuminate\Http\Request;

class BookController extends Controller
{
・・・中略・・・
    /**
     * Remove the specified resource from storage.
     *
     * @param  \App\Models\Book  $book
     * @return \Illuminate\Http\Response
     */    public function destroy(Book $book)
    {
        $book->delete();
        return response()->redirectToRoute('books.index');
    }
}

index.blade.php

<h1>図書一覧</h1>

<a href="{{ route('books.create') }}">新規作成</a>

<table>
    <tr>
        <th>ID</th>
        <th>タイトル</th>
        <th>著者</th>
        <th colspan="2"></th>
    </tr>
    @foreach($books as $book)
        <tr>
            <td>{{ $book->id }}</td>
            <td>{{ $book->title }}</td>
            <td>{{ $book->author }}</td>
            <td><a href="{{ route('books.edit', $book) }}">詳細</a></td>
<!-- ここから追記 -->
            <td>
                <form method="post" action="{{ route('books.destroy', $book) }}" onsubmit="return destroy()">
                    @csrf
                    @method('delete')
                    <button type="submit">削除</button>
                </form>
            </td>
<!-- ここまで追記 -->
        </tr>
    @endforeach
</table>

<!-- ここから追記 -->
<script>
    function destroy() {
        if (!confirm('本当に削除しますか?')) {
            return false;
        }
    }
</script>
<!-- ここまで追記 -->

Duskのテストコード

このサンプルでは、削除ボタンを押すと「本当に削除しますか?」というダイアログが表示されてから、削除を行います。その動作をLaravel Duskで実装します。
それでは、先ほどの BookCreateTest.phpに追記します。

<?php

namespace Tests\Browser;

use Illuminate\Foundation\Testing\DatabaseMigrations;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;

class BookCreateTest extends DuskTestCase
{
・・・中略・・・
    public function testDestroyCancel()
    {
        $this->browse(function (Browser $browser) {
            $browser->visit('/books') // 一覧画面を表示
                ->click('button') // 削除ボタンをクリック
                ->assertDialogOpened('本当に削除しますか?')  // ダイアログの文言確認
                ->dismissDialog() // キャンセルボタンをクリック
                ->assertPathIs('/books') // 一覧画面のパス確認
                ->assertSee('タイトルテスト2') // 削除されていないことを表示で確認
                ->assertSee('著者テスト2'); // 削除されていないことを表示で確認
        });
    }

    public function testDestroyBook()
    {
        $this->browse(function (Browser $browser) {
            $browser->visit('/books') // 一覧画面を表示
                ->click('button') // 削除ボタンをクリック
                ->assertDialogOpened('本当に削除しますか?') // ダイアログの文言確認
                ->acceptDialog() // 削除ボタンをクリック
                ->assertPathIs('/books') // 一覧画面のパス確認
                ->assertDontSee('タイトルテスト2') // 削除した本のタイトルが表示されていないことを確認
                ->assertDontSee('著者テスト2');  // 削除した本の著者が表示されていないことを確認
        });
    }
}

ここで使用しているメソッドを紹介します。

  • assertDialogOpened(テキスト)
    ダイアログ(JSのalert/confirm/prompt)のメッセージが、指定のものになっているか検証します。
  • acceptDialog()
    ダイアログのOKボタンを押下します。
  • dismissDialog()
    ダイアログを閉じます。(confirm, promptではキャンセルボタン押下と同じになります。)
  • assertDontSee(テキスト)
    指定したテキストが表示されていないことを検証します。

さいごに

今回のテストコードの全体をここに載せておきます。

<?php

namespace Tests\Browser;

use Illuminate\Foundation\Testing\DatabaseMigrations;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;

class BookCreateTest extends DuskTestCase
{
    public function testCreateBookOK()
    {
        $this->browse(function (Browser $browser) {
            $browser->visit('/books/create') // 新規作成画面に遷移
                    ->type('title', 'タイトルテスト') // タイトルを入力する
                    ->type('author', '著者テスト')  // 著者を入力する
                    ->click('button') // 送信ボタンをクリック
                    ->assertPathIs('/books') // 一覧画面に遷移を確認
                    ->assertSee('タイトルテスト') // 「タイトルテスト」というテキストが含まれていること
                    ->assertSee('著者テスト'); // 「著者テスト」というテキストが含まれていること
        });
    }

    public function testEditBook()
    {
        $this->browse(function (Browser $browser) {
            $browser->visit('/books')
                ->clickLink('詳細')
                ->assertValue('input[name="title"]', 'タイトルテスト')
                ->assertValue('input[name="author"]', '著者テスト');
        });
    }

    public function testUpdateBook()
    {
        $this->browse(function (Browser $browser) {
            $browser->visit('/books')
                ->clickLink('詳細')
                ->type('title', 'タイトルテスト2')
                ->type('author', '著者テスト2')
                ->click('button')
                ->assertPathIs('/books')
                ->assertSee('タイトルテスト2')
                ->assertSee('著者テスト2');
        });
    }

    public function testCreateBookValidationError()
    {
        $this->browse(function (Browser $browser) {
            $browser->visit('/books/create')
                ->click('button')
                ->assertPathIs('/books/create')
                ->assertSee('タイトルは必ず指定してください。')
                ->assertSee('著者は必ず指定してください。');
        });
    }

    public function testDestroyCancel()
    {
        $this->browse(function (Browser $browser) {
            $browser->visit('/books')
                ->click('button')
                ->assertDialogOpened('本当に削除しますか?')
                ->dismissDialog()
                ->assertPathIs('/books')
                ->assertSee('タイトルテスト2')
                ->assertSee('著者テスト2');
        });
    }

    public function testDestroyBook()
    {
        $this->browse(function (Browser $browser) {
            $browser->visit('/books')
                ->click('button')
                ->assertDialogOpened('本当に削除しますか?')
                ->acceptDialog()
                ->assertPathIs('/books')
                ->assertDontSee('タイトルテスト2')
                ->assertDontSee('著者テスト2');
        });
    }
}

今回紹介した以外にも、様々なアクション・アサーションがあります。詳しくは公式ドキュメントを参照してください。
Laravel Dusk: https://laravel.com/docs/master/dusk

おすすめ書籍

カイザー

シェア
執筆者:
カイザー
タグ: phplaravel

最近の投稿

Goの抽象構文木でコードを解析する

はじめに Goでアプリケーショ…

2時間 前

フロントエンドで動画デコレーション&レンダリング

はじめに 今回は、以下のように…

4週間 前

Goのクエリビルダー goqu を使ってみる

はじめに 最近携わっているとあ…

1か月 前

【Xcode15】プライバシーマニフェスト対応に備えて

はじめに こんにちは、suzu…

2か月 前