browser-useでE2Eテストを自動化すると1ドルで44テストが書ける話

E2Eテストはソフトウェアの質を確保する重要なプロセスですが、実施には大きなコストが必要です。パスを指定してテストコードを書く作業、ちょっとしたDOMの変更で通らなくなるテスト。

コードを書くのが好きなエンジニアもテスト駆動が好きなエンジニアもいますが、e2e書くのが大好き！というエンジニアに会ったことはありません。

そしてテック業界での課題感も大きくE2Eテストの自動化やノーコードを目指すサービスも多数存在します。しかし高価なうえ、自動化の精度が十分でない場合も少なくありません。

そんな中で **browser-use** が登場しました。Web開発者にとってブラウザの自動操作といえば思いつくのはe2eテストです。


## 概要

本実験では、以下のツールとサービスを使用してE2Eテストの自動化を試みました。

- **使用ツール**: Python, browser-use, Playwright, Jest
- **実験用サイト**: [Sauce Demo](https://www.saucedemo.com)
  - 色々なテストライブラリのデモで使われているサイト
  - ECサイト風ですが全てモック、アカウント情報は公開されています
- **目標**: 人間の手間を出来るだけ省きe2eテストを行う

先に**結論**を書いてしまうと 
**「それなりに行ける（改善の余地は大いにあり）」** という感じです！

利用したコードをリポジトリにアップしていますので、興味があれば後でも先でもご覧ください！
https://github.com/pppp606/browser-use_e2e_test_automation_labs

## 戦略

まず「このサイトでe2eテストして！」と一発で解決すれば嬉しかったのですが、そこまで上手くはいかず、以下の手順でE2Eテストの自動化を試みました。

1. **browser-useに対象サイトを回遊させ、サイト構成を出力**

2. **サイト構成をもとにページ毎にテストシナリオを出力**

3. **人間がレビューし、必要であれば修正**

4. **browser-useにテストシナリオを渡し、ページ毎にテストコードを生成**

5. **生成されたテストコードを実行**

---

### サイト構成の取得

最初のステップとして、browser-useを利用して対象サイトの構成を取得します。これ以降のプロンプトをページ単位で投げる為にページのリストを作る事が目的です。
サイト全体の処理を一度に投げると上手くいかず、試行錯誤の結果、ページ単位に区切って処理をしてもらう形に変更しました。

また一連で処理をしようとすると上手くいかない（頑なに一部のテストコードしか書かない）原因として、トークンサイズがネックになっているのではと疑っていたのですが、どうもそうではなくbrowser-useが一連の流れをLLMに投げている事に由来する問題なんじゃないかと思います。（色々やってみた結果の憶測です）

#### プロンプト

```python
site_structure_task = f"""
Analyze the website starting from {url}. Identify and output:
1. All accessible pages and subpages within the domain ({url}). Include dynamically loaded content and hidden links.
2. For each page, provide the purpose or functionality in concise terms.
3. Ensure the analysis includes:
   - Static links
   - Dynamic or JavaScript-driven links
   - Form actions and submission endpoints
   - API endpoints if visible
4. For pages with similar structures but different parameters (e.g., query strings like ?id=), group them under one representative page.

## Output JSON Format:
[
  {{ "path": "<path or URL>", "purpose": "<brief description of the page's purpose or functionality>" }},
  ...
]

## Login Information
- id: {user_id}
- password: {password}
"""
```

---

### テストシナリオの生成

次に取得したサイト構造を基に各ページごとのテストシナリオを生成します。
一度、自然言語でシナリオを出力する事で

- 人間のレビューが可能になる
- テストコードの出力が安定する

というメリットがあります。

#### プロンプト

scenario\_language を日本語に設定するとシナリオは日本語で出力されます。

```python
scenario_task = f"""
Generate exhaustive test scenarios for the following page:
- Page: {page_path}
  Purpose: {page_purpose}

For this page, include all possible user actions, such as:
  - Form submissions
  - Button clicks
  - Dropdown selections
  - Interactions with modals or dynamic elements

Test both expected behaviors and edge cases for each action.
Output format:
path: {page_path},
actions:
  - test: <description of action>,
    expect: <expected result>,
  - test: <description of action>,
    expect: <expected result>,

The output must be written in {scenario_language}.

## Root URL
{url}

## Login Information
- id: {user_id}
- password: {password}
"""
```

#### コード

ページ単位でAgentを実行しています。await せずに並列で実行した方が効率が良さそうです。（pythonがよく分かっていません😅）

```python
  for page in site_structure:
    page_path = page.get("path")
    page_purpose = page.get("purpose")

    scenario_task = f"""...."""

    result = await Agent(
        task=scenario_task,
        llm=ChatOpenAI(model="gpt-4o"),
    ).run()

    scenario_content = extract_content(result)
    all_scenarios.append(scenario_content)
```



#### 出力されたシナリオの例

```md
path: /,
actions:
  - test: ユーザー名とパスワードを正しく入力してログインボタンをクリック,
    expect: ログイン成功し、ユーザーダッシュボードにリダイレクト,
  - test: ユーザー名を入力せずにパスワードを入力しログインボタンをクリック,
    expect: エラーメッセージが表示される,
  - test: パスワードを入力せずにユーザー名を入力しログインボタンをクリック,
    expect: エラーメッセージが表示される,
  - test: ユーザー名とパスワードを入力せずにログインボタンをクリック,
    expect: エラーメッセージが表示される,
  - test: 不正なユーザー名を入力し正しいパスワードを入力してログインボタンをクリック,
    expect: エラーメッセージが表示される,
  - test: 正しいユーザー名を入力し不正なパスワードを入力してログインボタンをクリック,
    expect: エラーメッセージが表示される,
  - test: ユーザー名に空白を含めて正しいパスワードを入力してログインボタンをクリック,
    expect: エラーメッセージが表示される,
  - test: 大文字小文字を混ぜて正しいユーザー名とパスワードを入力してログインボタンをクリック,
    expect: ログイン成功し、ユーザーダッシュボードにリダイレクト,
  - test: すべてのフィールドに大量の文字を入力してログインボタンをクリック,
    expect: エラーメッセージが表示されるまたはリクエストが処理されない,
  - test: JavaScriptで無効な入力を生成してログインボタンをクリック,
    expect: 入力が拒否されエラーメッセージが表示される,

path: /checkout-step-one.html,
actions:
  - test: ユーザーがすべてのフィールド（名、姓、郵便番号）を正しく入力し、「Continue」ボタンをクリックする,
    expect: 次のページに進む（/checkout-step-two.html）,
  - test: 名のフィールドのみ入力し、「Continue」ボタンをクリックする,
    expect: エラーメッセージが表示される,
  - test: 姓のフィールドのみ入力し、「Continue」ボタンをクリックする,
    expect: エラーメッセージが表示される,
  - test: 郵便番号のフィールドのみ入力し、「Continue」ボタンをクリックする,
    expect: エラーメッセージが表示される,
  - test: すべてのフィールドを空白のまま「Continue」ボタンをクリックする,
    expect: エラーメッセージが表示される,
  - test: 名に数字を入力し、「Continue」ボタンをクリックする,
    expect: エラーメッセージが表示される,
  - test: 姓に記号を入力し、「Continue」ボタンをクリックする,
    expect: エラーメッセージが表示される,
  - test: 郵便番号にアルファベットを入力し、「Continue」ボタンをクリックする,
    expect: エラーメッセージが表示される,
  - test: 「Cancel」ボタンをクリックする,
    expect: カートページに戻る（/cart.html）
```



### テストコードの生成
最後のステップでは、生成したシナリオに基づいてJestとPlaywrightで実行可能なテストコードを出力します。
自然言語で出力したシナリオをそのままLLMに投げてテストしてもらうという方法も考えられますが、信頼性とコストのバランスを考えると、テストコードを出力する方法が最適だと考えました。

#### プロンプト
scenarioにレビュー済みのシナリオを渡しています。

```python
task = f"""
Based on the provided URL and scenario, generate the necessary test code for end-to-end testing.
The code should be written using Jest and Playwright, including all necessary imports and configurations.
Ensure the output is in a fully executable state, ready to be copied and run immediately.
Do not include any markdown code formatting. Output only the code.

## URL
{url}

## Login Information
- id: {user_id}
- password: {password}

## Scenario
{scenario}
"""
```

#### コード

```python
  for scenario in latest_file_content.strip().split("\n\n"):
    result = await Agent(
      task=task,
      llm=ChatOpenAI(model="gpt-4o", temperature=0.8),
    ).run()

    result_content = extract_content(result)
    save_test_file("./tests", file_name, result_content)
```

ここでもページ毎にテストを生成しファイルに出力しています。


### 生成されたテストコード
それなりに行けている感じがする！！

##### index.test.js

```javascript
const { test, expect } = require('@playwright/test');

test.describe('SauceDemo Login Tests', () => {

  test('ユーザー名とパスワードを正しく入力してログインボタンをクリック', async ({ page }) => {
    await page.goto('https://www.saucedemo.com/');
    await page.fill('input[name="user-name"]', 'standard_user');
    await page.fill('input[name="password"]', 'secret_sauce');
    await page.click('input[name="login-button"]');
    await expect(page).toHaveURL('https://www.saucedemo.com/inventory.html');
  });

  test('ユーザー名を入力せずにパスワードを入力しログインボタンをクリック', async ({ page }) => {
    await page.goto('https://www.saucedemo.com/');
    await page.fill('input[name="password"]', 'secret_sauce');
    await page.click('input[name="login-button"]');
    await expect(page.locator('.error-message-container')).toBeVisible();
  });

  test('パスワードを入力せずにユーザー名を入力しログインボタンをクリック', async ({ page }) => {
    await page.goto('https://www.saucedemo.com/');
    await page.fill('input[name="user-name"]', 'standard_user');
    await page.click('input[name="login-button"]');
    await expect(page.locator('.error-message-container')).toBeVisible();
  });

  test('ユーザー名とパスワードを入力せずにログインボタンをクリック', async ({ page }) => {
    await page.goto('https://www.saucedemo.com/');
    await page.click('input[name="login-button"]');
    await expect(page.locator('.error-message-container')).toBeVisible();
  });

  test('不正なユーザー名を入力し正しいパスワードを入力してログインボタンをクリック', async ({ page }) => {
    await page.goto('https://www.saucedemo.com/');
    await page.fill('input[name="user-name"]', 'invalid_user');
    await page.fill('input[name="password"]', 'secret_sauce');
    await page.click('input[name="login-button"]');
    await expect(page.locator('.error-message-container')).toBeVisible();
  });

  test('正しいユーザー名を入力し不正なパスワードを入力してログインボタンをクリック', async ({ page }) => {
    await page.goto('https://www.saucedemo.com/');
    await page.fill('input[name="user-name"]', 'standard_user');
    await page.fill('input[name="password"]', 'wrong_password');
    await page.click('input[name="login-button"]');
    await expect(page.locator('.error-message-container')).toBeVisible();
  });

  test('ユーザー名に空白を含めて正しいパスワードを入力してログインボタンをクリック', async ({ page }) => {
    await page.goto('https://www.saucedemo.com/');
    await page.fill('input[name="user-name"]', ' standard_user ');
    await page.fill('input[name="password"]', 'secret_sauce');
    await page.click('input[name="login-button"]');
    await expect(page.locator('.error-message-container')).toBeVisible();
  });

  test('大文字小文字を混ぜて正しいユーザー名とパスワードを入力してログインボタンをクリック', async ({ page }) => {
    await page.goto('https://www.saucedemo.com/');
    await page.fill('input[name="user-name"]', 'Standard_User');
    await page.fill('input[name="password"]', 'Secret_Sauce');
    await page.click('input[name="login-button"]');
    await expect(page).toHaveURL('https://www.saucedemo.com/inventory.html');
  });

  test('すべてのフィールドに大量の文字を入力してログインボタンをクリック', async ({ page }) => {
    await page.goto('https://www.saucedemo.com/');
    await page.fill('input[name="user-name"]', 'a'.repeat(1000));
    await page.fill('input[name="password"]', 'b'.repeat(1000));
    await page.click('input[name="login-button"]');
    await expect(page.locator('.error-message-container')).toBeVisible();
  });

  test('JavaScriptで無効な入力を生成してログインボタンをクリック', async ({ page }) => {
    await page.goto('https://www.saucedemo.com/');
    await page.evaluate(() => {
      document.querySelector('input[name="user-name"]').value = '<script>alert(1)</script>';
    });
    await page.evaluate(() => {
      document.querySelector('input[name="password"]').value = '<script>alert(1)</script>';
    });
    await page.click('input[name="login-button"]');
    await expect(page.locator('.error-message-container')).toBeVisible();
  });

});

```

##### checkout-step-one.test.js

ちゃんと beforeEach などの処理も記述されています

```javascript
const { test, expect } = require('@playwright/test');

test.describe('Checkout Step One Tests', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('https://www.saucedemo.com/');
    await page.fill('#user-name', 'standard_user');
    await page.fill('#password', 'secret_sauce');
    await page.click('#login-button');
    await page.goto('https://www.saucedemo.com/checkout-step-one.html');
  });

  test('ユーザーがすべてのフィールドを正しく入力し、「Continue」ボタンをクリックする', async ({ page }) => {
    await page.fill('#first-name', 'John');
    await page.fill('#last-name', 'Doe');
    await page.fill('#postal-code', '12345');
    await page.click('#continue');
    await expect(page).toHaveURL('https://www.saucedemo.com/checkout-step-two.html');
  });

  test('名のフィールドのみ入力し、「Continue」ボタンをクリックする', async ({ page }) => {
    await page.fill('#first-name', 'John');
    await page.click('#continue');
    await expect(page.locator('.error-message-container')).toBeVisible();
  });

  test('姓のフィールドのみ入力し、「Continue」ボタンをクリックする', async ({ page }) => {
    await page.fill('#last-name', 'Doe');
    await page.click('#continue');
    await expect(page.locator('.error-message-container')).toBeVisible();
  });

  test('郵便番号のフィールドのみ入力し、「Continue」ボタンをクリックする', async ({ page }) => {
    await page.fill('#postal-code', '12345');
    await page.click('#continue');
    await expect(page.locator('.error-message-container')).toBeVisible();
  });

  test('すべてのフィールドを空白のまま「Continue」ボタンをクリックする', async ({ page }) => {
    await page.click('#continue');
    await expect(page.locator('.error-message-container')).toBeVisible();
  });

  test('名に数字を入力し、「Continue」ボタンをクリックする', async ({ page }) => {
    await page.fill('#first-name', '12345');
    await page.fill('#last-name', 'Doe');
    await page.fill('#postal-code', '12345');
    await page.click('#continue');
    await expect(page.locator('.error-message-container')).toBeVisible();
  });

  test('姓に記号を入力し、「Continue」ボタンをクリックする', async ({ page }) => {
    await page.fill('#first-name', 'John');
    await page.fill('#last-name', '@#!');
    await page.fill('#postal-code', '12345');
    await page.click('#continue');
    await expect(page.locator('.error-message-container')).toBeVisible();
  });

  test('郵便番号にアルファベットを入力し、「Continue」ボタンをクリックする', async ({ page }) => {
    await page.fill('#first-name', 'John');
    await page.fill('#last-name', 'Doe');
    await page.fill('#postal-code', 'abcde');
    await page.click('#continue');
    await expect(page.locator('.error-message-container')).toBeVisible();
  });

  test('「Cancel」ボタンをクリックする', async ({ page }) => {
    await page.click('#cancel');
    await expect(page).toHaveURL('https://www.saucedemo.com/cart.html');
  });
});
```


## テストを実行してみる
生成されたテストコードを実行してみます！！
結果は44件のテストに対して18件の失敗がありました。

ですが内容を見ていくと、それほどズレたものでもありません。

### 理想のシナリオ通りに実装されていないパターン
名前の入力欄に数字を入力した場合にエラーメッセージが表示されることを期待したテストになります。

```javascript
  test('名に数字を入力し、「Continue」ボタンをクリックする', async ({ page }) => {
    await page.fill('#first-name', '12345');
    await page.fill('#last-name', 'Doe');
    await page.fill('#postal-code', '12345');
    await page.click('#continue');
    await expect(page.locator('.error-message-container')).toBeVisible();
  });
```

テストにつかったサイトがモックのため実際にはエラーにならず正常系として処理されてしまっています。またこれもモックサイトの為か、ボタンイベントが想定とは異なる（人間がみてもそう思う）遷移先というパターンもありました。

ただこれらはテストの問題ではく、また実装にミスがある場合にも気がつけるので正しい失敗だと思います。こう言ったパターンが17件中5件ありました。



### 利用するライブラリが想定と異なるパターン
Playwrightのランナーで記述されている想定でしたが、Jest Circusを利用しているためエラーが発生しているパターンがありました。テストの内容は正しくimport等を変更するだけで解決しました。

これはテストコード生成時のプロンプトで明示すれば解決できそうな問題です。
1ファイル内のテストが全て失敗していて、17件中12件がこのパターンに当たります。

### その他
残り1件は以下のテストで、カートに含んだ商品の合計値を確認するテストです。
beforeEachの処理でカートページに遷移してからテストを実行しているため、`Add to cart`のボタンは存在しません。これはページ単位で独立して推論をさせて事の弊害に思います。

```javascript
  test.beforeEach(async ({ page }) => {
    // Navigate to the login page
    await page.goto(baseURL);

    // Perform login
    await page.fill('input[data-test="username"]', 'standard_user');
    await page.fill('input[data-test="password"]', 'secret_sauce');
    await page.click('input[data-test="login-button"]');

    // Add items to cart
    await page.click('text=Add to cart', { index: 0 });
    await page.click('text=Add to cart', { index: 2 });
    await page.click('text=Add to cart', { index: 4 });

    // Go to cart
    await page.click('.shopping_cart_link');
  });

  test('Verify correct total amount with multiple items', async ({ page }) => {
    // Add items back to compare price
    await page.click('text=Add to cart', { index: 0 });
    await page.click('text=Add to cart', { index: 1 });
    await page.click('text=Add to cart', { index: 2 });

    const items = await page.locator('.cart_item .inventory_item_price');
    let total = 0;
    for (let i = 0; i < await items.count(); i++) {
      const priceText = await items.nth(i).textContent();
      total += parseFloat(priceText.replace('$', ''));
    }

    const totalPriceText = await page.locator('.summary_total_label').textContent();
    const totalDisplayed = parseFloat(totalPriceText.replace('Total: $', ''));
    expect(totalDisplayed).toBe(total);
  });
```


## 掛かった費用
OpenAPIのgpt-4oを使用し、何度もテストを繰り返した結果かかった費用が7ドル程度でした。プロンプトと方法が確立した状態で今回テストに使ったサイトであれば、サイト構成の把握から、シナリオ作成、テストコードの作成までで1ドルも掛かりません。


## 課題

#### プロンプトに改善の余地がある
安定した出力を得る事が出来ずページ単位で処理をしましたが、一連の流れでテストコードの出力ができるとよりユーザーの操作に近いテストができると思います。

#### 差分のみの出力がしたい
一度出力し、また修正したテストコードを活かしつつ、コードの差分のみ検知してテストの変更、追加ができるとより効率的です。
どういった方法がよいのかパッとは思いついていません。

#### 無限ループに陥っていた
何度もテストを繰り返したうちの一度だけなのですが、シナリオ作成の段階でブラウザが同じ操作を繰り返し、気がつくと90ステップぐらいLLMとのやり取りを繰り返していました。
何が原因なのかよく分からなかったのですが、browser-useの設定で制御しておいた方が安全そうです。

#### セキュリティの問題
今回はモックサイトを利用しているので問題はありませんが、実際のサイトを対象にする場合、ユーザー情報やセキュリティに関わる情報をLLMに渡すべきではありません。
ローカルのLLMを利用する、またAzure OpenAI Serviceなどで安全な環境を作る対策が必要です。

#### カバレッジを取る
自動化すればするほど、シナリオまたはテストコードのカバレッジを確認する術が必要になってくると思います。

## まとめ
E2Eテストを人間が書く時代は終わりそうです。
とは言え、AIと齟齬のないコミュニケーションをとる為の信頼性の高いツールとしてプログラミング言語が最適な時代はもう少し続くように思いました！