PlaywrightとLangGraph(Python)、Go、Next.jsを組み合わせて、セルフホスト型のE2Eテスト基盤 「AXIOM(アクシオム)」 を個人開発しました。手元のPCや自社サーバー上で、Docker Composeだけで一通り動かせる形で公開しています。
開発にあたって目指したのは、次のような特徴を持つテスト環境です。
- 手元のDocker環境内で完結すること: 対象URLや認証情報、DOM、録画ビデオを外部へ送信せず自分の環境内に保存できること
- ローカルLLM(Ollama)にも対応すること: 外部APIを使わずに、手元のマシンや社内サーバー上のOllamaでAI機能を動かせること
- 独自形式でロックインしないこと: GUIやGherkinで編集した内容が、標準の
@playwright/test(.spec.ts)として残ること - Web・モバイル・VRT・手動テストを統合すること: 画面崩れ検知(VRT)やAndroid(Maestro)、手動テストも同じ画面で扱えること
この記事では、全体のシステム構成と、実装するなかで気をつけた設計上のポイントについてまとめます。
- 全体のシステム構成
- 1. 独自フォーマットでロックインしないための双方向同期
- 2. 初回生成(OODAループ)と回帰実行(Staticモード)の分離
- 3. ローカルLLM(Ollama)でのセレクタ自己修復とガードレール
- 4. 録画ビデオの破損防止と、VRT(視覚的回帰テスト)の工夫
- 5. セルフホスト運用に向けたセキュリティとコンテナ設計
- 手元の環境で動かす手順(Quickstart)
- おわりに
全体のシステム構成
システム全体は、Docker Composeで起動する4つのコンポーネントで構成しています。テスト対象のWebアプリ側に専用SDKなどを組み込む必要はなく、コンテナ内のブラウザから到達できるURLがあればテストを実行できます。
+-----------------------------------------------------------------------+
| 自社サーバー / ローカルPC (Docker Compose) |
| |
| +-----------------------+ 双方向同期 +--------------+ |
| | Frontend (Next.js) | <=====================> | Backend (Go) | |
| | GUI / Gherkin / VRT | GUI ⇔ Gherkin ⇔ .spec | 暗号化・管理 | |
| +-----------------------+ +------+-------+ |
| | |
| v |
| +-----------------------+ +--------------+ |
| | PostgreSQL / Redis | <-----------------------+ Runner | |
| | MinIO (動画・画像保存)| | Playwright & | |
| | ※127.0.0.1のみバインド| | LangGraph | |
| +-----------------------+ +------+-------+ |
+-----------------------------------------------------------|-----------+
|
+-----------------------------------------+
| |
v ブラウザ操作・録画 v 推論リクエスト
+--------------------+ +--------------------+
| テスト対象システム | | Ollama (ローカル) |
| (Web / Android) | | または各種LLM API |
+--------------------+ +--------------------+
- Frontend: Next.js(App Router)+ React + TypeScript
- Backend: Go(Clean Architecture + Ent ORM)
- Runner: Python + Playwright + Maestro + LangGraph
- Storage: PostgreSQL(メタデータ)+ Redis(ジョブ制御)+ MinIO(録画ビデオやVRT画像を保存するS3互換ストレージ)
1. 独自フォーマットでロックインしないための双方向同期
GUI付きのE2Eテストツールを作るときに一番気をつけたのが、作成したテスト資産のポータビリティです。将来もしこのツールを使わなくなっても、それまでに作ったテストがそのまま通常のPlaywrightコードとして動くようにしたかったためです。
ツール独自のJSON形式や専用ランタイムに依存すると、後から素のPlaywright環境へ移行する際にテストの書き直しが発生します。そこで、以下の3つの表現を双方向で変換・同期する構成にしました。
- GUIのステップエディタ: ブラウザ上でステップの追加や並び替え、アサーション条件を確認する画面
- Gherkin仕様書(
.feature):Given / When / Then(または前提 / もし / ならば)で記述するテスト仕様 - 標準のPlaywrightコード(
.spec.ts)/ Maestro(.yaml): ラッパーを含まない標準の@playwright/testコード
GUIやGherkinで編集した内容は、@playwright/test のTypeScriptコードへ変換されます。逆にエディタ上で .spec.ts のコードを直接書き換えると、その変更がGherkinやGUIのステップ表示にも反映されます。また、GitHub連携を設定すれば、生成した .spec.ts ファイルをそのままリポジトリへPull Requestとして出力できます。
import { test, expect } from '@playwright/test'; const BASE_URL = process.env.BASE_URL || 'https://staging.example.com'; test.describe('ショッピングカート機能', () => { test('商品をカートに追加して合計金額が表示されること', async ({ page }) => { await page.goto(BASE_URL); await page.getByRole('button', { name: 'カートに入れる' }).first().click(); await page.getByRole('link', { name: 'カートを見る' }).first().click(); await expect(page.getByText('小計')).toBeVisible(); }); });
このように手元には npx playwright test で単体実行できる通常の .spec.ts ファイルが残ります。そのため、既存のCIパイプラインと併用したり、後から通常のPlaywright運用へ戻したりしやすい作りになっています。
シナリオ名と test() タイトルの1対1マッピング
Gherkinの仕様書とPlaywrightのコードを同期する際、双方のタイトルが必ず1対1で一致するようルール化しています。具体的には、Gherkin側の Scenario: 名と、Playwright側の test('...') 名を揃える形です。
これには2つの理由があります。1つ目は、Gherkinのシナリオ名を見たときに、.spec.ts 内のどのテストケースに対応するかを分かりやすくするためです。2つ目は、後述するVRT(視覚的回帰テスト)やAI自己修復の際に、シナリオ単位で正確にテストケースを特定するためです。
2. 初回生成(OODAループ)と回帰実行(Staticモード)の分離
AIにブラウザを操作させる仕組みを作る際、すべてを毎回LLMの推論に任せると、1シナリオあたり30秒〜1分以上の時間がかかります。APIを使う場合はトークン消費もその分だけ増えてしまいます。
そこで、テストのライフサイクルを「初回生成(探索実行)」と「普段の回帰実行(Staticモード)」の2つに分けました。
初回探索:LangGraphによるOODAループとDOM情報の圧縮
まだテストコードが確定していない段階では、Runnerコンテナ内のLangGraphエージェントが OODAループ(Observe → Orient → Decide → Act) を回します。
- Observe(観察): 現在のURL、ページタイトル、アクセシビリティツリー、操作可能な要素を取得する
- Orient / Decide(判断・決定): Gherkinのシナリオと画面状態を照らし合わせ、次に操作する要素とアクションを1〜3ステップ単位で決定する
- Act(実行): Playwrightでアクションを実行し、成功したステップをTypeScriptコードとして記録する
Observeフェーズで await page.content() の生HTMLをそのままLLMに渡すと、1ステップあたり数万トークンに達してしまいます。SVGのパスやインラインスタイル、スクリプトタグなどが大量に含まれるためです。
そこで、ブラウザ側で不要な装飾タグや非表示要素を除外する前処理を入れました。アクセシビリティツリーと操作可能な要素(role、aria-label、data-testid、id、表示テキスト)だけを構造化して抽出しています。これにより1ステップあたりのトークン量が約1/10になり、getByRole や getByLabel を使ったPlaywrightコードを生成しやすくなりました。
自律操作でフレーキーなテストを生まないための3つの対策
AIに画面を操作させてテストコードを生成する際、素直に実装するとテストが不安定(フレーキー)になりやすい箇所がありました。対策として主に次の3点を入れています。
1. 一時的なローディング表示に対する expect の抑止
モダンなWebアプリでは、「Loading...」や「保存中...」といったスピナーが数十ミリ秒だけ表示されて消えることがよくあります。AIがこのスピナー文字列に対して expect を書いてしまうと、2回目以降の高速な実行時にスピナーがすでに消えていてタイムアウトになります。そのため、一時的なローディング状態ではなく、処理が完了した後の最終的な表示要素を検証するよう制御しています。
2. 画面遷移やモーダル開閉を伴うアクションでのバッチ区切り
OODAループでは、1回の推論で1〜3個のアクションをまとめて返せるようにしています。ただし、ボタンを押して画面遷移やAPI通信が発生する場合に後続の操作までまとめて予測させると、遷移後の実際のDOMとズレが生じます。そこで、状態変化を引き起こすアクションを実行した時点で一度バッチを区切るようにしました。次のターンで新しいDOMを観察してから、続きの操作を判断させています。
3. Strict Modeエラーを防ぐ .first() の付与と <select> の扱い
Playwrightでは、getByRole('button', { name: '削除' }) のように複数要素にマッチするロケータをそのままクリックするとStrict Modeエラーになります。また、ネイティブの <select> 要素に対してAIが選択肢のテキストを直接クリックしようとして失敗するケースもあります。そのため、<select> には .selectOption() を使い、複数マッチし得るロケータには .first() を付けるルールを徹底しています。
日常の回帰テスト:LLMを介さないStaticモード
一度 .spec.ts のコードが生成された後は、AIを介さずにPlaywrightランナーで直接コードを実行する Staticモード で回します。StaticモードではLLMとの通信が発生しないため、1シナリオ数秒で完了します。
3. ローカルLLM(Ollama)でのセレクタ自己修復とガードレール
画面の改修でボタンの文言やDOM構造が変わり、Staticモードのテストが落ちたときだけAIを呼び出してセレクタの修正案(Self-Healing)を作成します。AIプロバイダはOpenAIやGeminiのほか、ローカルの Ollama(qwen2.5-coder や llama3.1 など)を選択できます。
小型モデルやローカルLLMで自己修復を回す際は、以下のガードレールを入れて安定させています。
① AIが勝手にテストケースやアサーションを消すのを防ぐ
AIによる自己修復で一番気をつけないといけないのが、テスト内容の勝手な改変です。アプリ側の不具合でエラー画面になっているのに、AIがテストをPASSさせようとして expect() の検証条件を消してしまうことがあります。また、複数の test() ブロックがあるファイルから、失敗した1本だけを省略して返してくるケースもあります。
これを防ぐため、修正プロンプトには必ず元の Gherkin仕様書 を含めています。そのうえで、LLMが返してきたコードに対して、Gherkin内の全 Scenario: タイトルに対応する test('...') ブロックが揃っているかをパーサーで検査しています。もし欠落しているシナリオが見つかった場合は、不足分を明示して再出力させる補正パスが自動で走る仕組みです。
② LLMがよく間違えるPlaywright構文の自動補正
ローカルLLMにTypeScriptのPlaywrightコードを書かせると、Python版Playwrightの記法と混同するミスがよく起きます。代表例が、.first().click() とメソッドで呼ぶべき箇所を、.first.click() とプロパティ参照で書いてしまうミスです。
// ❌ LLMが出力しがちなミス(Python版の記法と混同) await page.getByRole('button', { name: 'サインイン' }).first.click(); // ✅ TypeScript版Playwrightの正しい記法 await page.getByRole('button', { name: 'サインイン' }).first().click();
こうした定型的な構文ミスでテストが落ちないよう、生成後のコードに対して後処理で自動補正をかけています。あわせて、import { test, expect } from '@playwright/test'; の不足補完や、実ブラウザ(Staticモード)での再実行検証も行います。最終的には、画面上で変更前後のDiffと録画ビデオを確認してから反映できるフローにしています。
4. 録画ビデオの破損防止と、VRT(視覚的回帰テスト)の工夫
失敗時でも破損しない録画ビデオの回収と容量管理
すべてのテスト実行で、ブラウザ操作の録画ビデオ(WebM/MP4)をMinIOに保存しています。PlaywrightのPython APIでは、BrowserContext を完全に close() する前に page.video.path() を読むと破損ファイルになることがあります。動画バッファがまだフラッシュされきっていないためです。
# Playwrightで録画ファイルを破損させずに回収する基本パターン try: await run_test_steps(page) finally: video = page.video await page.close() await context.close() # close() 完了後に動画ファイルが確定する if video: video_path = await video.path()
そのため、途中でタイムアウトやエラーが起きたときも finally 節で必ずクローズ処理を完了させてから動画を回収しています。また、毎日定期実行を回していると動画ファイルでディスク容量が埋まってしまいます。そこでMinIOのライフサイクルルール(ILM)を使い、一定の期間が経過した古いアーティファクトを自動削除できるようにしています。
VRT(視覚的回帰テスト)でフォントのアンチエイリアス差を吸収する
DOMのアサーションだけでは検知できないCSS崩れを拾うため、ベースライン画像と実行時のスクリーンショットをピクセル単位で比較するVRT機能も組み込みました。単純にRGB値が1でも違えば差分とみなす実装にすると、OSやブラウザのわずかなフォントレンダリング差(アンチエイリアス)だけで毎回テストが落ちてしまいます。そこで以下の処理を入れています。
- 画像サイズ(縦横ピクセル数)が微妙に異なる場合は、比較前にベースライン側の解像度へリサンプリング(
LANCZOS)して揃える - 各ピクセルのRGB差分を計算する際、微小なノイズ(輝度差10以下)は無視し、それ以上の差があるピクセルの割合を差分率(
diff_percentage)として算出する - 画面の性質に合わせて、許容する差分率(Threshold)をGUIのスライダーで調整できるようにする
5. セルフホスト運用に向けたセキュリティとコンテナ設計
セルフホスト環境であっても、テスト基盤自体は認証情報やネットワークへの到達性を持つため、セキュリティとコンテナ管理には以下の対策を入れています。
秘密情報の保護と「伏字の上書き保存」防止
認証用のシークレット等は、バックエンド側でAES-256-GCMによる暗号化を施してからDBに保存しています。また、設定したAIのAPIキーや外部連携トークンなどはAPIレスポンス上で伏字(例: sk-...abcd)に変換して返し、ブラウザ側から平文を取得できないようにしています。
ここで地味に気をつけたのが、設定画面のフォームから更新リクエストが送られてきたときの扱いです。画面表示用に伏字化した文字列がそのまま保存リクエストに含まれた場合、それをそのままDBへ書き込むと本物のAPIキーが伏字で上書きされてしまいます。そのため、伏字状態の値が送信された場合は保存を拒否(または既存の値を維持)するバリデーションを入れています。
サブプロセス環境変数の分離とDocker特有のChromiumクラッシュ対策
テスト実行コンテナ内でPlaywrightをサブプロセスとして起動する際は、システム環境変数をホワイトリスト方式で最小限に絞って渡しています。これにより、テストコードの中からアプリケーション基盤側の内部設定値やシークレット変数を参照できないよう分離しています。
また、Dockerコンテナ内でHeadless Chromiumを安定して動かすため、以下の設定を入れています。
shm_size: 1gbの割り当て: デフォルトの/dev/shm(64MB)では画像等の多いページでタブがクラッシュしやすいため共有メモリを拡張する- 孤児プロセスの回収と権限最小化: ゾンビプロセス蓄積を防ぐ
init: trueやメモリ上限に加え、read_only: trueとcap_drop: ALLを適用する - ポートのローカルバインド: PostgreSQLやRedis、MinIOはホストの
127.0.0.1のみにバインドし、Runnerは外部へポートを公開しない構成にする
手元の環境で動かす手順(Quickstart)
DockerとDocker Composeが入っていれば、以下のコマンドで手元の環境にセットアップできます(現在は全機能を無料で利用できます)。
curl -fsSL https://axiom-qa.dev/install.sh | bash cd axiom-selfhosted # サイト上で無料発行したライセンスキーを .env に設定 vi .env docker compose -f docker-compose.prod.yml up -d
起動後、ブラウザで http://localhost:3001 を開くと管理画面にアクセスできます。ホスト側でOllama(http://localhost:11434)を起動していれば、コンテナ内から http://host.docker.internal:11434 へ自動変換して接続します。そのため、追加のネットワーク設定なしでローカルLLMを使ったテスト生成や自己修復を試せます。
おわりに
実際の画面の動き(AIでのテスト作成、OODAループ実行、セレクタの自己修復、VRT比較、Androidテスト、レポート出力など)は、以下のサイトに1分前後の解説動画を8本載せています。
手元のDocker環境や社内サーバー内で完結するE2Eテスト基盤を探している方の参考になれば幸いです。感想やフィードバックがあればぜひお気軽にお寄せください。
