旅好きエンジニアのメモ

旅のこと映画、統計、会計、プログラミングなど、気の向くままに語ります

手元のDockerで完結する、Playwright×AIのセルフホスト型E2Eテスト基盤「AXIOM」を個人開発して公開しました

PlaywrightとLangGraph(Python)、Go、Next.jsを組み合わせて、セルフホスト型のE2Eテスト基盤 「AXIOM(アクシオム)」 を個人開発しました。手元のPCや自社サーバー上で、Docker Composeだけで一通り動かせる形で公開しています。

axiom-qa.dev

開発にあたって目指したのは、次のような特徴を持つテスト環境です。

  • 手元のDocker環境内で完結すること: 対象URLや認証情報、DOM、録画ビデオを外部へ送信せず自分の環境内に保存できること
  • ローカルLLM(Ollama)にも対応すること: 外部APIを使わずに、手元のマシンや社内サーバー上のOllamaでAI機能を動かせること
  • 独自形式でロックインしないこと: GUIやGherkinで編集した内容が、標準の @playwright/test(.spec.ts)として残ること
  • Web・モバイル・VRT・手動テストを統合すること: 画面崩れ検知(VRT)やAndroid(Maestro)、手動テストも同じ画面で扱えること

この記事では、全体のシステム構成と、実装するなかで気をつけた設計上のポイントについてまとめます。


全体のシステム構成

システム全体は、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つの表現を双方向で変換・同期する構成にしました。

  1. GUIのステップエディタ: ブラウザ上でステップの追加や並び替え、アサーション条件を確認する画面
  2. Gherkin仕様書(.feature): Given / When / Then(または 前提 / もし / ならば)で記述するテスト仕様
  3. 標準の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) を回します。

  1. Observe(観察): 現在のURL、ページタイトル、アクセシビリティツリー、操作可能な要素を取得する
  2. Orient / Decide(判断・決定): Gherkinのシナリオと画面状態を照らし合わせ、次に操作する要素とアクションを1〜3ステップ単位で決定する
  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本載せています。

axiom-qa.dev

手元のDocker環境や社内サーバー内で完結するE2Eテスト基盤を探している方の参考になれば幸いです。感想やフィードバックがあればぜひお気軽にお寄せください。

Nest.jsでREPLを使う方法

普段、Railsで開発することが多いので、Nest.jsでもREPLを使用できればいいなぁと思っていたので調べていたら公式ドキュメントにREPLの書き方が書いてありました

docs.nestjs.com

main.tsと同様のディレクトリに下記内容のrepl.tsを作成して配置するだけでした。

import { repl } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  await repl(AppModule);
}
bootstrap();

実行は下記だけで問題なさそうでした。

npm run start -- --entryFile repl

転職ドラフトを使ってみて転職活動を行った所感

こちらの記事は転職ドラフト体験談投稿キャンペーンに参加しています

https://job-draft.jp/articles/251

自分の転職歴について

転職活動は2回あります。

自分は現在20代後半で、現在は転職ドラフト経由でとある企業に入社して 3社目になりました。

1回目の転職は転職ドラフトは使用せずに、直接応募と転職エージェントを使用していました。 2回目の転職は転職ドラフトのみを使用しました。

転職ドラフトのここがよかったと思うところ

面接/面談

転職ドラフトはレジュメをしっかりかかないといけないです。 企業はそのレジュメを見てスカウトするかを決めるので、 面談では企業が経歴をしっかり見てくれているので、話がスムーズになり、やりやすいです。

企業を知る機会

転職ドラフトに登録されている企業はメガベンチャーから、規模の小さいスタートアップのような企業まで幅広くあります。 自分が知らないような企業からスカウトが来たりするので、いろんな企業を知ることができて楽しいです。 未知の企業からスカウトがくることで、自分が行いたいこととマッチするかもしれないですし、新しい可能性を得られるのではないかと思います。

希望年収を設定できる

希望年収を設定できるのはありがたいです。 転職で難しいことの一つに給与交渉があると思いますが、 転職ドラフトの場合、希望年収を設定して企業がそれを確認して、スカウトをしてくるので 給与交渉の煩わしさが存在しません。 また、希望年収を設定することである程度企業をスクリーニングできるのがありがたいです。

指名状況

転職ドラフトは2回参加して 1回目は10社 2回目は3社 からスカウトをいただきました

傾向というか、やはり初参加の人に指名が偏る気がしました。 まぁ、当然といえば当然ですが...

提示年収は、希望年収を記載していたこともあり、だいたい同等の提示をしてくれる企業が多かったです。

指名のコメントは企業によって様々でした。 その人のレジュメを見てくれているんだなぁという企業もあれば、テンプレのようなコメントを行う企業もありました。 やはり、その人のレジュメをしっかり見てくれている企業だと、現時点で興味がなくても、あってみようとかそういう気持ちになっったりしました。

ここが辛かった

指名を受けるためにはレジュメをしっかり書かないといけません。 普通の職務経歴書よりもかなりガッツリと書くので、労力が結構あります。 ただ、レジュメをしっかり書くことで、面接でしゃべることがまとまったり 自分のやってきた業務をしっかり見つめ直すことになるのでよかったです。

まとめ

転職ドラフトはレジュメさえ書けば、後は指名を待つだけなので時間のないエンジニアに向いているサービスだと思いました!

Nuxt.jsをfirebaseにデプロイする方法

Nuxt.jsでアプリを作成する

hogeがアプリケーション名です。

vue init nuxt-community/starter-template hoge

セットアップ

cd hoge
npm install

ローカルで確かめてみる

npm run dev

下記にアクセスして問題ないか確かめます。
localhost:3000

firebaseを導入

npm install -g firebase-tools
firebase init hosting
? What do you want to use as your public directory? (public) 

=> distと入力

? Configure as a single-page app (rewrite all urls to /index.html)? (y/N)

=> yと入力

firebaseにデプロイ

本番環境をローカルで確かめる

npm run build
npm run start

firebaseにデプロイする

npm run generate
firebase deploy

一応、ポートフォリオサイト作ってみました

githubにも上げているので参考にしてみてください。

ポートフォリオサイト

github

Nuxt.jsでsvgを使う方法

Nuxtのlintの設定でsvgファイルは閉じタグが強制になっているのでlintを少しカスタムしてあげる必要がありました。

.eslintrc.jsに下記を追加してあげれば大丈夫です。

  // add your custom rules here
  rules: {
    "vue/html-self-closing": ["error", {
      "svg": "any"
    }]
  }

anyはlintを強制しないという意味です

vueのプラグインの順番でハマった件

先日、vue-lazyloadを実装しようと組み込んだのですが、 Failed to resolve directive: lazyというエラーが出て機能しませんでした。

公式ページでプラグインを使用する場合は順番守ってね、ということでした。 https://jp.vuejs.org/v2/guide/plugins.html

Vue.use() グローバルメソッドを呼び出すことによってプラグインを使用します。これは new Vue() を呼び出してアプリを起動するよりも前に行われる必要があります:

// `MyPlugin.install(Vue)` を呼び出します
Vue.use(MyPlugin)

new Vue({
  // ... オプション
})

まぁ、そりゃそうですよね。