PlaywrightのPOM化 & CSVデータ管理をAIと最適に会話する試み

目的
Playwrightを活用した Page Object Model (POM) 化 と CSVデータ取り込みによるテスト管理 について、AIとどのように会話すれば 最適な回答を得られるか試みる。
 AIと最適に会話するためのポイント

# ☆具体的な質問をする
AIへの質問は 明確で具体的に すると、より正確な回答を得られます。
:relaxed:良い例:
>Playwrightで POM を活用したログインテストを設計するにはどうすればいいですか？


:sweat_smile:避けるべき例:
>PlaywrightでPOMを作りたい。助けて！

# ☆詳細な要件を伝えることで、コード例や設計戦略を得やすい！

エラーメッセージや環境情報を共有
⇒トラブルシューティングの際は、エラーメッセージの全文と環境情報を含める と、AIは正確な原因を特定し、適切な解決策を提案しやすくなります。
:relaxed:良い例:
>CSVデータの `actionTarget` に `Twilio Voice JavaScript SDK v2` を設定すると、
以下のエラーが出ます。  
`Error: locator.waitFor: Test timeout of 120000ms exceeded`
Playwrightのバージョン: 1.38.0  
Node.jsのバージョン: 18.17.0


:sweat_smile:避けるべき例:
>CSVデータを使っているけどリンクがクリックできない。なんで？


※注）エラーメッセージが具体的であるほど、AIの回答も最適化される。

# ☆ステップごとに会話する
一度に多くの質問をすると、AIの回答が曖昧になる ことがあります。
そのため、テーマを分割して段階的に会話するのがベスト です。
:relaxed:良い会話例:
>- POMの設計について聞く
PlaywrightでログインページをPOM化する方法を教えてください。
>- セッション管理について聞く
POM化したログインページでセッションを維持する方法は？
>- CSVデータ適用の方法について聞く
テストケースをCSVから読み込んで実行する方法を教えてください。
などなど。。。

:cake:公式リソースと組み合わせる
AIの回答は強力ですが、公式ドキュメントを 併用 することで、より確実な情報を得られます。
| リソース | リンク | 
|:--------------------------|:---------------------------------------------------|
| Playwright公式ドキュメント | https://playwright.dev | 
| Microsoft Copilotの情報 | https://copilot.microsoft.com | 
| AIのプライバシーポリシー | https://privacy.microsoft.com/en-us/privacystatement | 

✨試してみて、公式情報を確認しながらAIに質問すると、さらに改善点を修正していく感じ


# 下記に自分のローカル環境で検証した一例を記載します。※参考になれば幸いです。
参考：https://qiita.com/kzk_nemoto/items/567b698a44cd045f72ea
１、環境情報
```
OS：Windows10、VSC
参考：https://playwright.dev/docs/getting-started-vscode

 $npx --v
 10.9.2
 $ node -v
 v22.14.0
 $ npx playwright --version
 Version 1.52.0
```

２，playwrightの記録機能で検証スクリプト元ネタ作成
```
$ npx playwright codegen http://localhost:8080/LoginApp/ -o test.login.spec.ts
```
※検証用のログインアプリは最後に記載（これも実はAIで:information_desk_person_tone2:）

・playwrightの記録結果

```
import { test, expect } from '@playwright/test';
test('test', async ({ page }) => {
  await page.goto('http://localhost:8080/LoginApp/');
  await page.locator('input[name="username"]').click();
  await page.locator('input[name="username"]').click();
  await page.locator('input[name="username"]').fill('admin');
  await page.locator('input[name="password"]').click();
  await page.locator('input[name="password"]').fill('password123');
  await page.getByRole('button', { name: 'ログイン' }).click();
  await page.getByText('ようこそ, admin さん！ メニュー ログアウト').click();
  await page.getByRole('link', { name: 'メニュー' }).click();
  await page.locator('html').click();
  await page.getByRole('link', { name: 'Twilio Voice JavaScript SDK v2' }).click();
  await page.goto('http://localhost:8080/LoginApp/menu.jsp');
});
```

３，これをAIにCSV形式でテストケースを取り込む方式に変更とセッション管理するように依頼
```     
import fs from 'fs';
import path from 'path';
import { parse } from 'csv-parse/sync';
import { test } from '@playwright/test';

// セッションファイルのパス
const sessionFilePath = path.resolve(__dirname, 'session.json');

// セッションファイルの存在を確認
if (!fs.existsSync(sessionFilePath)) {
    throw new Error(`⚠ セッションファイルが見つかりません: ${sessionFilePath} を生成してください！`);
}

// セッション情報を適用（全テストでログイン済み状態を維持）
test.use({ storageState: sessionFilePath });

// CSVファイルのパス
const csvFilePath = path.resolve(__dirname, 'test-cases.csv');

// CSVデータを読み込んでパース
const cases = parse(fs.readFileSync(csvFilePath, 'utf-8'), {
    columns: true,
    skip_empty_lines: true,
    relax_column_count: true
});

console.log('✔ CSVデータ読み込み完了:', cases);

test.describe('AI生成を利用したテスト', () => {
    cases.forEach((data, index) => {
        test(`テストケース ${index + 1}: ${data.case || '不明なケース'}`, async ({ page }) => {
            test.setTimeout(120_000);
            console.log(`実行中のテスト: ${data.case}`);
            console.log(`現在のページURL: ${page.url()}`);

            switch (data.case?.toLowerCase()) {
                case 'login':
                    console.log(`→ [${data.case}] ログイン処理開始: ユーザー名=${data.username}`);
                    await page.goto('http://localhost:8080/LoginApp/');
                    await page.locator('input[name="username"]').fill(data.username);
                    await page.locator('input[name="password"]').fill(data.password);
                    await page.getByRole('button', { name: 'ログイン' }).click();

                    // 認証後のページ遷移を待機
                    await page.waitForURL('http://localhost:8080/LoginApp/welcome.jsp', { timeout: 60000 });

                    // セッション情報を保存（初回ログイン時のみ）
                    await page.context().storageState({ path: sessionFilePath });
                    console.log(`✔ ログイン完了 - セッション情報を保存`);
                    break;

                case 'welcome':
                case 'menu':
                    console.log(`→ [${data.case}] ログイン済みのセッションでページ遷移`);

                    if (data.actionType?.toLowerCase() === 'link') {
                        console.log(`→ [${data.case}] リンククリック: ${data.actionTarget}`);

                        // ページロード完了を待機
                        await page.waitForLoadState('networkidle');

                        // `href` があるか確認
                        let linkLocator;
                        if (data.actionTarget.startsWith('http')) {
                            linkLocator = page.getByRole('link', { name: data.actionTarget });
                        } else {
                            linkLocator = page.locator(`text=${data.actionTarget}`);
                        }

                        // リンク要素の存在を確認
                        await linkLocator.waitFor({ state: 'visible', timeout: 60000 });

                        // 強制クリック
                        await linkLocator.click({ force: true });

                        // クリック後のページ遷移を待機
                        await page.waitForURL(data.actionTarget, { timeout: 60000 });
                    } else {
                        throw new Error(`未定義のアクションタイプ: ${data.actionType}`);
                    }
                    break;

                default:
                    throw new Error(`未定義のケース: ${data.case}`);
            }
        });
    });
});
```

```test-cases.csv
case,username,password,actionType,actionTarget
Login,admin,password123,url,http://localhost:8080/LoginApp/welcome.jsp
welcome,,,link,http://localhost:8080/LoginApp/menu.jsp
Menu,,,link,https://qiita.com/halapolo/items/6c0dd905c665fd2e7e51
```
```session.json
{
  "cookies": [
    {
      "name": "JSESSIONID",
      "value": "B0A9D5433D1A99B0C660FB1EC67FC0E7",
      "domain": "localhost",
      "path": "/LoginApp",
      "expires": -1,
      "httpOnly": true,
      "secure": false,
      "sameSite": "Lax"
    }
  ],
  "origins": []
}
```

◆https://www.npmjs.com/package/csv-parse
```
npm i csv-parse
```

４，これをPOM化対応にAI依頼
```AI.Test.Pom.spec.ts
import fs from 'fs';
import path from 'path';
import { parse } from 'csv-parse/sync';
import { test } from '@playwright/test';
import { LoginPage } from './LoginPage';
import { WelcomePage } from './WelcomePage';
import { MenuPage } from './MenuPage';

// セッションファイルのパス
const sessionFilePath = path.resolve(__dirname, 'session.json');

// セッションファイルの存在を確認
if (!fs.existsSync(sessionFilePath)) {
    throw new Error(`⚠ セッションファイルが見つかりません: ${sessionFilePath} を生成してください！`);
}

// セッション情報を適用
test.use({ storageState: sessionFilePath });

// CSVファイルのパス
const csvFilePath = path.resolve(__dirname, 'test-cases.csv');

// CSVデータを読み込む
const cases = parse(fs.readFileSync(csvFilePath, 'utf-8'), {
    columns: true,
    skip_empty_lines: true,
    relax_column_count: true
});

console.log('✔ CSVデータ読み込み完了:', cases);

test.describe('AI生成を利用したテスト', () => {
    cases.forEach((data, index) => {
        test(`テストケース ${index + 1}: ${data.case || '不明なケース'}`, async ({ page }) => {
            test.setTimeout(120_000);
            console.log(`実行中のテスト: ${data.case}`);

            const loginPage = new LoginPage(page);
            const welcomePage = new WelcomePage(page);
            const menuPage = new MenuPage(page);

            switch (data.case?.toLowerCase()) {
                case 'login':
                    await loginPage.navigate();
                    await loginPage.login(data.username, data.password);

                    // 初回ログイン時にセッションを保存
                    await page.context().storageState({ path: sessionFilePath });
                    console.log(`✔ ログイン完了 - セッション情報を保存`);
                    break;

                case 'welcome':
                    await welcomePage.navigate();
                    await welcomePage.clickMenu(data.actionTarget);
                    break;

                case 'menu':
                    await menuPage.navigate();
                    await menuPage.clickLink(data.actionTarget);
                    break;

                default:
                    throw new Error(`未定義のケース: ${data.case}`);
            }
        });
    });
});
```
```LoginPage.ts
import { Page } from '@playwright/test';

export class LoginPage {
    constructor(private page: Page) {}

    async navigate() {
        await this.page.goto('http://localhost:8080/LoginApp/');
    }

    async login(username: string, password: string) {
        await this.page.locator('input[name="username"]').fill(username);
        await this.page.locator('input[name="password"]').fill(password);
        await this.page.getByRole('button', { name: 'ログイン' }).click();

        // 認証後のページ遷移を待機
        await this.page.waitForURL('http://localhost:8080/LoginApp/welcome.jsp', { timeout: 60000 });
        console.log(`✔ ログイン成功`);
    }
}
```
```WelcomePage.ts
import { Page } from '@playwright/test';

export class WelcomePage {
    constructor(private page: Page) {}

    async navigate() {
        await this.page.goto('http://localhost:8080/LoginApp/welcome.jsp');
    }

    async clickMenu(linkName: string) {
        await this.page.waitForLoadState('networkidle');
        // `href` で要素を検索する
        const linkLocator = this.page.locator(`a[href="${linkName}"]`);
        const menuLink = this.page.getByRole('link', { name: 'メニュー' });

        await menuLink.waitFor({ state: 'visible', timeout: 60000 });
        await menuLink.click({ force: true });

        // ページ遷移の待機
        await this.page.waitForURL(linkName, { timeout: 60000 });
        //await this.page.waitForURL('http://localhost:8080/LoginApp/menu.jsp', { timeout: 60000 });
    }
}
```

```MenuPage.ts
import { Page } from '@playwright/test';

export class MenuPage {
    constructor(private page: Page) {}
    
    async navigate() {
        await this.page.goto('http://localhost:8080/LoginApp/menu.jsp');
    }
    async clickLink(linkName: string) {
        console.log(`→ メニューのリンククリック: ${linkName}`);
    
        // ページロード完了を待機
        await this.page.waitForLoadState('networkidle');

        // `href` で要素を検索する
       const linkLocator = this.page.locator(`a[href="${linkName}"]`);
       const menuLink = this.page.getByRole('link', { name: 'Twilio Voice JavaScript SDK v2' });

        await menuLink.waitFor({ state: 'visible', timeout: 60000 });
        await menuLink.click({ force: true });

        // ページ遷移の待機
        await this.page.waitForURL(linkName, { timeout: 60000 });
    }
}
```

[検証アプリ及び検証スクリプトZIP](https://drive.google.com/file/d/1UL6UrQ-thdU-rf3IRpH82cmKh2ifQsoW/view?usp=drive_link)

５，テスト結果
```
$ npx playwright test AI.Test.Pom.spec.ts --ui 
```
![Ai.pom.snapshot.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2821899/f26014a7-a302-429d-9304-6307085cf921.jpeg)