DELOGs
Shadcn/uiで作るログイン画面

Shadcn/uiで作るログイン画面

Shadcn/uiを利用してログインを画面作成。UIのほかZodによるバリデーションなどを実践

初回公開日

最終更新日

Shadcn/uiを利用してログイン画面のUIを作成していきます。Zodを利用したバリデーションについても実践します。

0. 事前準備

準備段階は下記の通りです。詳細はそれぞれ過去記事で詳しい実践記録があるので、参照してください。

Next.jsとshadcn/uiのインストール

/xxx/xxx/project配下にインストールします。プロジェクトディレクトリはlogin-formとします。

Next.jsインストール:

zsh
1cd /xxx/xxx/project 2npx create-next-app@latest

Next.jsインストール:

zsh
1cd login-form 2npx shadcn@latest init

layout.tsxとglobals.cssを編集:

これは少し長くなってしまいますので、該当の過去記事Next.js+shadcn/uiのインストールと基本動作のまとめ を参照してください。

(任意)Tailwind CSS用のプリティアのインストール

これは任意ですが、私はCursorエディタを利用していますので、Tailwind CSS用のプリティアをインストールします。
zsh
1npm install -D prettier prettier-plugin-tailwindcss
プロジェクトディレクトリ直下に.prettierrcを作成して、下記のように記述
json : .prettierrc
1{ 2 "plugins": ["prettier-plugin-tailwindcss"] 3}

(任意)Tailwind CSS用のプリティアのインストール

これもやはり任意ですが、E2Eテスト用にplaywrightを利用するので、これもインストールします。
zsh
1npm i -D @playwright/test 2npx playwright install
これで準備完了です。

1. ログイン画面のUI設計(Shadcn/ui使用)

本記事では、Shadcn/uiを使用して、法人向けアプリに適したログインフォームUIを構築します。必要な入力項目は以下の3つです:
  • アカウントID
  • メールアドレス
  • パスワード
すべての項目に対して適切なバリデーションを実装し、視覚的にわかりやすいエラーメッセージ表示も行います。
下記のようなUIになります。
ログイン画面
次のセクションでは、フォーム全体の構成と使用するShadcn/uiコンポーネント、そしてZodによるバリデーション定義について詳しく解説していきます。

2. Shadcn/ui コンポーネントの追加インストール

以下のコマンドで、今回使用するUIコンポーネントを追加インストールします:
zsh
1npx shadcn@latest add input card form
formをインストールするとbuttonを同時にイントールされます。また、バリデーションスキーマのZodも利用できるようになります。

使用コンポーネントの構成

今回のログインフォームには以下のShadcn/uiコンポーネントを使用します:
  • <Form>:React Hook Formのラッパー
  • <FormField>:各入力フィールドとバリデーションを繋ぐ
  • <Input>:テキスト入力
  • <FormMessage>:バリデーションエラー表示
  • <Button>:ログインボタン
これらを組み合わせることで、堅牢かつ見やすいUIを構築します。

3. ページ構成とuse clientの役割

Next.jsのApp Router構成では、app/page.tsxはデフォルトでサーバーコンポーネントです。そのため、useStateuseEffectなどのクライアント側の状態管理を使用する場合は、別ファイルに分離して use client を指定する必要があります。

ディレクトリ構成

txt
1 login-form/ 2├── app/ 3│ ├── page.tsx ← サーバーコンポーネント(トップページ) 45├── components/ 6│ └── LoginForm.tsx ← クライアントコンポーネント(フォーム本体) 78├── public/ 9│ └── logo.svg ← ロゴ画像 1011├── lib/ 12│ └── schema.ts ← Zodのバリデーションスキーマ 1314├── tests/ 15│ └── login-form.spec.ts ← Playwright E2Eテスト設定 1617├── playwright.config.ts ← Playwright設定
Next.js の App Router 構成では、app/page.tsx は デフォルトでサーバーコンポーネント(use client が不要)です。これは以下のような処理に適しています:
  • 初回のページ読み込み時にサーバーからHTMLを生成する(SEOや速度に有利)
  • 状態管理やイベント処理を含まない表示専用のUIに向いている
当サイトでは可能な限りサーバーコンポーネントでサーバサイドレンダリング(SSR)できるようにしたいと思っています。
一方、フォームのように:
  • useState や useForm など状態管理
  • イベント処理(onSubmit など)
  • DOMとのやり取り(フォーカス・バリデーション表示)
が必要な処理はクライアントコンポーネントとして分離する必要があります。そのため、components/LoginForm.tsx の冒頭に "use client"を記述し、page.tsx 側では表示として読み込むだけ、という設計にしています。

page.tsx

今回のログインフォームはユーザー入力を取り扱うため、components/LoginForm.tsx としてクライアントコンポーネント化し、page.tsxから呼び出す構成にします。
tsx
1// app/page.tsx(サーバーコンポーネント) 2import Image from "next/image"; 3import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 4import LoginForm from "@/components/LoginForm"; // ログインフォームコンポーネント 5 6export default function Page() { 7 return ( 8 <main className="flex min-h-svh w-full items-center justify-center bg-gray-800 p-6 md:p-10"> 9 <Card className="w-full max-w-md"> 10 <CardHeader> 11 <CardTitle className="flex justify-center"> 12 <Image 13 src="/logo.svg" 14 alt="サイトロゴ" 15 width={160} 16 height={40} 17 priority={true} 18 className="h-[40px] w-[160px]" 19 /> 20 </CardTitle> 21 </CardHeader> 22 <CardContent> 23 <LoginForm /> 24 </CardContent> 25 </Card> 26 </main> 27 ); 28}
この構成により、SSRのメリットを活かしつつ、フォームの動的な処理はクライアント側に切り分けることができます。

next/imageのポイント

  • 自動で最適なサイズ・形式に変換してくれる(WebPなど)
  • 遅延読み込み(Lazy Load)によりパフォーマンス向上
  • <img>タグよりもSEOや速度面で有利
next/imageはせっかくNext.jsを使うのなら必須のものですが、最初、下記の2つのブラウザ警告に悩まされました 。
Image with src "/logo.svg" was detected as the Largest Contentful Paint (LCP). Please add the "priority" property if this image is above the fold.
  • 結論:priority={true}をつける
  • LCP (Largest Contentful Paint)とは?: 警告文にある LCP(Largest Contentful Paint)とは、Webページの表示速度を測る重要な指標の一つです。ユーザーがページを開いたときに、画面内で最も大きな画像またはテキストブロックが表示されるまでの時間を指します。LCPが速いほど、ユーザーは「このページは速い」と感じます。今回のケースでは、ログインフォームがページの主要コンテンツであるため、その中にあるロゴ画像がLCP要素だとNext.jsが自動で検知した、ということです。
  • priorityプロパティの役割: priorityプロパティは、Next.jsに対して「この画像はLCPになる可能性が高い、非常に重要な画像です」と明示的に伝えるためのものです。このプロパティを指定すると、Next.jsは画像の読み込みを最適化するために、内部で以下の2つの処理を自動的に行います。
    • 画像のプリロード(Preload): HTMLのタグ内にタグを挿入します。これにより、ブラウザは他のリソースの読み込みを待たずに、非常に早い段階で画像のダウンロードを開始します。
    • 遅延読み込み(Lazy Loading)の無効化: 通常、画面外にある画像はスクロールされるまで読み込みが遅延されますが、priorityが指定された画像は遅延されず、即座に読み込まれます。
Image with src "http://localhost:3001/logo.svg" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio.
  • 結論:className="h-[40px] w-[160px]"のようにwidth={160}height={40}と同じ値をCSSで指定
  • 親要素がFlexbox(またはCSS Grid)であることが直接的な原因。簡単に言うと、レイアウトの主導権争いが起きている
  • Next.js の主張:width={160} と height={40} のプロパティは、HTMLのタグに width="160" と height="40" という属性を設定します。これはブラウザに対して「私は本来このサイズです。レイアウトが崩れないように、このスペースを確保してください」と伝えています。
  • 親要素(Flexbox)の主張:一方、className="flex" が指定された親要素は、「私の配下にあるアイテム(子要素)は、Flexboxのルールに従って配置・伸縮しなさい!」と、より強力な指示を出します。Flexboxのデフォルトの挙動では、アイテムはコンテナに合わせて引き伸ばされたり縮んだりします。
  • ブラウザの判断:ブラウザは、HTML属性よりもCSSのレイアウト指示(Flexbox)を優先します。その結果、コンポーネントはFlexboxのルールに従ってリサイズされ、widthとheight属性が示す本来のサイズと食い違いが生じます。これが、Next.jsが警告を出す原因です。
  • 解決策(className)の役割:そこで、className="w-[160px] h-[40px]" というCSSのクラスを指定します。これはブラウザに対して「Flexboxのルールの中にいるけれど、この要素だけは絶対に width: 160px, height: 40px にしてください!」という、より具体的で強い指示になります。これにより、主導権争いが解決し、Next.jsの要求(レイアウトシフト防止)とCSSの要求(表示サイズ)が一致するため、すべてが丸く収まります。
画像は /public/logo.svg に配置しておきましょう。

4. Zodによるバリデーションスキーマ

Zodとは?─ 安全でわかりやすいスキーマバリデーションライブラリ

Zod(ゾッド)は、TypeScriptの型安全性とバリデーションを両立するための軽量なバリデーションライブラリです。
フォーム入力やAPIレスポンスのような「ユーザーが入力したデータ」や「外部から受け取ったデータ」は、正しい形式である保証がありません
Zodを使えば、そのような「信用できないデータ」に対して、構造や型、制約を定義し、厳密に検証できるようになります。

✅ Zodの特徴

特徴説明
型推論に強いスキーマ定義からTypeScript型をそのまま生成できる(z.infer<>
エラーメッセージのカスタマイズ各フィールドごとに個別のエラーメッセージを設定可能
シンプルな構文JavaScript初心者でも読みやすく、直感的に書ける
バリデーションと型を1つにTypeScript型とバリデーションルールを同時に記述できる

🔰 なぜZodを使うのか?

Next.js や React Hook Form と組み合わせることで、ユーザー入力のチェック → エラー表示 → データ送信までを一貫して安全に処理できます。
たとえば、以下のように:
ts
1const schema = z.object({ 2 email: z.email({ message: "有効なメールアドレスを入力してください。" }), 3 password: z.string().min(15).regex(...), 4});
と書くだけで:
  • メール形式の検証
  • パスワードの文字数制限・形式制限
  • エラーメッセージの指定
などを直感的に定義できます。
また、次のようにTypeScriptの型も自動で得られるため、フロントエンド開発の型整合性にも役立ちます:
ts
1type FormData = z.infer<typeof schema>;

バリデーション定義(lib/schema.ts)

Zodライブラリを用いて、次のようなバリデーションルールを定義します:
ts
1import { z } from "zod"; 2 3export const loginSchema = z.object({ 4 accountId: z 5 .string() 6 .min(15, { message: "アカウントIDは15文字以上で入力してください。" }) 7 .regex(/[A-Z]/, "大文字を1文字以上含めてください。") 8 .regex(/[a-z]/, "小文字を1文字以上含めてください。") 9 .regex(/[0-9]/, "数字を1文字以上含めてください。"), 10 email: z.email({ message: "有効なメールアドレスを入力してください。" }), 11 password: z 12 .string() 13 .min(15, { message: "パスワードは15文字以上で入力してください。" }) 14 .regex(/[A-Z]/, "大文字を1文字以上含めてください。") 15 .regex(/[a-z]/, "小文字を1文字以上含めてください。") 16 .regex(/[0-9]/, "数字を1文字以上含めてください。") 17});
.email()は、Zodが内部的に「@記号を含み、適切なドメイン構造を持つメールアドレス形式か?」を検証する関数です。これは RFC 5322 の簡易準拠として設計されており、実用上のメールバリデーションとして広く使われている正規表現を基にしています。Gmailやyahooなどの一般的な形式は通過しますが、「@が無い」「ドメインが無い」といった典型的な誤りは弾かれます。
このスキーマを使って、react-hook-formと連携させていきます。

5. フォームの実装コード例(LoginForm.tsx)

以下はsrc/components/LoginForm.tsxに配置する仮実装のログインフォーム例です:
tsx
1"use client"; 2 3import { useState } from "react"; 4import { useForm } from "react-hook-form"; 5import { zodResolver } from "@hookform/resolvers/zod"; 6import { loginSchema } from "@/lib/schema"; // ログインスキーマ 7import { z } from "zod"; 8import { 9 Form, 10 FormField, 11 FormItem, 12 FormLabel, 13 FormControl, 14 FormMessage, 15} from "@/components/ui/form"; 16import { Input } from "@/components/ui/input"; 17import { Button } from "@/components/ui/button"; 18import { Eye, EyeOff } from "lucide-react"; 19 20export default function LoginForm() { 21 const form = useForm<z.infer<typeof loginSchema>>({ 22 resolver: zodResolver(loginSchema), 23 defaultValues: { 24 accountId: "", 25 email: "", 26 password: "", 27 }, 28 }); 29 30 const [showPassword, setShowPassword] = useState(false); 31 32 function onSubmit(values: z.infer<typeof loginSchema>) { 33 console.log("送信データ:", values); 34 // ここにAPI連携を実装予定 35 } 36 37 return ( 38 <Form {...form}> 39 <form 40 onSubmit={form.handleSubmit(onSubmit)} 41 className="mx-auto max-w-md space-y-4 pt-4 pb-10" 42 > 43 <FormField 44 control={form.control} 45 name="accountId" 46 render={({ field }) => ( 47 <FormItem> 48 <FormLabel>アカウントID</FormLabel> 49 <FormControl> 50 <Input 51 data-testid="accountId" 52 placeholder="CORP000123456" 53 autoFocus 54 {...field} 55 /> 56 </FormControl> 57 <FormMessage data-testid="accountId-error" /> 58 </FormItem> 59 )} 60 /> 61 <FormField 62 control={form.control} 63 name="email" 64 render={({ field }) => ( 65 <FormItem> 66 <FormLabel>メールアドレス</FormLabel> 67 <FormControl> 68 <Input 69 data-testid="email" 70 type="email" 71 autoComplete="email" 72 placeholder="your@email.com" 73 {...field} 74 /> 75 </FormControl> 76 <FormMessage data-testid="email-error" /> 77 </FormItem> 78 )} 79 /> 80 <FormField 81 control={form.control} 82 name="password" 83 render={({ field }) => ( 84 <FormItem> 85 <FormLabel>パスワード</FormLabel> 86 <div className="flex items-start gap-2"> 87 <FormControl> 88 <Input 89 {...field} 90 data-testid="password" 91 type={showPassword ? "text" : "password"} 92 autoComplete="current-password" 93 placeholder="半角英数字15文字以上" 94 /> 95 </FormControl> 96 <Button 97 data-testid="password-toggle" 98 type="button" 99 size="icon" 100 variant="outline" 101 onClick={() => setShowPassword((prev) => !prev)} 102 aria-label={ 103 showPassword 104 ? "パスワードを非表示にする" 105 : "パスワードを表示する" 106 } 107 className="shrink-0 cursor-pointer" 108 > 109 {showPassword ? <EyeOff size={18} /> : <Eye size={18} />} 110 </Button> 111 </div> 112 <FormMessage data-testid="password-error" /> 113 </FormItem> 114 )} 115 /> 116 <Button 117 data-testid="submit" 118 type="submit" 119 className="mt-4 w-full cursor-pointer" 120 > 121 ログイン 122 </Button> 123 </form> 124 </Form> 125 ); 126}

LoginForm.tsx の初心者向け構造解説

このセクションでは、src/components/LoginForm.tsx の構成を 初心者の方でも理解しやすいように丁寧に分解して解説します。

🔰 ファイル全体の役割は?

このファイルは、**ログインフォームのUIとロジックを実装する「クライアントコンポーネント」**です。ユーザーが入力する情報(アカウントID・メールアドレス・パスワード)を受け取り、バリデーションして、ログイン処理(まだAPI未接続)につなげる準備をしています。

🔁 useForm とは?

ts
1const form = useForm<z.infer<typeof loginSchema>>({ 2 resolver: zodResolver(loginSchema), 3 defaultValues: { 4 accountId: "", 5 email: "", 6 password: "", 7 }, 8});
  • useForm は React Hook Form のメイン関数で、フォームの状態やバリデーションの制御をまとめるものです。
  • zodResolver(loginSchema) によって、Zodで定義したバリデーションルールを適用しています。
  • defaultValues でフォームの初期値を定義します。
z.infer<typeof loginSchema> は TypeScript の型推論機能で、バリデーション定義から自動で型を導き出します。

🔐 パスワード表示切替(useState)

ts
1const [showPassword, setShowPassword] = useState(false);
  • showPasswordtrue ならパスワードを「表示」、false なら「●●●」のマスク表示にします。
  • これをトグルで切り替えることで、パスワード表示/非表示切替ボタンを実装しています。

🧩 Form の構造全体

tsx
1<Form {...form}> 2 <form onSubmit={form.handleSubmit(onSubmit)}> 3 {/* 各FormFieldがここに入る */} 4 </form> 5</Form>
  • Form は Shadcn/ui のラッパーで、React Hook Form を内包して動作します。
  • onSubmit でフォーム送信時の処理を受け取ります。
  • 中身には <FormField> を使ってそれぞれの入力欄を定義していきます。

🧱 FormField の使い方

tsx
1<FormField 2 control={form.control} 3 name="accountId" 4 render={({ field }) => ( 5 <FormItem> 6 <FormLabel>アカウントID</FormLabel> 7 <FormControl> 8 <Input placeholder="CORP000123456" {...field} /> 9 </FormControl> 10 <FormMessage /> 11 </FormItem> 12 )} 13/>
それぞれの要素の役割は以下の通り:
コンポーネント役割説明
FormField1フィールドのバリデーションや状態をまとめる枠組み
FormItem全体を包むラッパーで、ラベル・入力欄・エラーメッセージを内包
FormLabel入力欄のラベル表示(<label>
FormControl入力欄のラッパー。ここに InputTextarea を入れる
FormMessageバリデーションエラー時のメッセージを自動表示

🧠 Password欄 + 表示切り替えボタンの構成

tsx
1<FormLabel>パスワード</FormLabel> 2<div className="flex items-start gap-2"> 3 <FormControl> 4 <Input type={showPassword ? "text" : "password"} ... /> 5 </FormControl> 6 <Button type="button"> {showPassword ? <EyeOff /> : <Eye />} </Button> 7</div>
  • パスワード欄は Input の横に <Button> を配置し、クリックで表示状態を切り替えます。
  • lucide-reactEye / EyeOff アイコンを使って視覚的に明示しています。
  • この構成は、今後も DELOGsのログインフォーム標準仕様として扱います。

📤 onSubmit処理

tsx
1function onSubmit(values) { 2 console.log("送信データ:", values); 3 // TODO: API連携を実装予定 4}
  • values には、Zodバリデーション済みの 安全な入力データ が入ります。
  • 今は console.log のみですが、今後ここにログインAPIとの通信処理を追加予定です。

🧪 テスト用のIDを付与

これはE2Eテスト用なので、そこはやらないという方には関係ないのですが、各パーツにテスト用のID:data-testidを付与しています。
tsx
1<Input data-testid="accountId" placeholder="CORP000123456" autoFocus {...field} />
これがあるとE2Eのテストファイルを記述するときにinput要素の日本語名称やエラーメッセージなどをターゲットにせずに、data-testidをターゲットにできます。運用面を考慮すると、私としてはマストの対応となっています。
これで、LoginForm.tsx の構成とその理由がしっかり理解できるようになったかと思います。 この知識は次の「API連携」や「テスト設計」のステップでも役立ちます!

6. (任意)E2Eのテストファイルを作ろう

この章以降は、任意の作業です。毎度テストするのが面倒という方向けです。それ以外の方は読み飛ばしていただいても問題ないです。
テストファイルについては下記でも触れています。ただ今回は、より実践的にパワーアップした内容になっています。

Playwright の設定ファイルを作成:playwright.config.ts

Playwright の設定ファイルについては下記でも少し触れています。ただ今回は、より実践的にパワーアップした内容になっています。
Playwright を用いた E2E テストを行う際、playwright.config.ts はその「設計図」となる重要ファイルです。この記事では、DELOGsログインフォームに合わせて最適化された設定ファイルを作成します。

🎯 playwright.config.tsとは?

Playwright の動作全体を定義する設定ファイルで、次のような内容を記述できます:
  • テストファイルの場所(どのフォルダを見るか)
  • デフォルトのブラウザや画面サイズ
  • 各種オプション(動画撮影、スクリーンショット、HTMLレポートなど)
  • 実行時間やタイムアウトなどの制御
ファイル名は playwright.config.ts(または .js)で、プロジェクトのルートに配置します。

🏗 サンプル構成(DELOGs用)

ts : title
1import { defineConfig, devices } from '@playwright/test'; 2 3const PORT = 3010; 4 5export default defineConfig({ 6 testDir: './tests', 7 timeout: 30 * 1000, 8 expect: { 9 timeout: 5000, 10 }, 11 fullyParallel: true, 12 reporter: [['html', { open: 'never' }]], 13 use: { 14 baseURL: `http://localhost:${PORT}`, 15 headless: true, 16 viewport: { width: 1280, height: 720 }, 17 ignoreHTTPSErrors: true, 18 screenshot: 'only-on-failure', 19 video: 'retain-on-failure', 20 }, 21 webServer: { 22 command: `npm run dev -- -p ${PORT}`, 23 port: PORT, 24 reuseExistingServer: !process.env.CI, 25 }, 26 projects: [ 27 { 28 name: 'Desktop Chrome', 29 use: { ...devices['Desktop Chrome'] }, 30 }, 31 { 32 name: 'Desktop Firefox', 33 use: { ...devices['Desktop Firefox'] }, 34 }, 35 { 36 name: 'Desktop Safari', 37 use: { ...devices['Desktop Safari'] }, 38 }, 39 { 40 name: 'Mobile Chrome', 41 use: { ...devices['Pixel 5'] }, 42 }, 43 { 44 name: 'Mobile Safari', 45 use: { ...devices['iPhone 13'] }, 46 }, 47 ], 48});

各項目の意味とポイント

🔍 testDir テストファイルがあるディレクトリの指定。今回は ./tests にすべての .spec.ts を置く想定です。
timeout テスト1件あたりの実行タイムアウト時間(ミリ秒)。30 * 1000 は 30秒です。
🧪 expect.timeoutexpect(...)toBeVisible() などの判定を待つ時間。
🚀 fullyParalleltrue にすると、複数のテストファイルを並列に実行できます。CI高速化に有効。
📊 reporter テストレポートの出力形式。ここでは HTML を指定しつつ open: 'never' にして、勝手にブラウザ表示しないようにしています。
use ブロックの詳細
🌐 baseURL テストのアクセス先(page.goto('/')http://localhost:3000/ を意味するように)
👻 headless GUIなしでブラウザを開くか。trueにすると非表示モード(CIで使う)。
📐 viewport ブラウザの画面サイズ。
🔒 ignoreHTTPSErrors 自己署名SSL証明書などでもエラーにしない。
📸 screenshot スクリーンショットの自動撮影タイミング。
  • only-on-failure:テスト失敗時だけ保存(デフォルトにおすすめ)
🎥 video テスト実行中の「画面操作を録画」するオプション。
  • retain-on-failure失敗したときだけ動画を保存(バグ再現の検証に便利)
  • ファイルは test-results フォルダに保存される
  • on にすると常時記録されますが容量増大に注意
✅ Playwright の録画機能は、UIテストにおいて非常に強力なデバッグ手段になります。特にCIでの不安定な動作を「見える化」できる点が優秀です。
webServer オプション(Next.js 開発サーバを自動起動)
CI環境などで npm run dev を自動実行し、Next.jsアプリケーションを起動する設定です。
ts
1webServer: { 2 command: `npm run dev -- -p ${PORT}`, 3 port: PORT, 4 reuseExistingServer: !process.env.CI, 5},
  • command:テスト前に実行するコマンド
  • port:Playwrightがアクセスを試みるポート
  • reuseExistingServer:既にサーバーが起動していれば再利用(ローカルで便利)
projects ブロック
Playwright はマルチブラウザ対応なので、ここで ChromeFirefox などを定義できます。
ts
1projects: [ 2 { 3 name: 'Desktop Chrome', 4 use: { ...devices['Desktop Chrome'] }, 5 }, 6 { 7 name: 'Desktop Firefox', 8 use: { ...devices['Desktop Firefox'] }, 9 }, 10 { 11 name: 'Desktop Safari', 12 use: { ...devices['Desktop Safari'] }, 13 }, 14 { 15 name: 'Mobile Chrome', 16 use: { ...devices['Pixel 5'] }, 17 }, 18 { 19 name: 'Mobile Safari', 20 use: { ...devices['iPhone 13'] }, 21 }, 22]
これにより、あらゆる主要ブラウザ・主要端末でのUI検証が自動化可能になります。

Playwright設定のまとめ

Playwrightの設定ファイルは少し複雑に見えますが、1つ1つの役割を知ればとてもパワフルな仕組みです。特に動画やスクリーンショット、レポートなどの自動生成は、開発者だけでなく非エンジニアにとっても可視性の高い検証手段になります。
今回のようなログイン画面のUI検証や、今後のフォーム・ダッシュボード画面のE2Eにも活用していきましょう。

テストファイルの作成 :tests/login-form.spec.ts

Playwright設定が終わったら、次にテストファイルを作成します。
テストファイルについては下記でも触れています。ただ今回は、より実践的にパワーアップした内容になっています。
プロジェクトディレクトリ直下にtestsディレクトリを作成してtsファイルを作成します。今回はtests/login-form.spec.tsを作成していきます。
テストパターンの構成は以下の4パートにしています:
  • 初期状態の確認:すべて空欄でボタンが有効であること
  • 未入力時のバリデーション表示
  • 形式エラー入力時のバリデーション表示
  • 正しい入力での送信処理(仮想送信)

🏗 テストファイルのサンプル(DELOGs用)

ts
1// tests/login-form.spec.ts 2import { test, expect } from "@playwright/test"; 3 4// ログインフォームの動作テスト 5test.describe("ログインフォーム", () => { 6 test.beforeEach(async ({ page }) => { 7 await page.goto("/"); 8 }); 9 10 test("フォームの初期状態", async ({ page }) => { 11 await expect(page.getByTestId("accountId")).toBeEmpty(); 12 await expect(page.getByTestId("email")).toBeEmpty(); 13 await expect(page.getByTestId("password")).toBeEmpty(); 14 await expect(page.getByTestId("submit")).toBeEnabled(); 15 }); 16 17 test("バリデーションメッセージの表示(未入力)", async ({ page }) => { 18 await page.getByTestId("submit").click(); 19 20 await Promise.all([ 21 expect(page.getByTestId("accountId-error")).toBeVisible({ 22 timeout: 7000, 23 }), 24 expect(page.getByTestId("email-error")).toBeVisible({ timeout: 7000 }), 25 expect(page.getByTestId("password-error")).toBeVisible({ timeout: 7000 }), 26 ]); 27 }); 28 29 test("バリデーションメッセージの表示(形式エラー)", async ({ page }) => { 30 await page.getByTestId("accountId").fill("ABC123ABC456ABC"); // 小文字なし 31 await page.getByTestId("email").fill("test@example.com"); // OK 32 await page.getByTestId("password").fill("securepassword123"); // 大文字なし 33 34 await page.getByTestId("submit").click(); 35 36 await Promise.all([ 37 expect(page.getByTestId("accountId-error")).toBeVisible({ 38 timeout: 7000, 39 }), 40 expect(page.getByTestId("password-error")).toBeVisible({ timeout: 7000 }), 41 ]); 42 }); 43 44 test("有効なデータでフォーム送信", async ({ page }) => { 45 await page.getByTestId("accountId").fill("CorpUserAccount123"); 46 await page.getByTestId("email").fill("test@example.com"); 47 await page.getByTestId("password").fill("SecurePassword123"); 48 49 await page.getByTestId("submit").click(); 50 // Safari/WebKitでは、submit直後にDOM反映が遅れるケースがあるため200msの猶予を与える 51 await page.waitForTimeout(200); // Safariでバリデーションが遅延する場合の対策 52 53 await Promise.all([ 54 expect(page.getByTestId("accountId-error")).not.toBeVisible({ 55 timeout: 10000, 56 }), 57 expect(page.getByTestId("email-error")).not.toBeVisible({ 58 timeout: 10000, 59 }), 60 expect(page.getByTestId("password-error")).not.toBeVisible({ 61 timeout: 10000, 62 }), 63 ]); 64 65 // 実際のAPI連携が未実装なので、ここではconsoleの確認やモック処理を想定 66 await expect(page).not.toHaveURL(/error/); 67 }); 68});
フォームでdata-testidを付与しているので、getByTestIdで値を取得してチェックするように書けます。運用面を考えると今のところ、これがベストかなと思っています。

テスト実施

準備ができたら、下記のコマンドでテストを実施してみます。
zsh
1npx playwright test --reporter=html
zsh
1Running 20 tests using 7 workers 2 20 passed (7.5s) 3 4To open last HTML report run: 5 6 npx playwright show-report
上記のようにテストを通過すればOKです。環境によってはDOM描画が遅くてエラーになることもあるかもしれません。その際はテストファイルのawait page.waitForTimeout(200); // Safariでバリデーションが遅延する場合の対策timeoutの値を調整してみてください。

7. (任意)GitHub ActionsによるE2E自動テスト設定

Playwrightを使ったE2Eテストを**CI(継続的インテグレーション)**環境で自動実行するには、GitHub Actionsの設定が必要です。ここでは、Next.jsアプリの開発サーバを起動し、E2Eテストを自動で走らせる最小構成の .github/workflows/e2e.yml を作成していきます。

GitHub ActionsでPlaywrightを動かす目的

目的説明
自動テストの仕組み化プルリクエスト作成やpush時にテストを自動で実行
テスト失敗の早期検知UIの崩れやバリデーションの不備をCIで検出
チーム開発での安心材料誰かがうっかり壊しても、CIが止めてくれる

e2e.ymlファイルの内容

ワークフローファイルの作成については、下記でも実施しています。今回は微調整を加えています。
.github/workflows/e2e.ymlを下記の内容で作成します。
yml
1name: Run Playwright E2E Tests 2 3on: 4 push: 5 branches: [main] 6 pull_request: 7 branches: [main] 8 9jobs: 10 test: 11 timeout-minutes: 10 12 runs-on: ubuntu-latest 13 14 steps: 15 # 1) ソース取得 16 - name: Checkout Repository 17 uses: actions/checkout@v4 18 19 # 2) Node 環境(バージョン固定) 20 - name: Setup Node.js 21 uses: actions/setup-node@v4 22 with: 23 node-version: 22.17.0 24 25 # 3) 依存インストール 26 - name: Install Dependencies 27 run: npm ci 28 29 # 4) Playwright のブラウザ環境をセットアップ 30 - name: Install Playwright Browsers 31 run: npx playwright install --with-deps 32 33 # 5) TypeScript型チェック 34 - name: Type Check 35 run: npx tsc --noEmit 36 37 # 6) Playwright E2Eテスト実行(HTMLレポート) 38 - name: Run Playwright Tests 39 run: npx playwright test --reporter=html 40 41 # 7) レポートのアーティファクト保存(常に実行) 42 - name: Upload Playwright Report 43 uses: actions/upload-artifact@v4 44 if: always() 45 with: 46 name: playwright-report 47 path: playwright-report 48 retention-days: 7 49 50 # 8) Next.jsの本番ビルド確認(任意・品質ゲート) 51 - name: Next.js Build 52 run: npm run build

設定ポイント(GitHub ActionsでPlaywrightをCI化)

このワークフローは、pushやpull_requestのたびにPlaywrightのE2Eテストを実行し、結果をHTMLレポートとして保存します。
以下は主な設定ポイントです:
ステップ内容備考
on.push / pull_requestmainブランチへのpushまたはPR作成時に動作
uses: actions/checkout@v4リポジトリのソースコードをチェックアウトGitHub Actionsの定番。必須
setup-node@v4Node.js 22.17.0 をインストールして固定化再現性を確保。毎回必ず同じ環境でテスト
npm cilockファイルをもとに依存を完全再構築npm installより厳密な依存管理
npx playwright install --with-depsPlaywright本体とブラウザのドライバをインストールGUIテストに必要なChrome/Firefox/Safariなど
npx tsc --noEmitTypeScriptの型エラーがないかチェックテストを始める前に型の整合性を担保
npx playwright test --reporter=htmlE2Eテストを実行してHTMLレポートを生成GUIテストの結果をHTMLで残せる
upload-artifact@v4PlaywrightのHTMLレポートを保存if: always()で失敗時もレポート取得可
retention-days: 7レポート保存期間を7日間に制限ディスク節約のための設定(CIコスト最適化)
npm run buildNext.jsの本番ビルドが通るか確認テストとビルド両方が成功すれば安全にマージ可
これを設定することで、GitHubにpushした瞬間にテストが自動で走り、結果がPRやコミットに表示されるようになります。

参考文献・リンク集

本記事で紹介した内容に関連する、公式ドキュメントや参考になる記事を以下にまとめます。
項目リンク
Next.js App Router 公式ドキュメントhttps://nextjs.org/docs/app
shadcn/ui コンポーネント集https://ui.shadcn.dev
React Hook Formhttps://react-hook-form.com
Zod(バリデーションライブラリ)https://zod.dev
Playwright 公式https://playwright.dev
Next.jsの画像最適化(next/imagehttps://nextjs.org/docs/app/api-reference/components/image
GitHub Actions ドキュメントhttps://docs.github.com/actions
以上で、Shadcn/uiとZodを用いたログイン画面のUI構築とE2Eテストの完全ガイドは完了です。 徐々に過去記事とも連動する内容になってきて、少しだけ成長してきた気がしています。
次のステップでは「認証APIの実装」や「保護ルートの設定」など、実運用を見据えた処理に進んでいきます!
この記事の執筆・編集担当
DE

松本 孝太郎

DELOGs編集部/中年新米プログラマー

ここ数年はReact&MUIのフロントエンドエンジニアって感じでしたが、Next.jsを学んで少しずつできることが広がりつつあります。その実践記録をできるだけ共有していければと思っています。