Claude Code GitHub ActionsとTerraformの組み合わせはいいぞ

こんにちは、JX通信社のCTOの小笠原(@yamitzky)です。

この記事では、TerraformとClaude Code GitHub Actionsを活用した、権限管理の効率化の取り組みについてご紹介します。

権限管理の課題と Terraform による IaC 化

JX通信社では、Google CloudやAWS、GitHub、Cloudflareといった複数のクラウドサービスを利用しています。もともとはすべて手作業で権限管理を行っていましたが、以下のような課題がありました。

  • 管理の属人化: 特定の管理者にしか設定が分からず、作業が集中してしまう。
  • 変更履歴の不透明性: 「いつ」「誰が」「なぜ」権限を変更したのか追跡するのが難しい。
  • 複雑な設定の手間: Workload Identity Federationの設定など、複雑な設定が必要な場合がある。

これらの課題を解決するため、権限設定を Terraform を用いてコード化(IaC: Infrastructure as Code)し、GitHub で一元管理できるよう、徐々に移行を進めています。

例えば、以下のようにTerraformのコードを書くだけで、GitHubにメンバーを招待できます。

resource "github_membership" "yamitzky" {
  username = "yamitzky"
  role     = "member"
}

※実際にはmodule化をすることで、より簡単・汎用的にしています。

現在は、下記の設定などをTerraformで管理し、Pull Requestベースで運用しています。

  • GitHubのアカウントやリポジトリ
  • Google Cloudのプロジェクトやロール
  • Cloudflareのアカウントやロール
  • Workload Identity設定(GitHub Actionsから各種クラウドに対しての認証)

Terraform による権限管理の問題

Terraformによる権限管理はよく行われていますが、一つ、大きな問題があります。

ずばり、Terraformの構成を書くのが難しくて面倒くさい! ということです。言い換えると「学習コストが高い」ということです。

JX通信社には普段Terraformを書いていないメンバーもいるため、「権限を追加してほしい場合は、Terraformのコードを書いてください」というルールにするのはややハードルが高いです。管理者が代理でTerraformを書く形だと、「管理者への作業集中」という課題は解決できません。

Claude Codeによる自動化で、誰でも権限申請

この「Terraformを書くのが大変」という問題を解決するために、Claude Code GitHub Actionsを導入し、AIによるTerraformコードの自動生成・提案の仕組みを構築しました。

Claude Code GitHub Actions は、Claude CodeによるAIコーディングの仕組みをGitHub Actions上で実行できるものです。

公式ドキュメント紹介記事がたくさんあるので説明を省略します。

申請から適用までの流れ

この仕組みの具体的な流れは以下の通りです。

  1. Issue の作成: 権限を申請したい人は、用途別に用意された Issue テンプレートを使って Issue を作成します。
  2. AIによるコード生成: Issue が作成されると、GitHub Actionsが実行され、AI(Claude Code)がIssueの内容を解釈し、権限設定用のTerraformコードを自動で生成・修正します。
  3. Pull Request の作成: Claude Codeが生成したコードを元に、Pull Requestを作成します。
  4. terraform planの実行: GitHub Action上で、terraform fmt, validate, planを実行し、結果をPull Requestにコメントします。
  5. レビューとマージ: 申請内容と生成されたコード、terraform planの内容をレビューし、問題がなければ管理者がマージします。マージされると、本番環境に権限設定が適用されます。

この仕組みにより、Terraform の知識がないメンバーでも、Issueを作成するだけで、セルフサービスで権限申請を行えるようになりました。

具体例:GitHubメンバーの招待

例えば、新しいメンバーをGitHubに招待したい場合、申請者は以下のようなIssueを作成します。

---
name: GitHubメンバー招待・管理
about: GitHub組織へのメンバー招待や権限変更の依頼
title: '[GitHub Member] '
labels: ['github', 'member']
assignees: []

---

## 依頼内容
- [ ] 新しいメンバーの招待
- [ ] 既存メンバーの権限変更
- [ ] メンバーの削除

## メンバー情報

### GitHubユーザー名
<!-- 例: example-user -->

### メールアドレス
<!-- 例: example@jxpress.net -->

### 権限レベル
- [ ] admin (管理者)
- [x] member (一般メンバー)

### 所属チーム(分かる場合)


---

@claude 上記の内容でGitHubメンバーをお願いします。

ポイントは、最後に @claude とメンションしている点です。claude へのメンションをあらかじめテンプレートに入れておくことで、申請者はユーザー名などを書いて投稿するだけで、権限申請ができるようになります。また、事前設定しておきたい原則ルール(例:adminではなくmember権限を原則とするなど)については予めテンプレートに記入しています。

GitHub Actions の設定

基本的には公式サンプルを踏襲していますが、2点、工夫している箇所があります。

JX通信社では、主にGoogle Cloudを利用しているため、Claude Code GitHub Actions の設定はGoogle CloudのWorkload Identity Federationを利用し、Vertex AI経由で実行しています。Claude CodeはAWSやGoogle Cloudでも使えるため、(SaaSの)Claude自体の利用が始まっていないような会社でも、支払いや許可などの点で利用しやすいのではないかと思います。

また、Claude Codeの実行は数分かかり、GitHub Actionsの費用を浪費してしまいます。そこで、余っているPCで組んだKubernetesクラスター上にActions Runner Controllerを導入し、GitHub Actionsを実行することで、費用を抑えています。

name: Claude Code Action

permissions:
  contents: write
  pull-requests: write
  issues: write
  id-token: write  

on:
  issue_comment:
    types: [created]
  pull_request_review_comment:
    types: [created]
  issues:
    types: [opened, assigned]
  pull_request_review:
    types: [submitted]

jobs:
  claude-code-action:
    if: |
      (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
      (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
      (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
      (github.event_name == 'issues' && contains(github.event.issue.body, '@claude'))
    runs-on: kubernetes
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 1

      - name: Generate GitHub App token
        id: app-token
        uses: actions/create-github-app-token@v2
        with:
          app-id: ${{ secrets.CLAUDE_CODE_APP_ID }}
          private-key: ${{ secrets.CLAUDE_CODE_APP_PRIVATE_KEY }}

      - name: Authenticate to Google Cloud
        id: auth
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: '(略)'
          service_account: '(略)'

      - name: Run Claude PR Action
        uses: anthropics/claude-code-action@beta
        with:
          github_token: ${{ steps.app-token.outputs.token }}
          use_vertex: "true"
          model: 'claude-sonnet-4@20250514'
        env:
          ANTHROPIC_VERTEX_PROJECT_ID: ${{ steps.auth.outputs.project_id }}
          CLOUD_ML_REGION: us-east5

今後は、CI失敗時のレビューや、terraform fmtなど自動でのツール実行なども自動化させていきたいです。

CLAUDE.mdの設定

CLAUDE.mdには以下の内容だけを設定しています。*1

このリポジトリは Terraform で作られています。Terraform を使って、Google Cloud や AWS などのリソースを管理することを目的としています。
モジュール定義の書き方は @README.md を参照してください。
OIDC 設定を依頼された際は、サンプルとなる GitHub Actions の設定も教えて下さい。
日本語で書いてください。

その代わりに、READMEには各種権限管理モジュールの説明やサンプルなどを充実させ、人間にもAIにも優しいドキュメントを充実させています。

まとめ

本記事では、TerraformとClaude Code GitHub Actionsを組み合わせ、権限管理を効率化・自動化した取り組みについてご紹介しました。

Terraformでの権限管理自体はやっている会社も多いと思いますが、AIコーディングと組み合わせることで、学習コストの壁を取り払うことができました。ぜひ参考にしてください!

*1:意味のある設定になっているか検証をしていないので、誤り等あればご指摘ください

属人化を防ぎ自己管理型になるためにスクラムチームが始めた取り組み

お久しぶりです。スクラムマスターの@sakebookです。
Switch 2が当選してなくて残念ですが、MtGのFFコラボがあるので楽しめてます。

自己管理されたスクラムチームであっても、無意識のうちに得意な人が得意なタスクを手に取り、結果として知識が偏ってしまう…。そんな経験はないでしょうか。それはチームのパフォーマンスを一時的に高めますが、同時に「〇〇さんがボトルネックになる」という将来のリスクも育てています。この『短期的なスピード』と『長期的なチーム力』のジレンマは、チームの成熟度が問われる課題です。

私達のチームでは、属人化を防ぐためにいくつかの取り組みを始めて、うまくいっています。今では大体のことが、チームの誰がやることになっても「わからない」とかはない状態になっています。

前提

私達はPO、SM、エンジニア3名の、合計5人のチームです。なので、エンジニア3人の中で属人化を防げているという話です。
メンバーの減少があって3人ですが、4-5人のときにも属人化を減らせていたと思います。
スプリントは1週間で、チームはフルリモートの環境です。

取り組み

「計画者」と「実装者」をあえて分ける

スプリントプランニングのトピック3「選択した作業をどのように成し遂げるのか?」を、私達はエンジニアで詳細見積もりという枠で行っています。

そこでは、

  • スプリントに積んだプロダクトバックログアイテムに仮に個人をランダムにアサインし、どのように着手して進めるのか各自作業計画を立てる
    • 作業する単位で子課題を作成する
  • その後、各自作業計画を発表して、疑問点や考慮漏れなどを皆で洗い出す
  • そして最後に仮のアサインを外し、スプリント中の実装担当者をアサインする
    • すべてのアサインを決めることはせず、デイリースクラムで調整する

という流れで作業計画を立てています。この作業計画では、コードのこの関数を修正するという話まで行います。

この詳細見積もりを行うことで、

  • 皆が着手する課題に対して同じ理解で着手できる
  • 事前に問題点に気づきやすくなる
    • この作業は他のリリースに影響するので別途ブランチを切ったほうがいい
    • 不確実性が高いのでペアで進めたほうがいい
    • 依存関係があるのでxxまでに終わってないとスプリントゴールに影響しそう
    • などなど

という効果がありました。
私達のチームでは木曜をスプリント始まりにしているのですが、木曜はほぼスクラムイベントで埋まります。この詳細見積もりは2時間枠を取っています。途中休憩を挟みつつ、短く終わるときもあれば長くなるときもあります。

スプリントをまたいだタスクは別メンバーが引き継ぐ

こちらも詳細見積もりでの取り組みです。
前回のスプリントの残りがあったときには、着手していた人に作業計画を共有してもらいます。
アサインを検討するときには、着手していた人以外が行うようにしています。

これは

  • 解決しなかった問題が解決する場合もあるし、複数の視点で見れる
  • 一人が詰まっててスプリントをまたいでしまった可能性もあるので、それを次回にも起こらないように防ぐ

という効果があります。 残りがあったときは、スプリント中におかわりして追加したものであることが多いので、バトンタッチすることで属人化を防ぐ役割もあります。

「自分が作っていない機能」をレビューで発表する

スプリントレビューのための準備の時間をチームで確保するようになりました。スプリントレビュー準備という枠です。
そこでは、スプリントゴールと、スプリントプランニングのときに話したスプリントレビュー予定の内容を照らし合わせてどんなインクリメントを提示できるかを話します。
共有する内容の大筋と発表者を決めるのですが、その内容にあまり関わっていなかった人に発表してもらうようにしています。

こうすることで

  • スプリント内で何に取り組んでいたのかの理解を底上げする
  • 作ったけどなんの課題解決につながってるのかわからないというのを防ぐ
  • 皆でやってる意識が持てる
  • ドメイン知識も身についていく

という効果があります。
スプリントレビュー準備では、プロダクトゴールに近づくために必要なことについても考え、スプリントレビューでPOと話す内容として整理します。

AIへの指示(プロンプト)とその結果の要約を残す

JXではいくつものAI Coding AgentやAI Editorを活用していますが、その効果的な使い方は個人の経験に依存しがちです。これも一種の属人化と言えます。そこで、性能や活用具合による差を少なくし、チーム全体の生産性を底上げするためにこの取り組みを始めました。
知見共有も兼ねて、AIに指示させたものはサマリーを作成して特定のディレクトリに保存するというのを試験的に行っています。
AIが、過去の経緯などを調べるためにそのディレクトリを活用するケースもあって、指示の安定化と性能の底上げに貢献しています。

サマリー作成に使うテンプレートの一部

過去のサマリー

取り組みの始まり方

これらの取り組みははじめからあったわけでもなく、同時に始まったものでもありませんでした。
スプリントを繰り返し、スプリントレトロスペクティブで振り返る中で生まれました。
私達のチームでは、スプリントレトロスペクティブの手法を固定せず、複数利用しています。
手法によって話の出やすさや話の切り口は異なります。改善が滞っているチームではいつもと異なる手法を採用してみるというのは効果的かもしれません。
今回紹介した取り組みも、自分たちにも合いそうなものがあったなら改善の一つとしてTRYしてみてください。

Datadogとクラウドの費用を見直し、60%以上費用を削減した話

CTOの小笠原(@yamitzky)です。今日は、JX通信社でも利用しているモニタリングの SaaS 「Datadog」の利用費用を削減した話を書きます。

JX通信社とDatadog

Datadog は「クラウド アプリケーションのための モニタリングとセキュリティ プラットフォーム」です(公式サイトより)。特に主要な使い方としては、サーバーの各種モニタリングや、そのアラート検知などのために利用されることが多いと思います。

JX通信社では、FASTALERTなどのシステム構築のためにGoogle CloudやAmazon Web Servicesなど複数のクラウドサービスを利用していますが、それらを統合的に監視するためにもDatadogを活用しています。細かい利用用途としては以下の通りですが、「AWSとGoogle Cloudを横断でサービス監視する」という用途がメインです。

  • Google Cloud、AWSのマネージドサービス(例:ロードバランサー)の指標の監視
  • 一部、VPS で構築されているシステムの監視
  • Synthetic モニターによる、API の外形監視
  • 朝会で確認するためのダッシュボード

Datadogには他にもたくさんの便利な機能がありますが、弊社の場合は、CloudWatch (Amazon)やCloud Monitoring (Google Cloud)自体での監視も併用しているため、活用の用途は限られていました。

昨今の円安による為替レートの悪化や、Datadogを活用しているシステム数の増加などにより、社内のDatadog利用料金が増大しており、最適化を行う必要がありました。

かかっている費用を“正確に”把握する

Datadog 利用にかかる費用は「Datadogの請求書に記載された費用」だけではありません。 「Datadogを利用するためにGoogle Cloud や AWSからデータを取り込む費用」もかかっており、これらの費用も合わせて最適化を行うことが重要です。

JX通信社の場合、Amazon CloudWatchの指標を取得する費用(CW:GMD-Metrics)や、Google Cloudの指標を取得する費用(Monitoring API Requests)が、Datadogと同じくらいかかっていました。これらも、Datadogが指標を取り込むためにかかっている費用です。

Datadogの管理画面から見れる「Cost Summary」の数字と、AWS、Google Cloudの費用を合わせて分析した結果、次のような比率であることがわかりました。

どこでかかる費用か 名称 比率 補足
Datadog Serverless Invocations 16% Cloud Runなどのサーバーレスなサービスの実行回数に応じた従量課金
Serverless App Instances 14% Cloud Runなどのサーバーレスなサービスの数に応じた従量課金
Synthetics API Test Runs 11% Syntheticsテストの実行回数の従量課金
(その他) 6%
Google Cloud Monitoring API Requests 24% API呼び出し数の従量課金 ≒ プロジェクトが多いほど費用がかかる
AWS CW:GMD-Metrics 29% API呼び出しによる取得指標数の従量課金 ≒ 指標が多いほど費用がかかる

つまり、主要な費用は、

  • Datadogのサーバーレス系の従量課金 (30%)
  • Amazon CloudWatchからの指標の取り込み (29%)
  • Google Cloud Monitoringからの指標の取り込み (24%)
  • Syntheticsテストの従量課金 (11%)

に占められていたということになります。これはあくまで JX通信社のケースですので、会社によって全く異なると思います。特に、JX通信社はサーバーレスを活用したシステム構築が多いため、Infra Hosts=ホスト自体の監視にほぼ費用がかかっていません。

実施した施策

分析の結果、何に費用がかかっているかが判明したので、地道に削っていきます。

サーバーレス系の従量課金の削減

Datadogはサーバーレスなシステムの監視を行う機能があり、そのシステム数や呼び出し数に応じて費用がかかります。

docs.datadoghq.com

説明には「Datadog内で追跡・監視されている呼び出しとアクティブな Lambda 関数の組み合わせ」とありますが、Cloud Runなども同様に費用がかかります。ただし、Lambdaと同じく「アクティブ」かつ「Datadog内で追跡・監視されている」システムのみが対象となります。そのため、(全てのシステムを取り込まず) 本当に必要なシステムだけをフィルターすれば、費用を抑えることが可能です。

JX通信社の場合は、監視していないCloud Runのデータ取り込みに費用がかかってしまっていたため、 service_name: で必要なサービス名のみを指定することで限定し、費用を抑えました。このあたりのフィルター方法が公式ドキュメントに見つけられなかったので、試行錯誤に時間がかかっていました。

CloudWatchからの指標の取り込みを限定する

Amazon Cloud CloudWatchからDatadogへのデータの取り込みは、「CloudWatchのAPIなどをポーリング式で取得する方法」と「ストリームで取得する方法」があります。JX通信社の場合は「ポーリング式」で取得をしていました。*1

「ポーリング式」の場合、10分おきにバッチ処理が発火し、CloudWatch の GetMetricData API を叩き続けます。GetMetricData の料金 は指標の合計取得数に応じてかかります。例えば10,000程度の指標があった場合、月間で4,320万メトリクス分の取得になり、これはおよそ7万円/月の費用に相当します*2

そこで、「CloudWatch自体にある、指標の種類が多いが活用できていない指標を見直す」「Datadog が取り込む対象のリージョン、サービス、リソースを限定する」という2つの方針で、CloudWatchから取り込む指標の数を減らすことに取り組みました。

特に後者について、

  • 全リージョンを取得 → 日本のリージョンのみを有効化
  • 全サービスを取得 → 指標数が多く活用できてなかった EBS などを無効化し、重要なサービス(Lambda、ECS など)のみを有効化
  • Application Load Balancer の全ターゲットグループの指標を取得 → 「datadog:enabled」というタグがついた、重要サービスのみの指標を取得

といった設定変更を行なっています。ただし「リソース」の限定については Lambda、EC2、ロードバランサー、RDSなどが対象で、ECSのサービス名指定はできないようでした。

Cloud Monitoringの呼び出しを削減する

Cloud MonitoringからDatadogへの取り込みについても、AWSと同様、ポーリング式で行われています(5分ごと)

Cloud Monitoring のAPIは、AWSとは異なり、API の呼び出し数に応じて費用がかかります。Datadogからプロジェクトごとに呼び出しが行われるますが、デフォルトではおおよそ 1万円/月 × プロジェクト数の費用がかかります*3つまり実質的には、Google Cloudのプロジェクト数に応じた費用です。

こちらも、有効化するサービスの数を限定することで、Cloud Monitoringの費用を抑えることができます。2023年の公式ブログにはない機能だったので、この1-2年で設定できるようになったようです。

実際活用できていないプロジェクトをDatadogの監視対象から外すなどの地道な変更も行いました。

効果の確認

まず、Datadogの費用について、今回削減に取り組んだ指標を抜粋して記載しました。特にサーバーレス関連の費用を大きく下げることができました。11月の途中に設定変更をしているため、12月にかけて削減されるような形になっています。

次に、AWSについて、Cost ExplorerのGMD-Metricsの使用量(Metrics数)を日次で集計しました。こちらも大きく削減できています。

最後に、Google Cloud Monitoringについても、Cloud Billingの画面から確認し、Cloud Monitoring API Requests が下がっていることを確認しました。

終わりに

今回は、JX通信社で取り組んだ、Datadog関連の費用最適化について共有しました。

Datadogは機能が多いため、会社によってコストのかかり方は全く異なると思いますが、指標取得関連の費用が大きくかかっている会社は多いのではないでしょうか。「Datadogだけでなく、関連費用も見直す」「細かく有効化を切り替える」など、今回の取り組みを参考にしていただけたら幸いです。

*1:ストリーム方式で取得する方法に移行することでコストが下げられる可能性もありますが、JX通信社の場合は逆に大幅に費用が上がってしまったため、断念しています。EC2の情報を意図せず取得してしまい、EC2ホスト台数分の追加費用が大きくかかってしまいました。フィルターすることでEC2の費用を抑えられる可能性もありますが、検証していません

*2:単純計算のため、実際には増減すると思います

*3:newmoさんのブログ でも同様の費用だったので、プロジェクトの規模は大きく影響しないと思われます

browser-use を使って情シス業務を自動化するための実践的テクニック

こんにちは、CTOの小笠原(@yamitzky)です。

最近、LLMを使ってブラウザ操作を自動化する、browser-use が流行っていますね! 今回は、毎月実施している情シスタスクの一つをテーマに、browser-use で業務自動化できるかを検証してみました。

browser-use とは

browser-use は、AIエージェントを使ってブラウザ操作を自動化するツールです。ブラウザ操作には内部的には playwright を使い、AI 部分は langchain を使っています。また、タスクを遂行するためのエージェントとしての仕組み(タスクを分解して、ネクストアクションを決め、ゴールが達成できているかを評価する)も備わっています。

そのため、ざっくりと「◯◯◯のサイトを開いて、◯◯◯の予約をして」というと、ブラウザを動かしてゴールを達成してくれます。

browser-use.com

ただし、「業務を自動化する」となるといくつかハイコンテキストな部分があり、雑に指示するだけでは難しいな、という印象です。

自動化したい情シスタスクの概要

情シス部門では毎月、「アカウントの棚卸し」という業務を行っています。これは、各種SaaSサービスにログインし、アカウント一覧をCSVなどでエクスポートし、退職済みの方がいないか、使われていないライセンスがないか、などを確認する業務です。セキュリティインシデントの防止や、コスト削減のために、単純ではあるものの重要な業務です。これを解決する SaaS などもありますが、それほど時間がかかっていないというのもあり、現在は手動で行っています。

今回は、Zoomを対象に「アカウント一覧をCSVなどでエクスポート」までを自動化してみます。

Step 1:公式ドキュメントに従って browser-use を使ってみる

import asyncio

from browser_use import Agent, Controller
from browser_use.browser.browser import Browser, BrowserConfig
from langchain_openai import ChatOpenAI

browser = Browser(
    config=BrowserConfig(
        headless=False,
        disable_security=False,
    )
)
controller = Controller()
model = ChatOpenAI(model="gpt-4o")


async def main():
    task = "Zoom のユーザー一覧ページを開き、エクスポート → テーブル内のすべてのユーザー からダウンロードをしてください"
    agent = Agent(
        task=task, llm=model, use_vision=True, controller=controller, browser=browser
    )
    await agent.run()
    await browser.close()


if __name__ == "__main__":
    asyncio.run(main())

公式ドキュメントに従えば、上記のようなコードで動きそう... ですが、実際にはうまくいきません。業務知識として「ZoomのID/PASSWORD」が必要なので、エージェントにはログインできないのです。放置していると、下記スクリーンショットのように、存在しないアカウントでログインを試みます。

Zoomのログインに失敗する様子(3倍速)

Step 2:ログインを実装する

さすがに LLM の API に ID/PASSWORD を送りたくないので、人間がログインをするようにしましょう。また、具体的にどの URL を開くのか指定した方が、結果が安定します。

# ...略...

browser = Browser(
    config=BrowserConfig(
        chrome_instance_path="/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
    )
)

# ...略...

@controller.action("ユーザーにログインを依頼する")
def ask_login():
    # Macユーザー限定
    subprocess.run(["say", "-v", "Kyoko", "ログインしてください"])
    input("ログインしてください。ログインが完了したら、Enterキーを押してください")
    return ActionResult(
        extracted_content="ログインが完了しました", include_in_memory=True
    )


async def main():
    async with await browser.new_context() as context:
        task = "https://zoom.us/profile を開き、ログイン済みかを確かめてください。ログインが完了していない場合は、ユーザーにログインを依頼(ask_login)してください。"
        agent = Agent(
            task=task,
            llm=model,
            use_vision=True,
            controller=controller,
            browser_context=context,
        )
        await agent.run()

        task = "https://zoom.us/account/user#/ を開き、エクスポート → テーブル内のすべてのユーザー からダウンロードをしてください"
        agent = Agent(
            task=task,
            llm=model,
            use_vision=True,
            controller=controller,
            browser_context=context,
        )
        await agent.run()

    await browser.close()

一部を省略しましたが、 @controller.action("ユーザーにログインを依頼する") で tool を登録してあげると、人間に操作を依頼できるようになります。tool 名はプロンプト内で具体的に指定した方が、期待通りに動作をしてくれます。

加えて、次の3つのテクニックを追加しました。こちらは必須というよりは好みの問題です。

  • 複数の Agent に分割している
    • AI になるべく単純なタスクを与えるため。実際には、単一のAgent、単一のプロンプトでもうまくいくことが多いです。
  • Playwright の Headless Chrome ではなく、実際のブラウザを使う(上記例ではChrome Canaryを指定)
    • ログイン情報(セッション)が保存されたり、Chrome 拡張をインストールしておくことがやりやすいです。
  • say コマンドで音声を読み上げる(Macユーザー限定)
    • バックグラウンドで AI に操作させておいて、人間の対応が必要なときに気づけるようになります。

上記の対応で、ログインまではうまくいくようになります...が、実際にはエクスポートしたファイルのダウンロードができていません。

Step3:ダウンロードに対応

Step2では一見するとダウンロードが成功したように見えるのですが、Playwright の仕様でダウンロード済みのファイルにアクセスすることができません。関連した Issue も立っており、回避策が提案されています。

https://github.com/browser-use/browser-use/issues/91

事前に download イベントに対応する処理を記載する必要があります。

# ...略...

def get_or_create_download_dir() -> Path:
    exe_dir = Path(os.path.dirname(os.path.abspath(__file__)))
    download_dir = exe_dir / "downloads"
    download_dir.mkdir(exist_ok=True, parents=True)
    return download_dir

async def handle_download(download: Download):
    original_path = await download.path()
    # ファイル名からダウンロード元サイトがわかるよう、example_com 形式でドメインを追加
    domain_prefix = download.url.split("/")[2].replace(".", "_")
    new_filename = f"{domain_prefix}_{download.suggested_filename}"
    new_path = get_or_create_download_dir() / new_filename
    os.rename(original_path, new_path)

async def handle_new_page(page: PlaywrightPage):
    page.on("download", handle_download)

async def setup_download(playwright_browser: PlaywrightBrowser):
    while len(playwright_browser.contexts) < 1:
        await asyncio.sleep(1)
        print("waiting for contexts to be created")
    for context in playwright_browser.contexts:
        context.on("page", handle_new_page)
        for page in context.pages:
            page.on("download", handle_download)

async def main():
    async with await browser.new_context() as context:
        pw_browser = await context.browser.get_playwright_browser()
        asyncio.create_task(setup_download(pw_browser))

        # ...略...

これで、基本的には問題なく動くと思います!

browser-useでエクスポートが成功している例(3倍速)

Step4:AI に処理の完了をちゃんと確認させる(おまけ)

今回の Zoom のエクスポートはかなり単純な例でした。他の SaaS では、エクスポートが非同期で行われることも多いです。複雑な処理の場合、Agent は誤って「ダウンロードが完了した」と誤解してしまうことがあります。

そこで、Agent にダウンロード済みファイルの確認をさせます。

# ...略...

@controller.action("ダウンロード済みファイル一覧を確認する")
def get_downloaded_files():
    download_dir = get_or_create_download_dir()
    files = list(download_dir.glob("*"))
    if not files:
        return ActionResult(
            extracted_content="ダウンロードフォルダに現在ファイルはありません。",
            include_in_memory=True,
        )

    file_list = "\n".join([f"- {file.name}" for file in files])
    return ActionResult(
        extracted_content=f"ダウンロードフォルダの内容:\n{file_list}",
        include_in_memory=True,
    )


async def main():
    async with await browser.new_context() as context:
        pw_browser = await context.browser.get_playwright_browser()
        asyncio.create_task(setup_download(pw_browser))

        task = "https://zoom.us/profile を開き、ログイン済みかを確かめてください。ログインが完了していない場合は、ユーザーにログインを依頼(ask_login)してください。"
        agent = Agent(
            task=task,
            llm=model,
            use_vision=True,
            controller=controller,
            browser_context=context,
        )
        await agent.run()

        task = "https://zoom.us/account/user#/ を開き、エクスポート → テーブル内のすべてのユーザー からダウンロードをしてください。作業を完了する前に、ダウンロードフォルダのファイル一覧(get_downloaded_files)を確認し、ダウンロードができているかを確認してください。ファイル名にはダウンロード元サイトのドメインが含まれています。"
        agent = Agent(
            task=task,
            llm=model,
            use_vision=True,
            controller=controller,
            browser_context=context,
        )
        await agent.run()

# ...略...

「ダウンロード済みファイル一覧を確認する」という tool を作成し、それによって作業の完了を確認させるようにしました。

実際、意地悪をして get_downloaded_files 関数が常に「ダウンロードフォルダに現在ファイルはありません。」を返すようにすると、延々とダウンロード処理を繰り返します。

振り返り

今回は「Zoom のユーザー一覧をエクスポートする」という作業をテーマに、browser-use での自動化をしてみました。

実際には他にも情シス業務の自動化を検証してみていますが、ハードルはやや高いかなという印象です。例えば、Google Sheets (スプレッドシート) のような複雑な UI の操作はあまりうまくいかない印象で、tool を用意してあげたり、タスクを分解してあげる、人間のレビューを挟むなど、「AI に期待しすぎない」というのがコツかなと思いました。

一方で、「AIでもわかるようにドキュメントや手順を簡略化する」という検討も進むので、良い機会にはなりました!

ぜひ皆さんも業務自動化をチャレンジしてみてください!

MSWを活用したフロントエンドIntegrationテストのノウハウ

こんにちは!JX通信社のシニアエンジニアのSirosuzumeです。

通信を含むコンポーネントのテストや、Storybookの動作を確認する際、皆さんはどんなアプローチをしていますか? 私はMSWを使用して、通信処理をMockしてテストを行っています。 MSWを導入する以前は、通信をMockするためにコンポーネントのPropsでfetcherを渡すという方法をとっていました。 実際、この方法は特別なライブラリを必要とせず、シンプルでわかりやすい方法ですが、コンポーネントの親子関係が複雑になると、Propsのバケツリレーが発生しがちです。 MSWはnode環境、あるいはbrowser環境で通信をMockするためのライブラリであり、上記のようなPropsによる制御が不要になります。 JestやVitestといったテスト環境だけではなく、Storybookや開発環境でも使用することができるため、実際に動かしてUXを確認する際にも便利です。

この記事では、MSWの活用方法のうち、Node環境で実行するIntegrationテストの書き方について紹介します。

準備

テスト環境におけるMSWのライフサイクルを把握する

Node環境下でMSWを動かす場合、msw/browserではなくmsw/nodeを使い、setupServer関数を使用して通信のMock設定を行う必要があります。

基本の流れとしては以下のようになります。

  1. setupServer関数を使ってMockサーバーのインスタンスを作成する
  2. listenメソッドを使ってMockサーバーを有効化する(基本的にbeforeAllで呼び出す)
  3. 必要に応じてuseメソッドを使用して、ハンドラーを追加し、テストを実行する
  4. afterEachでresetHandlersメソッドを呼び出して、3で追加したハンドラーを無効化する
  5. afterAllでcloseメソッドを呼び出してMockサーバーを無効化する

公式ドキュメントではjest.setup.tsなどでグローバルのタイミングでセットアップを行うことを推奨しています。 しかし、私達のプロジェクトではMSWを使わないUnitテストのファイルも多いこと、Mock箇所は必要最小限に抑えたいという方針であるため、MSWの使用はテストファイルごとに行う方針でテストを記述しています。 この方針でMSWを使用する場合、以下のようなスニペットをリポジトリに追加しておくと便利です。

const server = setupMockServer(/* TODO: デフォルトのハンドラーを追加 */);
beforeAll(() => {
  server.listen();
});
afterEach(()=> {
  server.resetHandlers();
});
afterAll(() => {
  server.close();
});

予想外のリクエストをキャッチするように設定する

MSWのhandlerは第一引数に指定された文字列、正規表現およびメソッドにマッチしたリクエストをMockし、マッチしなかったものは通常通りの通信を行います。 これはブラウザでの確認時、まだデプロイされていないエンドポイントの一部だけをMockするといった用途には適した動作なのですが、テスト実行時に外部に向けて予期せぬリクエストが飛んでしまう可能性があります。 私達のプロジェクトでは、下記の例のようにsetupServerをラップした関数を用意し、キャッチできなかった全てのリクエストを404にしてしまうハンドラーを末尾に追加しています。(GraphQLのリクエストもPostリクエストであるため、このハンドラーでキャッチできます) テスト時はmsw/nodeから直接setupServerをimportせず、この関数(setupMockServer)を使うようにすることで安全性を高めることができます。

import { type SetupServerApi, setupServer } from "msw/node";

export function setupMockServer(
  ...handlers: Array<RequestHandler>
): SetupServerApi {
  return setupServer(
    ...handlers,
    http.all(
      "*",
      () =>
        new HttpResponse(null, {
          status: 404,
          statusText: "not found",
        }),
    ),
  );
}
// 自分の環境だけかもしれないが、setupServerを自動でimportしようとすると
// msw/nodeではなくmsw/lib/nodeが優先してimportされて、テストが失敗してしまうことがある。
// 副作用的だが、その問題への対処にもなっている

Integrationテストの書き方

フロントエンドでのテストでは、よくTesting Trophyという考え方が取り上げられます。大雑把に言うと、Integrationテストを最も厚くし、UnitテストやE2Eテストの数は、Integrationテストよりも少なくなっているのが理想的という指針です。 この指針は意識的にUnitテストを減らしたり、E2Eテストを減らすといったやり方で行うべきではありません。 MSWを活用してIntegrationテストを書いていくと、自然とこの指針に従ったテストができあがっていきます。

サーバーからのレスポンスにより変更した要素を確かめる

通信を含むコンポーネントのIntegrationテストで、最も基本となる形は「レスポンスに応じて要素が変化することの検証」になります。 コンポーネントがレンダリングされた後、非同期の処理を挟んで要素が変化する場合、要素の変化を待機する必要があります。 testing-libraryではこうした非同期処理や、useEffectによる要素の変更に対応するために、waitFor、findBy〜といったメソッドが用意されています。 findByは内部的にwaitForを利用しているUtilityな立ち位置の関数で、記述もこちらのほうが直感的でわかりやすいので、要素の変更が発生する場合は、なるべくfindByを使うようにしましょう。

例として、検索欄に入力された情報を元にユーザー一覧を表示するコンポーネントのテストを考えてみます。

it("検索したユーザーが表示される", async () => {
  const user = userEvent.setup();
  const handler = http.get("/users", (req) => {
    return HttpResponse.json({ items: [generateMockUser({ name: "JX太郎" }] });
  });
  server.use(handler);
  render(<UserList />);
  const searchInput = screen.getByRole("textbox", { name: "検索欄" });
  await user.type("JX太郎");
  const button = screen.getByRole("button", { name: "送信" });
  await user.click(button);
  const userName = await screen.findByText("JX太郎");
  expect(userName).toBeVisible();
});

上記の例は、まさしくハッピーパスといった感じのテストケースですが、通信を伴うコンポーネントは、多くの場合以下のような状態を持っています。

  • 初期状態
  • ローディング中
  • データが存在する
  • データが存在しない
  • エラー発生時

これらのテストケースをカバーすると、Integrationテストは必然的にテストコード全体の中でも多くの割合を占めることになり、Testing Trophyの指針に従ったテストが書きやすくなります。 特に通信エラー時の表示などの異常系のテストは、手動やE2Eなどのブラウザー環境で実行するには何かと特殊な操作やMockが必要となるため難しくスキップされ、結果的に「想定通りに動いていなかった」といった事態が起こりやすいです。 MSWをつかったIntegrationテストでは、これらのテストをハッピーパスのテストと同程度の難易度で書くことがきます。

// 例 UserListコンポーネントのテスト
it("検索欄が空", () => {
  render(<UserList />);
  const searchInput = screen.getByRole("textbox", { name: "検索欄" });
  expect(searchInput).toHaveValue("");
});

async function setupWithSearch(handler: RequestHandler) {
    const user = userEvent.setup();
  server.use(handler);
  render(<UserList />);
  const searchInput = screen.getByRole("textbox", { name: "検索欄" });
  const button = screen.getByRole("button", { name: "送信" });
  await user.type("JX太郎");
  await user.click(button);
}

it("ローディング中の表示がされる", async () => {
  const handler = http.get("/users", (req) => {
    // 無限にローディング中の状態を続ける
    await delay('infinite')
    return HttpResponse.json({ items: [generateMockUser({ name: "JX太郎" }] });
  });
  await setup(handler);
  const loading = await screen.findByText("now loading...", { exact: true });
  expect(loading).toBeVisible();
});

it("データが存在する", () => {
  // 省略
})
it("検索結果が0件"), () => {
  // 省略
})
it("通信エラー時", () => {
  // 省略
})

サーバーに送信したリクエストを検査する

コンポーネントのアウトプットというと、第一にHTML要素が考えられますが、サーバーに送信されるリクエストもアウトプットの一種とみなすことができます。 フロントエンジニアであれば誰もが「入力内容に対して、期待通りのAPIリクエストが送信されておらず、バグが発生していた」と言ったバグを生み出した経験があると思います。 MSWのハンドラー内にコールバックの関数を設定しておくと、サーバー側に送信されたリクエストを検証することができます。

it("検索欄に入力した内容がsearchParamsに反映される", async () => {
  const user = userEvent.setup();
  const handleSearchParams = jest.fn()
  const handler = http.get("/users", (req) => {
    // レスポンスを返す前にコールバックを呼ぶ
    handleSearchParams(new URL(req.request.url).searchParams);
    return HttpResponse.json({ items: [generateMockUser({ name: "JX太郎" }] });
  });
  server.use(handler);
  render(<UserList />);
  const searchInput = screen.getByRole("textbox", { name: "検索欄" });
  const button = screen.getByRole("button", { name: "送信" });
  await user.type("JX太郎");
  await user.click(button);
  await waitFor(() => expect(handleSearchParams).toBeCalledTimes(1));
  expect(handleSearchParams).toBeCalledWith({ name: "JX太郎" });
});

MSWを使ったテストを書きやすくする環境作り

Integrationテストの数が多くなるぶん、テストコード自体の書きやすさや可読性、保守性等も重要になります。 MSWを使ったIntegrationテストでは、リクエストハンドラーの生成、レスポンスデータの生成が頻繁に発生します。

Mock用のデータ生成関数を用意する

MSWを使ったテストに限った話ではありませんが、サーバーからのレスポンスデータのMockを生成する関数を作成しておくと、テストコードの作成に取り組むときに非常に便利です。 私達のチームではAPI、レスポンスの型がきまったとき、モックデータの生成関数の作成も必須のタスクとしています。 以下は、ユーザー情報を生成する関数の例です。

// ファイル名はuser.mock.tsなど、通常のコードと区別できるファイル名にすると、lintの設定等で、mock.tsのimportを禁止するなどの対策を行うことができます
import type { User } from "./user"

// generateMock{モデル名}など、Mockデータ生成関数の命名規則は統一する
export function generateMockUser(override: Partial<User> = {}): User {
  return {
    id: "1",
    name: "JX太郎",
    age: 20,
    ...override,
  };
}

ハンドラーの生成を簡単にする

通信を伴うコンポーネントのIntegrationテストを行う際、以下のようなテストケースが頻出することが多いです。

  • 正常系
    • レスポンスに対して想定通りの要素が表示される
    • コンポーネントを操作したとき、サーバーに想定通りのリクエストが送信される
      • SearchParamsが想定通りか
      • PathParamsが想定通りか
      • RequestBodyが想定通りか
  • 異常系
    • サーバーから既知のエラーが返ってきたとき、コンポーネントが想定通りの動作をする
    • サーバーから未知のエラーが返ってきたとき、コンポーネントが想定通りの動作をする

fetchやaxiosなどを使った通信処理をコーディングする場合、たいていエンドポイントやメソッドを関数内に隠蔽した、関数を作成するのではないかと思います。 MSWハンドラーを作成するときも同じです。 エンドポイントとメソッドは固定で設定し、レスポンスだけを差し替えたハンドラーを作るのが便利です。

type Props = {
  // Propsの型パズルを頑張れば、invalidなときだけ任意の型を設定するなどできる
  response: JsonBodyType;
  status?: number;
  statusText?: string;
  onPathParams?: (params: unknown) => void;
  onRequestBody?: (body: unknown) => void;
  onRequestSearchParams?: (searchParams: URLSearchParams) => void;
}

function buildMockGetUsersMswHandler(props: Props) {
  return http.get("*/users", ({ req }) => {
      props.onRequestSearchParams?.(new URL(req.request.url).searchParams);
      props.onPathParams?.(req.params);
      props.onRequestBody?.(await req.request.json());
      return HttpResponse.json(props.response, {
        status: props.status ?? 200,
        statusText: props.statusText ?? "ok",
      });
  })
}

RESTApiであれば、ほとんどのテストケースで使用されるハンドラーは、上述の例のうち「エンドポイント」と「メソッド」だけの違いになります。もう一段階カリー化すれば、毎回ハンドラーを作成する手間が省けそうです。 以下の例は、エンドポイントとメソッドを引数に取り、REST API用のMSWハンドラーを返す関数を作成する例です。

type MswHttpHandlerBuilderProps = {
  response: JsonBodyType;
  status?: number;
  statusText?: string;
  onPathParams?: (params: unknown) => void;
  onRequestBody?: (body: unknown) => void;
  onRequestSearchParams?: (searchParams: URLSearchParams) => void;
};

type BuildMswHttpHandlerBuilderProps = {
  path: Path;
  method: keyof typeof http;
};
export function buildMswHttpHandlerBuilder({
  path,
  method,
}: BuildMswHttpHandlerBuilderProps) {
  return (props: MswHttpHandlerBuilderProps): HttpHandler =>
    http[method](path, async (req) => {
      props.onRequestSearchParams?.(new URL(req.request.url).searchParams);
      props.onPathParams?.(req.params);
      props.onRequestBody?.(await req.request.json());
      return HttpResponse.json(props.response, {
        status: props.status ?? 200,
        statusText: props.statusText ?? "ok",
      });
    });
}

// ハンドラー生成用関数を作成
export const buildGetUsersMswHandler =
  buildMswHttpHandlerBuilder({
    path: "*/users",
    method: "get",
  });

// テスト時に以下のようなハンドラー随時作成する
const handler = buildGetUsersMswHandler({
  response: { items: [generateMockUser({ name: "JX太郎" })] },
});

まとめ

人間が使いやすいUI/UXを実現しようとするほど、コンポーネントは複雑化していまう運命にあると感じています。 複雑なコンポーネントをテストするには、相応に複雑なMockやStubが必要になり、コンポーネントをただ書くことよりも難易度が高くなります。 そのためIntegrationテストはE2EテストやUnitテストよりも難易度が高く、どうしても省略したい、回避したい、という感情が生まれてしまいがちです。 昨年、新しいメンバーがプロジェクトに加わった際、Integrationテストのノウハウを、言語化して伝える機会があり、自分でも「ああ、こう書けばいいんだ!」という発見が多くありました。 この記事が参考になった!MSWを活用してIntegrationテストを書き始めた!という人が一人でもいれば幸いです。