0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

実務1年目駆け出しエンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(テスト・デバッグ編⑨)~E2Eテスト2[ゲスト]~

0
Last updated at Posted at 2026-08-30

実務1年目駆け出しエンジニアがLaravel&ReactでWebアプリケーション開発に挑戦してみた!(その51)

0. 初めに

こんにちは!
実務1年目のWeb開発エンジニアです。

このシリーズでは、Webアプリケーション開発をゼロから解説しています!

また、1か月ほどさぼってしまいました...
このシリーズは正直もうモチベーションがほとんど残っておらずw

でもキリがよいところまでは投稿するつもりです!

今日から本格的にE2Eテストのテストシナリオ作成に取り組んでいきたいと思います!

テストシナリオは、ユーザーの目線に立って作成します。

ログインしていない・会員登録していないユーザーを以降は、ゲスト、ログイン済みのユーザーを一般ユーザー、管理者IDでログインしているユーザーを管理者と呼び、それぞれの視点に立ったテストシナリオを作成していく予定です!

今日は、ゲストのテストシナリオから作成します!

1. ブランチ運用

developブランチを最新化して、そこから新規ブランチを切って作業をしましょう。

ブランチ名は、test/e2e/guestとかにしましょう。

前回使った、test/e2e/set-upブランチはもう不要なので削除しましょう。

2. ゲスト閲覧フロー

ゲストは、投稿や削除はできませんが、閲覧はすることができます。
それをテストするシナリオを作成します。

2.1 ホーム → 大学一覧 → 学部一覧 → 研究室一覧 → 研究室詳細 の順に遷移できる

e2eフォルダ直下に新規にファイルを作成しましょう。

\project-root\src\e2e\navigation.spec.js
import { test, expect } from '@playwright/test';

// このファイルはゲスト(未認証)状態で実行される画面遷移テスト
// シードデータ前提:
//   大学 id=1: テストA国立大学
//   学部 id=5: 工学部 (university_id=1)
//   研究室 id=1: 機械工学科 テストA研究室 (faculty_id=5)

test.describe('ナビゲーション: ホーム → 研究室詳細 までの遷移', () => {
  test('ホーム → 大学一覧 → 学部一覧 → 研究室一覧 → 研究室詳細 の順に遷移できる', async ({
    page,
  }) => {
    // 1. ホームページ
    await page.goto('/');
    await expect(page).toHaveURL('/');

    // 2. 大学名を検索して大学一覧へ
    const searchInput = page.getByPlaceholder('大学名を入力...');
    await expect(searchInput).toBeVisible({ timeout: 10000 });
    await searchInput.fill('テストA');
    await page.getByRole('button', { name: /検索/ }).click();
    await expect(page).toHaveURL(/\/universities\?query=/);

    // 3. テストA国立大学のリンクをクリックして学部一覧へ
    await page.getByText('テストA国立大学').first().click();
    await expect(page).toHaveURL(/\/universities\/1\/faculties/);

    // 4. 工学部をクリックして研究室一覧へ
    await expect(page.getByText('工学部')).toBeVisible({ timeout: 10000 });
    await page.getByText('工学部').click();
    await expect(page).toHaveURL(/\/faculties\/5\/labs/);

    // 5. 機械工学科 テストA研究室をクリックして研究室詳細へ
    await expect(page.getByText('機械工学科 テストA研究室')).toBeVisible({ timeout: 10000 });
    await page.getByText('機械工学科 テストA研究室').first().click();
    await expect(page).toHaveURL(/\/labs\/1/);
  });
});

初めてなので、少しくどいかもしれませんが一行ずつ見ていきましょう。

まずは、必要なモジュールをインポートします。

import { test, expect } from '@playwright/test';

次に以下のコメントアウトの部分です。

// このファイルはゲスト(未認証)状態で実行される画面遷移テスト
// シードデータ前提:
//   大学 id=1: テストA国立大学
//   学部 id=5: 工学部 (university_id=1)
//   研究室 id=1: 機械工学科 テストA研究室 (faculty_id=5)

ログインに関する処理(後述するので今は気にしなくてOK)がないので、ログアウト状態でのテストとなります。

シードデータに関してですが、これは今までの開発でも使ってきたデータです。

前回作った、テストを実行するたびに最初に呼び出されるコマンドによって、データがシーディングされます。

\project-root\src\e2e\global-setup.js
export default async function globalSetup() {
  // ↓これ
  // DB をリセットしてシードを実行
  console.log('\n[global-setup] Running migrate:fresh --seed ...');
  execSync('docker exec php-lab php artisan migrate:fresh --seed', {
    stdio: 'inherit',
  });

続いてtest.describeです。
これは、複数のテストシナリオをまとめて一つのグループにすることができます。

test.describe('ナビゲーション: ホーム → 研究室詳細 までの遷移', () => {
  // 色々書く...
});

第二引数のコールバック関数の中にテストを書きます。

今は一つしかありませんが、この後追加していきます。

最後にテストの中身を見てみましょう。

  test('ホーム → 大学一覧 → 学部一覧 → 研究室一覧 → 研究室詳細 の順に遷移できる', async ({
    page,
  }) => {
    // 1. ホームページ
    await page.goto('/');
    await expect(page).toHaveURL('/');

    // 2. 大学名を検索して大学一覧へ
    const searchInput = page.getByPlaceholder('大学名を入力...');
    await expect(searchInput).toBeVisible({ timeout: 10000 });
    await searchInput.fill('テストA');
    await page.getByRole('button', { name: /検索/ }).click();
    await expect(page).toHaveURL(/\/universities\?query=/);

    // 3. テストA国立大学のリンクをクリックして学部一覧へ
    await page.getByText('テストA国立大学').first().click();
    await expect(page).toHaveURL(/\/universities\/1\/faculties/);

    // 4. 工学部をクリックして研究室一覧へ
    await expect(page.getByText('工学部')).toBeVisible({ timeout: 10000 });
    await page.getByText('工学部').click();
    await expect(page).toHaveURL(/\/faculties\/5\/labs/);

    // 5. 機械工学科 テストA研究室をクリックして研究室詳細へ
    await expect(page.getByText('機械工学科 テストA研究室')).toBeVisible({ timeout: 10000 });
    await page.getByText('機械工学科 テストA研究室').first().click();
    await expect(page).toHaveURL(/\/labs\/1/);
  });

ブラウザのサーバーとのやり取りは非同期処理になるので、例のasync/awaitを付けます。

引数に見えるpageは、Fixturesと言って開発者が意図的に渡してあげる必要がなくテストに必要なものが自動的に受け取られる仕組みの一つです。

以下のように使います。

    // 1. ホームページ
    await page.goto('/');
    await expect(page).toHaveURL('/');

page.goto()で指定したURLのページを開きます。

expect(page).toHaveURL()でそのページのURLが正しいものであることを期待します。

PHPUnitのアサーションに相当する部分です。

まあ、'/'が開けないWebサイトってあまりないかもしれませんが、念のためテストしています。

続きを見てみましょう。

    // 2. 大学名を検索して大学一覧へ
    const searchInput = page.getByPlaceholder('大学名を入力...');
    await expect(searchInput).toBeVisible({ timeout: 10000 });
    await searchInput.fill('テストA');
    await page.getByRole('button', { name: /検索/ }).click();
    await expect(page).toHaveURL(/\/universities\?query=/);

page.getByPlaceholderは要素をプレースホルダで取得できます。
厳密にはこの段階では描画されたHTMLを取得しているのではなく、あくまで「プレースホルダに'大学名を入力...'と書かれている要素を対象にします」という宣言にすぎません。

そして、await expect(searchInput).toBeVisible({ timeout: 10000 });でそれが実際に描画されるかをテストしているということになります。

何かしらの理由で描画に時間がかかる可能性も考慮して、10000ms許容して、その時間待っても描画が確認できなければ失敗という判定にします。

その後、await searchInput.fill()で文字を入力します。

さらに、page.getByRole('button', { name: /検索/ })で要素の役割(今回は'button')で取得し、.click()をつなげることでそれをクリックします。

最終的にexpect(page).toHaveURL()でページ遷移が正しく行われるかを確認します。

要するにこの画面で検索文字列を入力してページ遷移できるかをテストしているということです。

image.png

image.png

ご覧の通り、遷移先のページには大学カード一覧が並んでいますので、それを文字で取得してクリックし、次のページに進みます。

    // 3. テストA国立大学のリンクをクリックして学部一覧へ
    await page.getByText('テストA国立大学').first().click();
    await expect(page).toHaveURL(/\/universities\/1\/faculties/);

残りも同様にして最終的に研究室の詳細画面まで移動します。
image.png

書けたら実行してみましょう。

実行コマンド

/project-root/src
$ npx playwright test e2e/navigation.spec.js

実行結果
image.png

テスト自体は問題ないです!
以下は、今後取り組むうえでのコラム的なものを用意しましたので是非読んでみてください。

実行が失敗したら...

クリックで読む...

テストが通らないときもあります。
僕もこの記事を書きながら何度かトライ&エラーを繰り返しました。(笑)
image.png

手っ取り早いのは、コンソールに出力されるエラー文を見ることです。
image.png

また、結果の画面の「npx playwright show-report」を「Ctrl」+クリックすると、レポート画面が表示されます(おそらく、http://localhost:9323/で表示されるはずです)。

ここではいろいろな情報を得ることができます。
image.png

スクリーンショットはもちろん、動画まで見られます...!!
image.png

これにより、どこで失敗したのかがわかります。

ぜひ活用してみてください!

Playwrightの使い方で迷ったら...

クリックで読む...

Playwrightの書き方や実行の仕方が分からない場合は、ドキュメントを読んでみましょう。
https://playwright.dev/docs/intro

僕も正直あまり自信がないので、読みながらこの記事を書いています...w

手動でテストしたほうが速くない?

クリックで読む... こんなにあれこれ準備して、テストシナリオをコードで書いて、実行してエラーが起きて、直して...って苦労するくらいなら、手動でブラウザを動かしたほうが速いのではないかという疑問がわく人もいるかなと思います。

確かにテストの自動化を導入するには時間がかかりますが、その分の恩恵はあります!

それは、2回目以降のテストです。

テストは1回実行してPASSしたらそれでもう二度とテストしないということはありません。

新機能の追加やバグの修正等で、ソースコードに変更を加えた際に、これまで動いていた機能に影響が及び最悪の場合別のバグが生まれてしまうことがあります。

これをデグレ(デグレードの略)と呼んだりします。

そのため、新機能を追加したりバグを修正したりして、それをお客さんにリリースする前などにしっかりと既存の機能も問題がないかテストすることが重要になります。

そこで、自動化ツールの出番というわけです。

確かに、準備するのには時間がかかりますが、一度作ってしまえば2回目以降はコマンド実行して、人間はコーヒーでも飲んで待っているだけでテストが完了します。

しかも自動実行ですから、テスト漏れなどの人間特有のミスも防げます

これが、「手動でやった方が速くない?」に対する回答です!

正規表現について

クリックで読む... 先ほどお見せしたコード内でスルーしていましたが、**正規表現** というものを使用していました。
/\/universities\/1\/faculties/ // ←こういうの

ドミノみたいに見えるこれですが、一応ちゃんと意味があります。(笑)

もしかしたら、このシリーズを通して何度か登場したかもしれません。
もし、初耳という方は調べてみてください!

https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Regular_expressions

2.2 研究室詳細ページに7項目の評価が表示される

続いて、詳細画面に移動して評価項目の表示を確認しましょう。

先ほどのtest.describe()の中に追加します。

\project-root\src\e2e\navigation.spec.js
test.describe('ナビゲーション: ホーム → 研究室詳細 までの遷移', () => {
  test('ホーム → 大学一覧 → 学部一覧 → 研究室一覧 → 研究室詳細 の順に遷移できる', async ({
    page,
  }) => 
  // ...
  });

  // ↓追加
  test('研究室詳細ページに7項目の評価が表示される', async ({ page }) => {
    await page.goto('/labs/1');

    // 7つの評価項目ラベルがすべて存在すること
    const ratingLabels = [
      '指導スタイル',
      '雰囲気・文化',
      '成果・活動',
      '拘束度',
      '設備',
      '働き方',
      '人数バランス',
    ];

    for (const label of ratingLabels) {
      await expect(page.getByText(label).first()).toBeVisible({ timeout: 10000 });
    }
  });
});

実行コマンド

/project-root/src
$ npx playwright test e2e/navigation.spec.js

実行結果
image.png

2.3 研究室詳細のコメントセクションがゲストでも表示される

続いてコメントです。

同様に追加です。

\project-root\src\e2e\navigation.spec.js
test.describe('ナビゲーション: ホーム → 研究室詳細 までの遷移', () => {
  test('ホーム → 大学一覧 → 学部一覧 → 研究室一覧 → 研究室詳細 の順に遷移できる', async ({
    page,
  }) => {
    // ...
  });

  test('研究室詳細ページに7項目の評価が表示される', async ({ page }) => {
    // ...
  });

  test('研究室詳細のコメントセクションがゲストでも表示される', async ({ page }) => {
    await page.goto('/labs/1');

    // 「もっと見る...」をクリックしてコメント一覧モーダルを開く
    await page.getByText('もっと見る...').click();

    // コメント一覧モーダルが表示されること
    await expect(page.getByRole('heading', { name: /コメント一覧/ })).toBeVisible({ timeout: 10000 });
  });
});

実行コマンド

/project-root/src
$ npx playwright test e2e/navigation.spec.js

実行結果
image.png

2.4 存在しない研究室IDにアクセスするとエラーページが表示される

デバッグ

現在、http://localhost/labs/999999などの用意していないページにアクセスすると、Laravelで最初から用意されているページが表示されます。
image.png

このテストシナリオを作っていて気が付きました!

  • resources/js/Pages/Error.jsx
    pagesでぃてくとり直下にError.jsxというファイルを新しく作ります。
\project-root\src\resources\js\Pages\Error.jsx
import { Link } from '@inertiajs/react';
import AppLayout from '@/Layouts/AppLayout';

const ERROR_MESSAGES = {
  400: { title: '不正なリクエスト',     description: 'リクエストが正しくありません。' },
  401: { title: '認証エラー',           description: 'このページを表示するにはログインが必要です。' },
  403: { title: 'アクセス禁止',         description: 'このページへのアクセス権限がありません。' },
  404: { title: 'ページが見つかりません', description: 'お探しのページは存在しないか、移動された可能性があります。' },
  500: { title: 'サーバーエラー',       description: 'サーバー内部でエラーが発生しました。しばらく時間をおいてから再度お試しください。' },
  503: { title: 'サービス利用不可',     description: 'ただいまメンテナンス中です。しばらくお待ちください。' },
};

const Error = ({ status }) => {
  const { title, description } = ERROR_MESSAGES[status] ?? {
    title: 'エラーが発生しました',
    description: '予期せぬエラーが発生しました。',
  };

  return (
    <AppLayout title={`${status} - ${title}`}>
      <div className="flex flex-col items-center justify-center min-h-[60vh] text-center gap-4">
        <p className="text-8xl font-semibold text-[#747D8C]">{status}</p>
        <h1 className="text-2xl font-semibold text-[#747D8C]">{title}</h1>
        <p className="text-[#747D8C]">{description}</p>
        <Link
          href="/"
          className="text-[#747D8C] font-medium"
        >
          ホームへ戻る
        </Link>
      </div>
    </AppLayout>
  );
};

export default Error;

  • bootstrap/app.php
    これを使えるように設定します。
\project-root\src\bootstrap\app.php
<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->web(append: [
            \App\Http\Middleware\HandleInertiaRequests::class,
            \Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
        ]);

        // 未認証ユーザーはホームページにリダイレクト(モーダルでログイン)
        $middleware->redirectGuestsTo('/');
    })
    // ↓追加
    ->withExceptions(function (Exceptions $exceptions) {
        $exceptions->respond(function (\Symfony\Component\HttpFoundation\Response $response) {
            if (in_array($response->getStatusCode(), [400, 401, 403, 404, 500, 503])
                && ! app()->runningInConsole()
            ) {
                return \Inertia\Inertia::render('Error', ['status' => $response->getStatusCode()])
                    ->toResponse(request())
                    ->setStatusCode($response->getStatusCode());
            }
            return $response;
        });
    })->create();

  • resources/js/Components/Common/FlashToast.jsx
    エラーページには、フラッシュメッセージが届かずエラーになるので、修正します。
\project-root\src\resources\js\Components\Common\FlashToast.jsx
import { usePage } from '@inertiajs/react';
import { useEffect } from 'react';
import { toast } from 'sonner';

const FlashToast = () => {
  const { flash } = usePage().props;

  useEffect(() => {
    if (flash?.success) { // ←オプショナルチェーンでundefinedを許容する
      toast.success(flash.success);
    }
    if (flash?.error) { // ← ここも
      toast.error(flash.error);
    }
  }, [flash?.success, flash?.error]); // ← ここも

  return null; // 描画はsonnerの<Toaster />が担当
};

export default FlashToast;

こんな感じです。
image.png

作成

では、テストシナリオの作成に戻ります。

\project-root\src\e2e\navigation.spec.js
import { test, expect } from '@playwright/test';

// このファイルはゲスト(未認証)状態で実行される画面遷移テスト
// シードデータ前提:
//   大学 id=1: テストA国立大学
//   学部 id=5: 工学部 (university_id=1)
//   研究室 id=1: 機械工学科 テストA研究室 (faculty_id=5)

test.describe('ナビゲーション: ホーム → 研究室詳細 までの遷移', () => {
  test('ホーム → 大学一覧 → 学部一覧 → 研究室一覧 → 研究室詳細 の順に遷移できる', async ({
    page,
  }) => {
  // ...
  });

  test('研究室詳細ページに7項目の評価が表示される', async ({ page }) => {
  // ...
  });

  test('研究室詳細のコメントセクションがゲストでも表示される', async ({ page }) => {
  // ...
  });

  // ↓追加
  test('存在しない研究室IDにアクセスするとエラーページが表示される', async ({ page }) => {
    const response = await page.goto('/labs/999999');

    // 404 レスポンス、またはエラーを示すコンテンツが表示されること
    const status = response?.status();
    const is404 = status === 404;
    const hasErrorText = await page.getByText(/404|ページが見つかりません/).isVisible().catch(() => false);

    expect(is404 || hasErrorText).toBeTruthy();
  });
});

toBeTruthyで両方ともTruthyであることを確認します。

「Truthyって何?」って人はこちらをどうぞ。
https://developer.mozilla.org/ja/docs/Glossary/Truthy

実行

ソースコードを修正したので、再ビルドしておきましょう。

実行コマンド

/var/www
$ npm run build 
/project-root/src
$ npx playwright test e2e/navigation.spec.js

実行結果
image.png

2.5 〇〇の変更履歴ページが表示される

\project-root\src\e2e\navigation.spec.js
import { test, expect } from '@playwright/test';

// このファイルはゲスト(未認証)状態で実行される画面遷移テスト
// シードデータ前提:
//   大学 id=1: テストA国立大学
//   学部 id=5: 工学部 (university_id=1)
//   研究室 id=1: 機械工学科 テストA研究室 (faculty_id=5)

test.describe('ナビゲーション: ホーム → 研究室詳細 までの遷移', () => {
  test('ホーム → 大学一覧 → 学部一覧 → 研究室一覧 → 研究室詳細 の順に遷移できる', async ({
    page,
  }) => {
    // 1. ホームページ
    await page.goto('/');
    await expect(page).toHaveURL('/');

    // 2. 大学名を検索して大学一覧へ
    const searchInput = page.getByPlaceholder('大学名を入力...');
    await expect(searchInput).toBeVisible({ timeout: 10000 });
    await searchInput.fill('テストA');
    await page.getByRole('button', { name: /検索/ }).click();
    await expect(page).toHaveURL(/\/universities\?query=/);

    // 3. テストA国立大学のリンクをクリックして学部一覧へ
    await page.getByText('テストA国立大学').first().click();
    await expect(page).toHaveURL(/\/universities\/1\/faculties/);

    // 4. 工学部をクリックして研究室一覧へ
    await expect(page.getByText('工学部')).toBeVisible({ timeout: 10000 });
    await page.getByText('工学部').click();
    await expect(page).toHaveURL(/\/faculties\/5\/labs/);

    // 5. 機械工学科 テストA研究室をクリックして研究室詳細へ
    await expect(page.getByText('機械工学科 テストA研究室')).toBeVisible({ timeout: 10000 });
    await page.getByText('機械工学科 テストA研究室').first().click();
    await expect(page).toHaveURL(/\/labs\/1/);
  });

  test('研究室詳細ページに7項目の評価が表示される', async ({ page }) => {
    await page.goto('/labs/1');

    // 7つの評価項目ラベルがすべて存在すること
    const ratingLabels = [
      '指導スタイル',
      '雰囲気・文化',
      '成果・活動',
      '拘束度',
      '設備',
      '働き方',
      '人数バランス',
    ];

    for (const label of ratingLabels) {
      await expect(page.getByText(label).first()).toBeVisible({ timeout: 10000 });
    }
  });

  test('研究室詳細のコメントセクションがゲストでも表示される', async ({ page }) => {
    await page.goto('/labs/1');

    // 「もっと見る...」をクリックしてコメント一覧モーダルを開く
    await page.getByText('もっと見る...').click();

    // コメント一覧モーダルが表示されること
    await expect(page.getByRole('heading', { name: /コメント一覧/ })).toBeVisible({ timeout: 10000 });
  });

  test('存在しない研究室IDにアクセスするとエラーページが表示される', async ({ page }) => {
    const response = await page.goto('/labs/999999');

    // 404 レスポンス、またはエラーを示すコンテンツが表示されること
    const status = response?.status();
    const is404 = status === 404;
    const hasErrorText = await page.getByText(/404|ページが見つかりません/).isVisible().catch(() => false);

    expect(is404 || hasErrorText).toBeTruthy();
  });

  // ↓3つ追加
  test('大学の変更履歴ページが表示される', async ({ page }) => {
    await page.goto('/universities/1/history');

    // 履歴ページがレンダリングされること(タイトルまたは履歴に関するテキスト)
    await expect(page.getByText('編集履歴').first()).toBeVisible({ timeout: 10000 });

    // 「大学に戻る」ボタンで学部一覧ページへ戻れること
    await page.getByRole('button', { name: '< 大学に戻る' }).click();
    await expect(page).toHaveURL(/\/universities\/1\/faculties/);
  });

  test('学部の変更履歴ページが表示される', async ({ page }) => {
    await page.goto('/faculties/5/history');

    await expect(page.getByText('編集履歴').first()).toBeVisible({ timeout: 10000 });

    // 「学部に戻る」ボタンで学部一覧ページへ戻れること
    await page.getByRole('button', { name: '< 学部に戻る' }).click();
    await expect(page).toHaveURL(/\/universities\/1\/faculties/);
  });

  test('研究室の変更履歴ページが表示される', async ({ page }) => {
    await page.goto('/labs/1/history');

    await expect(page.getByText('編集履歴').first()).toBeVisible({ timeout: 10000 });

    // 「研究室に戻る」ボタンで研究室詳細ページへ戻れること
    await page.getByRole('button', { name: '< 研究室に戻る' }).click();
    await expect(page).toHaveURL(/\/labs\/1/);
  });
});

実行コマンド

/project-root/src
$ npx playwright test e2e/navigation.spec.js

実行結果
image.png

ここまでできたらいったんコミットしておきましょう。

3. 認証

続いて、ログインをしてみましょう。

3.1 サイドバーの「ログイン」ボタンでモーダルが開く

\project-root\src\e2e\auth.spec.js
import { test, expect } from '@playwright/test';

test.describe('認証: ログインモーダル', () => {
  test('サイドバーの「ログイン」ボタンでモーダルが開く', async ({ page }) => {
    await page.goto('/');

    // ハンバーガーボタンでサイドバーを開く
    await page.getByRole('button', { name: 'メニューを開く' }).click();
    await expect(page.locator('aside[role="dialog"]')).toBeVisible({ timeout: 10000 });

    // サイドバー内の「ログイン」ボタンをクリック
    await page.locator('aside[role="dialog"]').getByRole('button', { name: 'ログイン' }).click();

    // ログインモーダルのフォームが表示されること
    await expect(page.getByPlaceholder('メールアドレス')).toBeVisible({ timeout: 10000 });
    await expect(page.getByPlaceholder('パスワード')).toBeVisible();
  });
});

aria-labelでハンバーガーアイコンを取得しているのがポイントです。

\project-root\src\resources\js\Components\HamburgerMenu.jsx
const HamburgerMenu = ({ onOpenSidebar }) => {
  return (
    <button
      onClick={onOpenSidebar}
      className="ml-auto flex items-center"
      aria-label="メニューを開く" // ← これを利用
    >
      <img
        src={hamburgerIcon}
        alt="メニュー"
        className="h-7 w-7 hover:opacity-80 transition"
      />
    </button>
  );
}

実行コマンド

/project-root/src
$ npx playwright test e2e/auth.spec.js

実行結果
image.png

3.2 正しい認証情報でログインするとモーダルが閉じて同じページに留まる

まずは、ログインモーダルを開く処理の繰り返しを避けるために/e2e/helpers/ヘルパー関数を定義したファイルを作りました。

\project-root\src\e2e\helpers\login-modal.js
import { expect } from '@playwright/test';

/** サイドバーを開いてログインモーダルを表示するヘルパー */
export async function openLoginModal(page) {
  await page.getByRole('button', { name: 'メニューを開く' }).click();
  await page.locator('aside[role="dialog"]').getByRole('button', { name: 'ログイン' }).click();
  // モーダルのメールアドレス入力欄が表示されるまで待つ
  await expect(page.getByPlaceholder('メールアドレス')).toBeVisible({ timeout: 10000 });
}

これを呼び出して使ってみます。

import { test, expect } from '@playwright/test';
import { openLoginModal } from './helpers/login-modal.js'; // ←追加

// このファイルはゲスト(未認証)状態をベースに認証フローをテストする
// ログインはサイドバー → ログインモーダル経由で行う

test.describe('認証: ログインモーダル', () => {
  test('サイドバーの「ログイン」ボタンでモーダルが開く', async ({ page }) => {
    await page.goto('/');

    // ハンバーガーボタンでサイドバーを開く
    await page.getByRole('button', { name: 'メニューを開く' }).click();
    await expect(page.locator('aside[role="dialog"]')).toBeVisible({ timeout: 10000 });

    // サイドバー内の「ログイン」ボタンをクリック
    await page.locator('aside[role="dialog"]').getByRole('button', { name: 'ログイン' }).click();

    // ログインモーダルのフォームが表示されること
    await expect(page.getByPlaceholder('メールアドレス')).toBeVisible({ timeout: 10000 });
    await expect(page.getByPlaceholder('パスワード')).toBeVisible();
  });

  // ↓追加
  test('正しい認証情報でログインするとモーダルが閉じて同じページに留まる', async ({ page }) => {
    await page.goto('/');
    await openLoginModal(page);

    await page.getByPlaceholder('メールアドレス').fill('user@example.com');
    await page.getByPlaceholder('パスワード').fill('password');
    await page.locator('form:has([placeholder="メールアドレス"]) button[type="submit"]').click();

    // モーダルが閉じること(入力欄が非表示になる)
    await expect(page.getByPlaceholder('メールアドレス')).not.toBeVisible({ timeout: 10000 });

    // URL が / のまま留まること
    await expect(page).toHaveURL('/');

    // サイドバーを再度開くとログイン済み状態(ログアウトボタン)が表示されること
    await page.getByRole('button', { name: 'メニューを開く' }).click();
    await expect(
      page.locator('aside[role="dialog"]').getByRole('button', { name: 'ログアウト' })
    ).toBeVisible({ timeout: 10000 });
  });
});

button[type="submit"]だけだと、検索ボタンと区別がつかなくてエラーになるので、メールアドレスをプレースホルダに持つボタンに絞ります。

実行コマンド

/project-root/src
$ npx playwright test e2e/auth.spec.js

実行結果
image.png

3.3 '誤ったパスワードでログインするとエラーメッセージが表示される

import { test, expect } from '@playwright/test';
import { openLoginModal } from './helpers/login-modal.js';

// このファイルはゲスト(未認証)状態をベースに認証フローをテストする
// ログインはサイドバー → ログインモーダル経由で行う

test.describe('認証: ログインモーダル', () => {
  test('サイドバーの「ログイン」ボタンでモーダルが開く', async ({ page }) => {
    await page.goto('/');

    // ハンバーガーボタンでサイドバーを開く
    await page.getByRole('button', { name: 'メニューを開く' }).click();
    await expect(page.locator('aside[role="dialog"]')).toBeVisible({ timeout: 10000 });

    // サイドバー内の「ログイン」ボタンをクリック
    await page.locator('aside[role="dialog"]').getByRole('button', { name: 'ログイン' }).click();

    // ログインモーダルのフォームが表示されること
    await expect(page.getByPlaceholder('メールアドレス')).toBeVisible({ timeout: 10000 });
    await expect(page.getByPlaceholder('パスワード')).toBeVisible();
  });

  test('正しい認証情報でログインするとモーダルが閉じて同じページに留まる', async ({ page }) => {
    await page.goto('/');
    await openLoginModal(page);

    await page.getByPlaceholder('メールアドレス').fill('user@example.com');
    await page.getByPlaceholder('パスワード').fill('password');
    await page.locator('form:has([placeholder="メールアドレス"]) button[type="submit"]').click();

    // モーダルが閉じること(入力欄が非表示になる)
    await expect(page.getByPlaceholder('メールアドレス')).not.toBeVisible({ timeout: 10000 });

    // URL が / のまま留まること
    await expect(page).toHaveURL('/');

    // サイドバーを再度開くとログイン済み状態(ログアウトボタン)が表示されること
    await page.getByRole('button', { name: 'メニューを開く' }).click();
    await expect(
      page.locator('aside[role="dialog"]').getByRole('button', { name: 'ログアウト' })
    ).toBeVisible({ timeout: 10000 });
  });

  test('誤ったパスワードでログインするとエラーメッセージが表示される', async ({ page }) => {
    await page.goto('/');
    await openLoginModal(page);

    await page.getByPlaceholder('メールアドレス').fill('user@example.com');
    await page.getByPlaceholder('パスワード').fill('wrongpassword');
    await page.locator('form:has([placeholder="メールアドレス"]) button[type="submit"]').click();

    // モーダルが開いたまま、エラーメッセージが表示されること
    await expect(page.getByPlaceholder('メールアドレス')).toBeVisible({ timeout: 10000 });
    await expect(page.getByText('メールアドレスまたはパスワードが正しくありません。')).toBeVisible({ timeout: 10000 });
  });
});

実行コマンド

/project-root/src
$ npx playwright test e2e/auth.spec.js

実行結果
image.png

3.4 サイドバーの「ログアウト」でログアウトするとゲスト状態に戻る

\project-root\src\e2e\auth.spec.js
import { test, expect } from '@playwright/test';
import { AUTH } from './helpers/auth-paths.js'; // 追加
import { openLoginModal } from './helpers/login-modal.js';

// このファイルはゲスト(未認証)状態をベースに認証フローをテストする
// ログインはサイドバー → ログインモーダル経由で行う

test.describe('認証: ログインモーダル', () => {
  test('サイドバーの「ログイン」ボタンでモーダルが開く', async ({ page }) => {
    await page.goto('/');

    // ハンバーガーボタンでサイドバーを開く
    await page.getByRole('button', { name: 'メニューを開く' }).click();
    await expect(page.locator('aside[role="dialog"]')).toBeVisible({ timeout: 10000 });

    // サイドバー内の「ログイン」ボタンをクリック
    await page.locator('aside[role="dialog"]').getByRole('button', { name: 'ログイン' }).click();

    // ログインモーダルのフォームが表示されること
    await expect(page.getByPlaceholder('メールアドレス')).toBeVisible({ timeout: 10000 });
    await expect(page.getByPlaceholder('パスワード')).toBeVisible();
  });

  test('正しい認証情報でログインするとモーダルが閉じて同じページに留まる', async ({ page }) => {
    await page.goto('/');
    await openLoginModal(page);

    await page.getByPlaceholder('メールアドレス').fill('user@example.com');
    await page.getByPlaceholder('パスワード').fill('password');
    await page.locator('form:has([placeholder="メールアドレス"]) button[type="submit"]').click();

    // モーダルが閉じること(入力欄が非表示になる)
    await expect(page.getByPlaceholder('メールアドレス')).not.toBeVisible({ timeout: 10000 });

    // URL が / のまま留まること
    await expect(page).toHaveURL('/');

    // サイドバーを再度開くとログイン済み状態(ログアウトボタン)が表示されること
    await page.getByRole('button', { name: 'メニューを開く' }).click();
    await expect(
      page.locator('aside[role="dialog"]').getByRole('button', { name: 'ログアウト' })
    ).toBeVisible({ timeout: 10000 });
  });

  test('誤ったパスワードでログインするとエラーメッセージが表示される', async ({ page }) => {
    await page.goto('/');
    await openLoginModal(page);

    await page.getByPlaceholder('メールアドレス').fill('user@example.com');
    await page.getByPlaceholder('パスワード').fill('wrongpassword');
    await page.locator('form:has([placeholder="メールアドレス"]) button[type="submit"]').click();

    // モーダルが開いたまま、エラーメッセージが表示されること
    await expect(page.getByPlaceholder('メールアドレス')).toBeVisible({ timeout: 10000 });
    await expect(page.getByText('メールアドレスまたはパスワードが正しくありません。')).toBeVisible({ timeout: 10000 });
  });

// 追加
test.describe('認証: ログアウト', () => {
  test.use({ storageState: AUTH.user });

  test('サイドバーの「ログアウト」でログアウトするとゲスト状態に戻る', async ({ page }) => {
    await page.goto('/');

    // ログイン済み状態でサイドバーを開く
    await page.getByRole('button', { name: 'メニューを開く' }).click();
    const logoutButton = page.locator('aside[role="dialog"]').getByRole('button', { name: 'ログアウト' });
    await expect(logoutButton).toBeVisible({ timeout: 10000 });
    await logoutButton.click();

    // ログアウト後にサイドバーを再度開くとゲスト状態(ログインボタン)が表示されること
    await page.getByRole('button', { name: 'メニューを開く' }).click();
    await expect(
      page.locator('aside[role="dialog"]').getByRole('button', { name: 'ログイン' })
    ).toBeVisible({ timeout: 10000 });
  });
});

test.use({ storageState: AUTH.user });でログイン状態を作り出しています。
前回作成したものを利用しています。

\project-root\src\e2e\helpers\auth-paths.js
export const AUTH = {
    /** 管理者 (admin@example.com) */
    admin: './e2e/.auth/admin.json',
    /** 一般ユーザー (user@example.com) */
    user: './e2e/.auth/user.json',
};

実行時に以下で設定されたファイル(例: /e2e/.auth/user.json)に格納されます。

\project-root\src\e2e\global-setup.js
/** ユーザー種別ごとの認証情報 */
const USERS = {
  admin: {
    email: 'admin@example.com',
    password: 'password',
    authFile: path.join(AUTH_DIR, 'admin.json'),
  },
  user: {
    email: 'user@example.com',
    password: 'password',
    authFile: path.join(AUTH_DIR, 'user.json'),
  },
};

実行コマンド

/project-root/src
$ npx playwright test e2e/auth.spec.js

実行結果
image.png

3.5 未認証アクセスのリダイレクト

\project-root\src\e2e\auth.spec.js
import { test, expect } from '@playwright/test';
import { AUTH } from './helpers/auth-paths.js';
import { openLoginModal } from './helpers/login-modal.js';

// このファイルはゲスト(未認証)状態をベースに認証フローをテストする
// ログインはサイドバー → ログインモーダル経由で行う

test.describe('認証: ログインモーダル', () => {
  test('サイドバーの「ログイン」ボタンでモーダルが開く', async ({ page }) => {
    await page.goto('/');

    // ハンバーガーボタンでサイドバーを開く
    await page.getByRole('button', { name: 'メニューを開く' }).click();
    await expect(page.locator('aside[role="dialog"]')).toBeVisible({ timeout: 10000 });

    // サイドバー内の「ログイン」ボタンをクリック
    await page.locator('aside[role="dialog"]').getByRole('button', { name: 'ログイン' }).click();

    // ログインモーダルのフォームが表示されること
    await expect(page.getByPlaceholder('メールアドレス')).toBeVisible({ timeout: 10000 });
    await expect(page.getByPlaceholder('パスワード')).toBeVisible();
  });

  test('正しい認証情報でログインするとモーダルが閉じて同じページに留まる', async ({ page }) => {
    await page.goto('/');
    await openLoginModal(page);

    await page.getByPlaceholder('メールアドレス').fill('user@example.com');
    await page.getByPlaceholder('パスワード').fill('password');
    await page.locator('form:has([placeholder="メールアドレス"]) button[type="submit"]').click();

    // モーダルが閉じること(入力欄が非表示になる)
    await expect(page.getByPlaceholder('メールアドレス')).not.toBeVisible({ timeout: 10000 });

    // URL が / のまま留まること
    await expect(page).toHaveURL('/');

    // サイドバーを再度開くとログイン済み状態(ログアウトボタン)が表示されること
    await page.getByRole('button', { name: 'メニューを開く' }).click();
    await expect(
      page.locator('aside[role="dialog"]').getByRole('button', { name: 'ログアウト' })
    ).toBeVisible({ timeout: 10000 });
  });

  test('誤ったパスワードでログインするとエラーメッセージが表示される', async ({ page }) => {
    await page.goto('/');
    await openLoginModal(page);

    await page.getByPlaceholder('メールアドレス').fill('user@example.com');
    await page.getByPlaceholder('パスワード').fill('wrongpassword');
    await page.locator('form:has([placeholder="メールアドレス"]) button[type="submit"]').click();

    // モーダルが開いたまま、エラーメッセージが表示されること
    await expect(page.getByPlaceholder('メールアドレス')).toBeVisible({ timeout: 10000 });
    await expect(page.getByText('メールアドレスまたはパスワードが正しくありません。')).toBeVisible({ timeout: 10000 });
  });
});

test.describe('認証: ログアウト', () => {
  test.use({ storageState: AUTH.user });

  test('サイドバーの「ログアウト」でログアウトするとゲスト状態に戻る', async ({ page }) => {
    await page.goto('/');

    // ログイン済み状態でサイドバーを開く
    await page.getByRole('button', { name: 'メニューを開く' }).click();
    const logoutButton = page.locator('aside[role="dialog"]').getByRole('button', { name: 'ログアウト' });
    await expect(logoutButton).toBeVisible({ timeout: 10000 });
    await logoutButton.click();

    // ログアウト後にサイドバーを再度開くとゲスト状態(ログインボタン)が表示されること
    await page.getByRole('button', { name: 'メニューを開く' }).click();
    await expect(
      page.locator('aside[role="dialog"]').getByRole('button', { name: 'ログイン' })
    ).toBeVisible({ timeout: 10000 });
  });
});

test.describe('認証: 未認証アクセスのリダイレクト', () => {
  test('未認証で /mypage にアクセスすると / にリダイレクトされる', async ({ page }) => {
    await page.goto('/mypage');

    await expect(page).toHaveURL('/');
  });

  test('未認証で /notifications にアクセスすると / にリダイレクトされる', async ({ page }) => {
    await page.goto('/notifications');

    await expect(page).toHaveURL('/');
  });
});

実行コマンド

/project-root/src
$ npx playwright test e2e/auth.spec.js

実行結果
image.png

コミット・プッシュ、PR作成・マージ、ブランチ削除をお忘れなく!!

4. まとめ・次回予告

今日から本格的にPlaywrightを使ったE2Eテストのテストシナリオ作成・実行を開始しました!

その中でもゲストの視点に立ったテストシナリオを作りましたね。

次回は、ログイン後の一般ユーザーの視点に立って、テストケースを作成していきましょう!

これまでの記事一覧

☆要件定義・設計編

☆環境構築編

☆バックエンド実装編

☆フロントエンド実装編

☆テスト・デバッグ編

軽く宣伝

YouTubeを始めました(というか始めてました)。
内容としては、Webエンジニアの生活や稼げるようになるまでの成長記録などを発信していく予定です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?