🌟

AIでUI作って、AIで実装して、AIでテストする、を試す

に公開

大雑把な流れ

  1. StitchでUIを作る
  2. Figmaにコピーする
  3. Codex CLI+Figma MCPを使って実装
  4. PlayWright Agentsでテスト

StitchでUIを作る

今回は「偉人の名言を表示するWebアプリ」を作ってみます。
作るのはスタンドアローンで動く、ReactのSPAアプリです。

後でFigma連携をしますが、そのためには「標準モード」で動かす必要があるのでご注意ください。
Stitchに指示を送ります。

すると要件を確認してくれるので、問題なければそのままUIを生成します。

画面を作ってくれました。

一番左の画面がイマイチなので追加で指示をだします。

背景画像を画面いっぱいに表示し、名言を垂直方向にセンタリングさせてください

修正してくれました。
ただ、納得いくまでAIに何度も指示を出すのは辛い気がするので、細かな調整はFigmaでします。

Figmaにコピーする

Stitchの画面でFigmaへのコピーのメニューを選択します。

1画面ずつコピーすることで移植完了しました。
ここではホーム画面のピン留めボタンの位置と色が納得いかないので、手で修正しています。

Codex CLI+Figma MCPを使って実装

実装前に事前準備をします。

  • サンプル格言と背景画像の準備(過去にChatGPTに作ってもらったものがあったのでそのまま流用)
  • 開発ディレクトリを作ってViteでReactプロジェクトを準備
  • MCPの登録などCodexの事前準備

準備が終わったのでCodexを起動します。
まずは要件を手作業で書き出してAIに要件に過不足・質問がないかを確認します。
AIからの質問に対する回答を「要確認事項への回答」として追記して、再度読んでもらいます。

AIへ渡した要件
# アプリ概要

偉人の名言をクールに表示するサイト
スタイリッシュで見ていてモチベーションが上がる感じ
ランダム名言表示、ピン留めした名言の表示、名言登録の機能がある

# 機能概要

- 今日の格言: ダークトーンの背景に、ランダムに表示される名言と偉人の情報が際立つスタイリッシュなメイン画面です。お気に入りとしてピン留めするボタンも配置されています。
- お気に入りの格言: ピン留めした名言がシンプルで洗練されたカード形式で一覧表示される画面です。各カードからは名言の編集や削除が可能です。
- 名言を登録: 新しい名言と作者を登録するためのフォーム画面です。モダンな入力フィールドと視認性の高い登録ボタンで、操作性を重視したデザインになっています。

# 機能詳細:今日の格言

ランダムで 1 個格言を表示する
格言は同じフォルダにある quote.txt にあるので、そこから格言の JSON ファイルを作る
発言した人の名前がないので適当に考えて設定しておく
格言には背景画像をランダムで表示する
背景画像素材は同じフォルダにある image1.webp,image2.webp,image3.webp を使用する
Pin のボタンを押すと格言をピン留めでき、「お気に入りの格言」画面で表示できる
Pin 留め情報は localStorage に保存する

# 機能詳細:お気に入りの格言

Pin 留めされた格言をカードで表示する
Pin 留めを解除する機能はバックログ

# 機能詳細:名言を登録

格言、発言者を登録でき、お気に入りの格言で表示できる
背景は素材からランダムで選ばれる

# 機能詳細:UI

登録されている figma mcp から AI_DEV プロジェクトのデザインを参照

# 要確認事項への回答

- quote.txt→JSON 化をどのタイミング・方法で行い、そのファイルをどこに配置するか(ビルド時に固定 JSON を用意する想定か、起動時に変換するスクリプトを用意するか)を明示したいです。

回答:開発時に JSON 化しておいて、public フォルダに入れてアプリ起動後に動的に fectch してください

- ランダム表示に自作登録の名言も含めるか、初期セットのみを対象にするかを決めたいです。

回答:自作登録の名言も含めてください

- 新規登録した名言をどこに保存するか(localStorage で良いか、キー名や構造、永続化の単位など)を確認したいです。

回答:localStorage に保存してください。構造はお任せしますが、名言と発言者名と何かしらの ID は必須です。

- 「お気に入りの格言」画面はピン留めのみを表示するのか、登録済みの全名言も表示するのかが曖昧です。登録直後にピン留め扱いにするのか、別アクションが必要かを決めたいです。

回答:登録済みの全名言も表示して下さい。登録直後にピン留め扱いで良いです。

- ピン留め済み・登録済みデータの重複防止ルールやバリデーション(空文字、長文など)の要件があれば共有をお願いしたいです。

回答:空文字のチェックのみで良いです。

次は実装してもらいます。
5分ほど放置してると出来上がりました。
ボタンが変なことを除けば、おおむね雰囲気はあっているのでFigmaのデザインはちゃんと理解していそうです。
個人的には「靴下が片方ないなら、両方脱げ」が好きです。足立澪さん、いったいどんな人なんでしょう。

ボタンのデザインが大きく乖離してるのでFigmaのデザインと合わせて欲しいと依頼して修正してもらいます。
見直してくれて見た目は綺麗にはなりました。

とりあえずは出来たので、今後のためにここまでの設計情報も書き出しておいてもらいます。(内容は割愛)

PlayWright Agentsでテスト

PlayWright AgentsのinitがCodexに対応していないのでOpenCodeで対応します。
以下参考。

https://zenn.dev/megyo9/scraps/54e5b10da780d2

OpenCodeを起動してプランナーを呼び出します。
たまにプランナーエージェントを呼ばずに自分で頑張っちゃうので、その場合はプランナーを呼べともう1回指示を出します。

@playwright-test-planner サブエージェントを使ってテストケースを立案してください。

対象:私が作った格言表示アプリ(http://localhost:5173/

* Seed fileはここです: seed.spec.js
* アプリの要件は specs/requirement.mdに書かれています
* アプリの設計は specs/design.mdに書かれています
* テストプランは specs/ 配下にMarkdownで出力してください
* テストプランはノンデグレードテストとして、全機能を浅く網羅するような形で作ってください

出力は日本語でお願いします。

テスト計画が出力されました。

AIが作ったテスト計画
# 格言表示アプリ ノンデグレードテスト計画

## アプリ概要

本アプリは、偉人の名言をスタイリッシュに表示し、ユーザーが名言をピン留め(お気に入り)・編集・削除・新規登録できるシングルページアプリケーションです。  
主な画面は「今日の格言」「お気に入りの格言」「名言を登録」の3つで構成され、localStorageによるデータ永続化や、ランダム背景画像の適用など、モダンなUI/UXを実現しています。

---

## テスト方針

- 画面単位で主要機能を網羅
- 代表的な正常系・バリデーション・UI遷移・localStorage保存の確認
- 編集・削除・ピン留め・登録・シャッフル等の一通りの操作
- データの重複・空文字バリデーション等の要件もカバー
- ノンデグレード(全機能が最低限動作することを確認)

---

## 1. 今日の格言画面

### 1-1. ランダム名言表示

**手順:**
1. アプリを起動し、「今日の格言」画面を表示する
2. 名言と発言者、背景画像が表示されていることを確認

**期待結果:**
- 名言・発言者・背景画像がランダムに1件表示される
- 背景画像は3種類のいずれか

---

### 1-2. 別の格言ボタン(シャッフル)

**手順:**
1. 「別の格言」ボタンをクリック
2. 表示される名言・発言者・背景画像が変化することを確認

**期待結果:**
- 直前と異なる名言・背景画像が表示される(名言が2件以上ある場合)

---

### 1-3. ピン留め(お気に入り追加)

**手順:**
1. 「ピン留め」ボタンをクリック
2. localStorageにピン留め情報が保存されることを確認
3. 「お気に入りの格言」画面で該当名言が表示されることを確認

**期待結果:**
- ピン留めした名言がlocalStorageに保存される
- お気に入り画面で該当名言が表示される

---

## 2. お気に入りの格言画面

### 2-1. ピン留め名言の表示

**手順:**
1. 「お気に入りの格言」画面を表示
2. ピン留めした名言がカード形式で表示されていることを確認

**期待結果:**
- ピン留め済み名言が背景画像付きでカード表示される

---

### 2-2. 編集機能

**手順:**
1. お気に入りカードの「編集」ボタンをクリック
2. テキスト・発言者を編集し、保存
3. 変更内容が即時反映され、localStorageにも保存されていることを確認

**期待結果:**
- 編集内容がカードに反映される
- localStorageにも反映される

---

### 2-3. 削除機能

**手順:**
1. お気に入りカードの「削除」ボタンをクリック
2. カードがリストから消えることを確認
3. localStorageからも削除されていることを確認

**期待結果:**
- カードが画面・localStorageから削除される

---

### 2-4. 編集・削除のバリデーション

**手順:**
1. 編集時に名言テキストを空欄にして保存
2. エラーメッセージが表示され、保存できないことを確認

**期待結果:**
- 空欄では保存できず、エラー表示される

---

## 3. 名言を登録画面

### 3-1. 新規名言登録(正常系)

**手順:**
1. 「名言を登録」画面を表示
2. 名言テキスト・発言者を入力し、「登録」ボタンをクリック
3. お気に入り画面に自動遷移し、登録した名言が表示されることを確認

**期待結果:**
- 新規名言がlocalStorageに保存される
- お気に入り画面に自動遷移し、登録名言が表示される

---

### 3-2. 発言者未入力時のフォールバック

**手順:**
1. 名言テキストのみ入力し、発言者を空欄のまま登録
2. お気に入り画面で「Anonymous」と表示されることを確認

**期待結果:**
- 発言者未入力時は「Anonymous」として登録される

---

### 3-3. バリデーション(空文字)

**手順:**
1. 名言テキストを空欄で登録を試みる
2. エラーメッセージが表示され、登録できないことを確認

**期待結果:**
- 空欄では登録できず、エラー表示される

---

### 3-4. 重複登録の挙動

**手順:**
1. 既存と同じ内容の名言・発言者で登録を試みる
2. 正常に登録できることを確認(重複防止は空文字のみ)

**期待結果:**
- 重複内容でも登録可能

---

## 4. フッターナビゲーション

### 4-1. タブ遷移

**手順:**
1. フッターの「今日」「お気に入り」「登録」タブを順にクリック
2. 各画面に正しく遷移することを確認

**期待結果:**
- 各タブで該当画面に遷移し、アクティブ状態が正しく表示される

---

## 5. localStorage保存・復元

### 5-1. データ永続化

**手順:**
1. 名言を登録・ピン留め・編集・削除など一通り操作
2. ページをリロード
3. 直前の状態が復元されていることを確認

**期待結果:**
- localStorageに保存されたデータが正しく復元される

---

## 6. その他UI・エラーハンドリング

### 6-1. 初期データ取得失敗時

**手順:**
1. quotes.jsonの取得に失敗する状況をシミュレート
2. エラーバナー等が表示されることを確認

**期待結果:**
- エラー表示が出る

---

## 付録

- 本テスト計画はノンデグレード(全機能が最低限動作すること)を目的とし、各機能の正常系・バリデーション・UI遷移・localStorage保存を網羅的に確認するものです。
- 詳細な異常系やパフォーマンステストは別途実施予定。

---

次にテストケースを出力してもらいます。

@playwright-test-generator specs/test-plan.mdをもとにテストコードを生成してください

テストケースが出力されました。
しかし、明らかにソースコードを見てない想像で書いたものになっていました。
ソースコードを見ながら作ってと再度依頼して出力してもらうと、それっぽいものになってました。

AIが作ったテストコード
import { test, expect } from '@playwright/test';

const APP_URL = 'http://localhost:5173/';

const PINNED_KEY = 'AI_DEV_PINNED_QUOTES_V1';
const CUSTOM_KEY = 'AI_DEV_CUSTOM_QUOTES_V1';

async function clearLocalStorage(page) {
  await page.goto(APP_URL);
  await page.evaluate(() => {
    localStorage.clear();
  });
}

test.describe('格言表示アプリ E2E', () => {
  test.beforeEach(async ({ page }) => {
    await clearLocalStorage(page);
  });

  test('1-1: 今日の格言画面で名言・発言者・背景画像が表示される', async ({ page }) => {
    await page.goto(APP_URL);
    await expect(page.locator('.today-card__quote')).toBeVisible();
    await expect(page.locator('.today-card__author')).toBeVisible();
    await expect(page.locator('.today-card__bg')).toBeVisible();
  });

  test('1-2: 「別の格言」ボタンで名言・背景画像が変化する', async ({ page }) => {
    await page.goto(APP_URL);
    const beforeText = await page.locator('.today-card__quote').textContent();
    await page.getByRole('button', { name: /別の格言/ }).click();
    const afterText = await page.locator('.today-card__quote').textContent();
    expect(beforeText).not.toBe(afterText);
  });

  test('1-3: 「ピン留め」ボタンでお気に入り追加・localStorage保存', async ({ page }) => {
    await page.goto(APP_URL);
    const quoteText = await page.locator('.today-card__quote').textContent();
    await page.getByRole('button', { name: /ピン留めする/ }).click();
    const pinned = await page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '[]'), PINNED_KEY);
    expect(pinned.some((q) => q.text === quoteText)).toBeTruthy();
    await page.getByRole('button', { name: /お気に入り/ }).click();
    await expect(page.locator('.favorite-card__quote', { hasText: quoteText })).toBeVisible();
  });

  test('2-1: お気に入り画面でピン留め名言がカード表示', async ({ page }) => {
    await page.goto(APP_URL);
    await page.getByRole('button', { name: /ピン留めする/ }).click();
    await page.getByRole('button', { name: /お気に入り/ }).click();
    await expect(page.locator('.favorite-card')).toHaveCount(1);
    await expect(page.locator('.favorite-card__quote')).toBeVisible();
    await expect(page.locator('.favorite-card__author')).toBeVisible();
  });

  test('2-2: お気に入り編集(正常系)', async ({ page }) => {
    await page.goto(APP_URL);
    await page.getByRole('button', { name: /ピン留めする/ }).click();
    await page.getByRole('button', { name: /お気に入り/ }).click();
    await page.getByRole('button', { name: /編集/ }).click();
    await page.fill('textarea', '編集後の格言');
    await page.fill('input', '編集後の発言者');
    await page.getByRole('button', { name: /保存する/ }).click();
    await expect(page.locator('.favorite-card__quote', { hasText: '編集後の格言' })).toBeVisible();
    await expect(page.locator('.favorite-card__author', { hasText: '編集後の発言者' })).toBeVisible();
    const pinned = await page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '[]'), PINNED_KEY);
    expect(pinned.some((q) => q.text === '編集後の格言' && q.author === '編集後の発言者')).toBeTruthy();
  });

  test('2-3: お気に入り削除', async ({ page }) => {
    await page.goto(APP_URL);
    await page.getByRole('button', { name: /ピン留めする/ }).click();
    await page.getByRole('button', { name: /お気に入り/ }).click();
    await page.getByRole('button', { name: /削除/ }).click();
    await expect(page.locator('.favorite-card')).toHaveCount(0);
    const pinned = await page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '[]'), PINNED_KEY);
    expect(pinned.length).toBe(0);
  });

  test('2-4: 編集時に格言を空欄で保存しようとするとエラー', async ({ page }) => {
    await page.goto(APP_URL);
    await page.getByRole('button', { name: /ピン留めする/ }).click();
    await page.getByRole('button', { name: /お気に入り/ }).click();
    await page.getByRole('button', { name: /編集/ }).click();
    await page.fill('textarea', '');
    await page.getByRole('button', { name: /保存する/ }).click();
    await expect(page.locator('textarea')).toBeVisible();
  });

  test('3-1: 名言を登録(正常系)', async ({ page }) => {
    await page.goto(APP_URL);
    await page.getByRole('button', { name: /名言を登録/ }).click();
    await page.fill('textarea', '新しい格言');
    await page.fill('input', '新しい発言者');
    await page.getByRole('button', { name: /登録してピン留め/ }).click();
    await expect(page.locator('.favorite-card__quote', { hasText: '新しい格言' })).toBeVisible();
    await expect(page.locator('.favorite-card__author', { hasText: '新しい発言者' })).toBeVisible();
    const custom = await page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '[]'), CUSTOM_KEY);
    expect(custom.some((q) => q.text === '新しい格言' && q.author === '新しい発言者')).toBeTruthy();
  });

  test('3-2: 発言者未入力時はAnonymousで登録', async ({ page }) => {
    await page.goto(APP_URL);
    await page.getByRole('button', { name: /名言を登録/ }).click();
    await page.fill('textarea', '匿名格言');
    await page.fill('input', '');
    await page.getByRole('button', { name: /登録してピン留め/ }).click();
    await expect(page.locator('.favorite-card__author', { hasText: 'Anonymous' })).toBeVisible();
  });

  test('3-3: 格言が空欄だと登録できずエラー表示', async ({ page }) => {
    await page.goto(APP_URL);
    await page.getByRole('button', { name: /名言を登録/ }).click();
    await page.fill('textarea', '');
    await page.getByRole('button', { name: /登録してピン留め/ }).click();
    await expect(page.locator('.helper-text')).toHaveText(/格言は必須/);
  });

  test('4-1: フッターナビゲーションで画面遷移', async ({ page }) => {
    await page.goto(APP_URL);
    await page.getByRole('button', { name: /お気に入り/ }).click();
    await expect(page.locator('.section-title', { hasText: 'お気に入りの格言' })).toBeVisible();
    await page.getByRole('button', { name: /名言を登録/ }).click();
    await expect(page.locator('.section-title', { hasText: '名言を登録' })).toBeVisible();
    await page.getByRole('button', { name: /今日の格言/ }).click();
    await expect(page.locator('.today-card')).toBeVisible();
  });

  test('5-1: localStorageのデータ永続化と復元', async ({ page, context }) => {
    await page.goto(APP_URL);
    await page.getByRole('button', { name: /名言を登録/ }).click();
    await page.fill('textarea', '永続格言');
    await page.fill('input', '永続発言者');
    await page.getByRole('button', { name: /登録してピン留め/ }).click();
    await page.reload();
    await page.getByRole('button', { name: /お気に入り/ }).click();
    await expect(page.locator('.favorite-card__quote', { hasText: '永続格言' })).toBeVisible();
    await expect(page.locator('.favorite-card__author', { hasText: '永続発言者' })).toBeVisible();
  });

  test('6-1: quotes.json取得失敗時にエラーバナー表示', async ({ page }) => {
    await page.route('**/quotes.json', route => route.abort());
    await page.goto(APP_URL);
    await expect(page.locator('.error-banner')).toHaveText(/名言の読み込みに失敗/);
  });
});

playwrightのテストを実行してみます。
1件失敗しました。

@playwright-test-healer 「4-1: フッターナビゲーションで画面遷移」のテストに失敗したので修正してください。test-result配下に実行結果はあります。

直してもらおうとするが修正失敗・・・
仕方なく自分で見て、原因を伝えてあげると修正してくれました。
(もしかするとヒーラーを呼び出せてなかったかも)

感想

個人/プロトタイピングレベルは問題ないですね。
今年のクソアプリカレンダーに向けて何か作りたいな。

StitchやOpalなど、今時点では課題も多いものと思いますが、今後も非技術者でもアイデアを形にできるようなものは登場すると思うので、技術者はそこに向き合っていかないといけない。
子供とかに使わせるとまた違った見方をするだろうし、使わせてみたいな。

テストについては、テスト計画上は「エラーが起きること」という記載にも関わらず、実際のテストケースはエラー文言をチェックするのではなくてその場に留まっていることを確認していました。
たぶん主観評価の入らない記述(誰が見ても同じ解釈になる記述)をするための工夫がいるんじゃないかと思います。
また、テスト駆動開発的に、先にプランナーを動かした後に実装するのも良いなと思いました。

今回作った(生成された)コードはこちら。
アプリはGitHub Pagesで見れるようにしてます。
https://github.com/uemegu/ai_develop_test

ちなみに今回用意した格言は以下でした。(だいぶ前にgpt-4oで生成したもの)

「風を追いかける者には、足元の花が見えぬ。」
「磨かれすぎた石は、誰もつまずかない。」
「雨に濡れる木は、陽が射せば虹をかける。」
「道のないところに立つ者は、自分が道だと気づけ。」
「静寂に耳を傾ける者だけが、遠雷を知る。」
「朝焼けを見て、夕焼けを語るな。」
「竹が伸びる音を聞きたければ、笹を揺らせ。」
「右手に持つ水は、左手で飲むべからず。」
「冬の虫は夏に踊らない。」
「石を積む者は、石を積む。」
「冷めたラーメンに後悔は乗らない。」
「靴下が片方ないなら、両方脱げ。」
「炊けたご飯は戻らない。」
「夜中のコンビニで牛乳を買う男に嘘はない。」
「傘を貸したら帰りは走れ。」

Discussion