実務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フォルダ直下に新規にファイルを作成しましょう。
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)がないので、ログアウト状態でのテストとなります。
シードデータに関してですが、これは今までの開発でも使ってきたデータです。
前回作った、テストを実行するたびに最初に呼び出されるコマンドによって、データがシーディングされます。
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()でページ遷移が正しく行われるかを確認します。
要するにこの画面で検索文字列を入力してページ遷移できるかをテストしているということです。
ご覧の通り、遷移先のページには大学カード一覧が並んでいますので、それを文字で取得してクリックし、次のページに進みます。
// 3. テストA国立大学のリンクをクリックして学部一覧へ
await page.getByText('テストA国立大学').first().click();
await expect(page).toHaveURL(/\/universities\/1\/faculties/);
書けたら実行してみましょう。
実行コマンド
$ npx playwright test e2e/navigation.spec.js
テスト自体は問題ないです!
以下は、今後取り組むうえでのコラム的なものを用意しましたので是非読んでみてください。
実行が失敗したら...
クリックで読む...
テストが通らないときもあります。
僕もこの記事を書きながら何度かトライ&エラーを繰り返しました。(笑)

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

また、結果の画面の「npx playwright show-report」を「Ctrl」+クリックすると、レポート画面が表示されます(おそらく、http://localhost:9323/で表示されるはずです)。
これにより、どこで失敗したのかがわかります。
ぜひ活用してみてください!
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()の中に追加します。
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 });
}
});
});
実行コマンド
$ npx playwright test e2e/navigation.spec.js
2.3 研究室詳細のコメントセクションがゲストでも表示される
続いてコメントです。
同様に追加です。
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 });
});
});
実行コマンド
$ npx playwright test e2e/navigation.spec.js
2.4 存在しない研究室IDにアクセスするとエラーページが表示される
デバッグ
現在、http://localhost/labs/999999などの用意していないページにアクセスすると、Laravelで最初から用意されているページが表示されます。

このテストシナリオを作っていて気が付きました!
-
resources/js/Pages/Error.jsx
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
これを使えるように設定します。
<?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
エラーページには、フラッシュメッセージが届かずエラーになるので、修正します。
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;
作成
では、テストシナリオの作成に戻ります。
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
実行
ソースコードを修正したので、再ビルドしておきましょう。
実行コマンド
$ npm run build
$ npx playwright test e2e/navigation.spec.js
2.5 〇〇の変更履歴ページが表示される
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/);
});
});
実行コマンド
$ npx playwright test e2e/navigation.spec.js
ここまでできたらいったんコミットしておきましょう。
3. 認証
続いて、ログインをしてみましょう。
3.1 サイドバーの「ログイン」ボタンでモーダルが開く
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でハンバーガーアイコンを取得しているのがポイントです。
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>
);
}
実行コマンド
$ npx playwright test e2e/auth.spec.js
3.2 正しい認証情報でログインするとモーダルが閉じて同じページに留まる
まずは、ログインモーダルを開く処理の繰り返しを避けるために/e2e/helpers/ヘルパー関数を定義したファイルを作りました。
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"]だけだと、検索ボタンと区別がつかなくてエラーになるので、メールアドレスをプレースホルダに持つボタンに絞ります。
実行コマンド
$ npx playwright test e2e/auth.spec.js
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 });
});
});
実行コマンド
$ npx playwright test e2e/auth.spec.js
3.4 サイドバーの「ログアウト」でログアウトするとゲスト状態に戻る
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 });でログイン状態を作り出しています。
前回作成したものを利用しています。
export const AUTH = {
/** 管理者 (admin@example.com) */
admin: './e2e/.auth/admin.json',
/** 一般ユーザー (user@example.com) */
user: './e2e/.auth/user.json',
};
実行時に以下で設定されたファイル(例: /e2e/.auth/user.json)に格納されます。
/** ユーザー種別ごとの認証情報 */
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'),
},
};
実行コマンド
$ npx playwright test e2e/auth.spec.js
3.5 未認証アクセスのリダイレクト
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('/');
});
});
実行コマンド
$ npx playwright test e2e/auth.spec.js
コミット・プッシュ、PR作成・マージ、ブランチ削除をお忘れなく!!
4. まとめ・次回予告
今日から本格的にPlaywrightを使ったE2Eテストのテストシナリオ作成・実行を開始しました!
その中でもゲストの視点に立ったテストシナリオを作りましたね。
次回は、ログイン後の一般ユーザーの視点に立って、テストケースを作成していきましょう!
これまでの記事一覧
☆要件定義・設計編
☆環境構築編
☆バックエンド実装編
- その7: バックエンド実装編① ~認証機能作成~
- その8: バックエンド実装編②前編 ~レビュー投稿機能作成~
- その8.5: バックエンド実装編②後編 ~レビュー投稿機能作成~
- その9: バックエンド実装編③ ~レビューCRUD機能作成~
- その10: バックエンド実装編④ ~レビューCRUD機能作成その2~
- その11: バックエンド実装編⑤ ~新規大学・学部・研究室作成機能作成~
- その12: バックエンド実装編⑥ ~大学検索機能作成~
- その13: バックエンド実装編⑦ ~大学・学部・研究室編集機能作成~
- その14: バックエンド実装編⑧ ~コメント投稿機能~
- その15: バックエンド実装編⑨ ~コメント編集・削除機能~
- その16: バックエンド実装編⑩ ~ブックマーク機能~
- その17: バックエンド実装編⑪ ~排他制御・トランザクション処理~
- その18: バックエンド実装編⑫ ~マイページ機能作成~
- その19: バックエンド実装編⑬ ~管理者アカウント機能作成~
- その20: バックエンド実装編⑭ ~通知機能作成~
- その21: バックエンド実装編⑮ ~ソーシャルログイン機能作成~
☆フロントエンド実装編
- その22: フロントエンド実装編① ~メインコンテンツ領域作成~
- その23: フロントエンド実装編② ~ヘッダー作成~
- その24: フロントエンド実装編③ ~サイドバー作成~
- その25: フロントエンド実装編④ ~ホームページ作成~
- その26: フロントエンド実装編⑤ ~大学検索結果画面作成~
- その27: フロントエンド実装編⑥ ~大学詳細・学部一覧画面作成~
- その28: フロントエンド実装編⑦ ~学部詳細・研究室一覧画面作成~
- その29: フロントエンド実装編⑧前編 ~研究室詳細・レビュー画面作成前編~
- その29.5: フロントエンド実装編⑧後編 ~研究室詳細・レビュー画面作成後編~
- その30: フロントエンド実装編⑨ ~マイページ作成~
- その31: フロントエンド実装編⑩ ~パンくずリスト作成~
- その32: フロントエンド実装編⑪ ~ログイン・新規登録モーダル作成~
- その33: フロントエンド実装編⑫ ~大学作成・編集モーダル作成~
- その34: フロントエンド実装編⑬ ~学部作成・編集モーダル作成~
- その35: フロントエンド実装編⑭ ~研究室作成・編集モーダル作成~
- その36: フロントエンド実装編⑮ ~レビュー作成・編集モーダル作成~
- その37: フロントエンド実装編⑯ ~コメント一覧表示モーダル作成~
- その38: フロントエンド実装編⑰ ~編集履歴ページ・削除依頼ページ・通知ドロップダウン作成~
- その39: フロントエンド実装編⑱ ~トースト作成~
- その40: フロントエンド実装編⑲ ~退会ページ作成~
- その41: フロントエンド実装編⑳ ~ファビコン作成~
- その42: フロントエンド実装編㉑ ~レスポンシブデザイン対応~
☆テスト・デバッグ編
- その43: テスト・デバッグ編① ~Unitテスト1[テストデータ準備・DB設定]~
- その44: テスト・デバッグ編② ~Unitテスト2[ポリシー]~
- その45: テスト・デバッグ編③ ~Unitテスト3[モデル]~
- その46: テスト・デバッグ編④ ~Featureテスト1[コントローラー①/レビュー/コメント/ブックマーク]~
- [その47: テスト・デバッグ編⑤ ~Featureテスト2コントローラー②/大学/学部/研究室~
- その48: テスト・デバッグ編⑥ ~Featureテスト3[コントローラー③/マイページ/削除依頼/通知/ホーム/管理者]~
- その49: テスト・デバッグ編⑦ ~Featureテスト4[ポリシー・モデル残り]~
- その50: テスト・デバッグ編⑧)~E2Eテスト1[Playwrightセットアップ]~
軽く宣伝
YouTubeを始めました(というか始めてました)。
内容としては、Webエンジニアの生活や稼げるようになるまでの成長記録などを発信していく予定です。















