Claude Code × Selenium Gridで
DjangoのE2Eテストを回す
ユニットテストが全部通っていても、画面で押したら動かない。受託開発ではよくある話です。そこで私たちは、Claude Codeに「本物のブラウザでE2Eテストを書いて、実行して、証拠を残す」ところまで任せています。
この記事では、Posiiが自社・受託のDjangoアプリで実際に使っている構成を紹介します。DockerのSelenium Grid(ポート4444)にClaude Codeが書いたテストを流し、noVNC(ポート7900)でブラウザの動きをライブで眺めるという形です。コードは実際に運用しているテストから、秘密情報や固有のデータを抜いて簡略化したものです。
なぜClaude CodeにSeleniumを書かせるのか
E2Eテストは「価値は高いが、書くのが面倒」の代表格です。フォームのid、待ち方、ログイン状態の後始末と、手間の割に地味な作業が続きます。Claude Codeはここが得意で、Djangoのテンプレートやフォーム定義を読んだうえで、id_title のような既定のフィールドidを正しく拾ってテストを組み立てます。
ただし、AIに任せるほど「本当に動いたのか」を人間が確かめられる仕組みが必要になります。そのためにこの構成では、次の三つを必ずセットにしています。
- 本物のブラウザ:Django標準のテストクライアントではなく、Chromeで実際にクリック・入力・アップロードする。
- ライブで見える:noVNCで、AIが操作しているブラウザ画面をリアルタイムに眺められる。
- 証拠が残る:各ステップのスクリーンショットと、合否を並べたチェック一覧を必ず出力する。
構成:Selenium Grid+noVNCをDockerで立てる
ブラウザはDockerコンテナの中で動かします。手元にchromedriverを入れて管理する必要がなくなるのが最大の利点です。実際、私たちの開発機はWindows on ARMで、Selenium Managerがchromedriverを自動取得できない(Unsupported platform/architecture: win32/arm64 で止まる)環境でした。一度は自前でchromedriverを用意して遠回りしましたが、ARMネイティブで動くGridがすでにあるなら、それを使えば済む話でした。
Hub/Node構成のdocker-composeは、一般的には次のような形です(イメージのタグは用途に合わせて固定してください)。
services:
selenium-hub:
image: selenium/hub:latest
ports:
- "4442:4442"
- "4443:4443"
- "4444:4444" # WebDriverの接続先
chrome:
image: selenium/node-chrome:latest # ARM機ならChromium版のnodeイメージ
shm_size: 2gb
depends_on:
- selenium-hub
environment:
- SE_EVENT_BUS_HOST=selenium-hub
- SE_EVENT_BUS_PUBLISH_PORT=4442
- SE_EVENT_BUS_SUBSCRIBE_PORT=4443
ports:
- "7900:7900" # noVNC(ブラウザでライブ視聴)
extra_hosts:
- "host.docker.internal:host-gateway" # Linuxでホストを参照する場合
Hubとブラウザが一体になったstandalone版イメージを1つ動かすだけでも、4444と7900の両方が使えます。私たちが普段使っているのも1コンテナ構成で、止まっていたら docker start で起こすだけです。noVNCには公式イメージの既定パスワードがかかっていますが、社外ネットワークには公開しないようにしてください。
起動直後のGridはすぐには応答しません。テスト開始前に http://localhost:4444/status を叩き、ready: true になるまで待つ処理を入れておくと安定します。
接続の要点:ここを外すと動かない
PythonからGridへの接続は webdriver.Remote です。ここで一番ハマるのがURLの向きです。
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.remote.file_detector import LocalFileDetector
opts = Options()
opts.add_argument("--window-size=1280,1400")
driver = webdriver.Remote(command_executor="http://localhost:4444", options=opts)
driver.file_detector = LocalFileDetector() # ファイルアップロードがあるなら必須
# ブラウザはコンテナ内。ホストのDjangoは host.docker.internal で見る
BASE = "http://host.docker.internal:8000"
- 対象URLは
host.docker.internal:ブラウザはコンテナの中にいるので、localhostはコンテナ自身を指し、ホストのDjangoには届きません。 - Djangoは
0.0.0.0で起動:python manage.py runserver 0.0.0.0:8000のようにしないと、コンテナからの接続を受け付けません。 - 接続先(4444)はホストから見たlocalhostでよい:テストスクリプト自体はホストで動くため、Gridへの接続は
localhost:4444のままで構いません。
最小のDjango E2Eテスト(実運用コードを簡略化)
訪日客向けツアーのマッチングアプリで、ガイドがログインしてツアーを作成し、写真をアップロードして公開ページに表示されるまでを通しで確かめるテストを、要点だけ残して書き直したものです。実物は14項目のチェックを持っています。
# driver と BASE は、上の接続コードで作成済みとする
import os, sys, time
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import Select, WebDriverWait
sys.stdout.reconfigure(encoding="utf-8") # Windowsコンソール対策
EMAIL = os.environ["E2E_EMAIL"] # テスト用アカウント(コードに直書きしない)
PASSWORD = os.environ["E2E_PASSWORD"]
TITLE = "[E2E] Sample Tour " + time.strftime("%m%d-%H%M%S")
FX = os.path.join(os.path.dirname(__file__), "fixtures")
results = []
def check(name, cond):
results.append((name, cond))
print(("OK " if cond else "NG ") + name)
def shot(name):
driver.save_screenshot(f"shots/{len(results):02d}_{name}.png")
wait = WebDriverWait(driver, 20)
try:
driver.get(BASE + "/")
driver.delete_all_cookies() # 前回のログイン状態を消す
driver.get(BASE + "/accounts/login/")
wait.until(EC.presence_of_element_located((By.ID, "id_login"))).send_keys(EMAIL)
driver.find_element(By.ID, "id_password").send_keys(PASSWORD)
driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
driver.get(BASE + "/host/experience/new/")
wait.until(EC.presence_of_element_located((By.ID, "id_title"))).send_keys(TITLE)
Select(driver.find_element(By.ID, "id_city")).select_by_visible_text("Nara")
driver.find_element(By.ID, "id_cover_image").send_keys(os.path.join(FX, "cover.jpg"))
shot("form_filled")
driver.find_element(By.CSS_SELECTOR, "form button[type='submit']").click()
wait.until(EC.url_contains("/dates/")) # 成功すると日程ページへ遷移
slug = driver.current_url.rstrip("/").split("/")[-2]
driver.get(f"{BASE}/tour/{slug}/")
wait.until(EC.presence_of_element_located((By.TAG_NAME, "h1")))
check("詳細ページにツアー名", TITLE in driver.find_element(By.TAG_NAME, "h1").text)
imgs = [i.get_attribute("src") or "" for i in driver.find_elements(By.TAG_NAME, "img")]
check("アップロードした写真が表示される", any("cover" in u for u in imgs))
shot("detail")
finally:
driver.quit()
failed = [n for n, ok in results if not ok]
print(f"{len(results) - len(failed)}/{len(results)} passed")
sys.exit(1 if failed else 0)
ポイントは、テストで作るデータに [E2E] のような識別マークを付けていることです。manage.py に後片付け用のコマンド(setup/teardown)を用意し、テスト開始時に前回の残骸を掃除します。テストを回すたびにダミーのツアーが増えていく、という事態を防げます。
Claude Codeへの頼み方(プロンプト例)
頼み方はシンプルです。ただし「どこで・どう動かすか」の前提を、あらかじめ手順書(Claude Codeのスキル)として書いておくのがコツです。私たちは「Grid(4444)とnoVNC(7900)を使う」「URLはhost.docker.internal」「アップロードにはLocalFileDetector」といった約束事を一か所にまとめ、毎回それを読んでから書かせています。
・ログインからツアー作成、写真アップロード、公開ページの表示までを Seleniumで通しで確認して。各ステップでスクショを撮って、最後に合否一覧を出して ・さっきの写真アップロードの流れを、ゆっくり操作するデモにして。 画面上部に今やっている手順をバナーで出して、全スクショをGIFにまとめて ・新規登録フォームのE2Eを書いて。テストで作ったユーザーは最後に必ず消すこと
二つ目の「デモ化」は意外と便利です。実運用では、操作速度を環境変数(SLOW)でゆっくりにし、JavaScriptで画面上部に「3. 表紙写真を選ぶ」のような説明バナーを差し込みながら撮影しています。撮ったスクリーンショットはPillowでつないでアニメーションGIFにし、そのままクライアントへの説明資料になります。
結果の確かめ方:noVNCで見る、スクショで残す
テストが走っている間、ブラウザで http://localhost:7900 を開くと、コンテナの中のChromeが入力し、クリックし、画面遷移していく様子がそのまま見えます。AIが書いたテストが意図どおりの画面を触っているかを、人間が目で確かめられるのがこの構成の一番の価値です。
見届けられなかった場合でも、連番のスクリーンショットと「OK/NG」のチェック一覧が残ります。テストの成否は終了コードでも返すので、Claude Code自身も失敗に気づいて原因調査に移れます。
画面を出すか、出さないか
別のDjangoアプリ(AIチャットのサービス)では、テストの共通処理に「見え方」の切り替えを三つ用意しています。
- 既定(何も指定しない):手元のPCに本物のChromeウィンドウが開く。準備不要で一番見やすい。
REMOTE=1:Docker GridのChromeで動かし、noVNC越しに見る。手元のChromeを別作業で使っていて塞ぎたくないときに。HEADLESS=1:画面を一切出さない。大量に流すときやCI的に結果だけ欲しいとき用。
以前は実行のたびにヘッドレスにしたりしなかったりと一貫せず、「確認できない」ということが何度かありました。今は「画面で見える既定モードを基本にし、ヘッドレスは明確な理由があるときだけ」と運用ルールを決めています。AIにテストを任せるほど、見える状態が標準であることが大事だと感じています。
実際にハマった落とし穴
1. send_keysの改行はEnterキーになる(多重送信事故)
これは一番痛い教訓です。Seleniumの send_keys に渡した文字列の改行は、Enterキーとして送られます。1行入力欄(<input>)で改行を送ると、その都度フォームの暗黙の送信が走り、改行の数だけ送信されてしまいます。
E2Eテストとは別の、ブラウザ自動化スクリプトでこれが起きました。改行を含む長い本文が、項目の誤判定で1行入力欄に入ってしまい、しかも画面遷移しないAJAX送信のフォームだったため、誰も気づかないまま同じ宛先へ合計168回送信されていました。送信成功でフォームが空になったのを「入力が消された」と誤解してやり直す処理が、被害を倍にしていました。
教訓:textarea以外に改行を含む値を送らない。1行欄はJavaScriptでvalueを代入してinput/changeイベントを発火させる。送信を伴う自動化には「送信回数を数えて止めるガード」と「同じ宛先には1回だけ」を記録する台帳を入れる。フォームが空になっていたら、まず「もう送信済みでは」と疑う。
ややこしいのは、ファイル選択欄(type=file)では逆に、改行区切りで複数ファイルを渡すのが正しい使い方だという点です。同じ「改行」でも、欄の種類で意味がまったく変わります。
2. コンテナ越しのアップロードにはLocalFileDetector
ファイル欄に send_keys(パス) を送っても、ブラウザがコンテナ内にある以上、ホストのファイルパスはそのままでは存在しません。driver.file_detector = LocalFileDetector() を設定すると、Seleniumがファイルを転送してくれます。これが無いと、手元では正しいパスなのにアップロードだけ失敗する、という分かりにくい症状になります。
3. 待ち方は「時間」ではなく「状態」で
time.sleep で待つと、遅い環境では落ち、速い環境では時間を無駄にします。WebDriverWait と expected_conditions で「このidの要素が出るまで」「URLに /dates/ が含まれるまで」のように状態で待つのが基本です。ゆっくり見せたいときの待ち時間は、判定とは切り離して SLOW のような環境変数で足すようにしています。
4. ロケール依存の入力、残ったセッション、文字化け
- 日付・時刻欄:
type=dateはキー入力の解釈がブラウザのロケールで変わるため、JavaScriptで値を入れてchangeイベントを発火させます。 - ログインが飛ばされる:連続実行で前回のセッションが残っていると、ログイン画面がリダイレクトされます。開始時に
delete_all_cookies()を入れておきます。 - Windowsの文字化け:コンソールがcp932だと日本語の出力で落ちるので、標準出力をUTF-8に固定します。
- 並列実行:同じ新規登録テストを複数のエージェントで同時に回したら、アプリ側のIP単位のレート制限に引っかかりタイムアウトしました。バグではなく想定どおりの挙動なので、同じE2Eを並行で走らせない運用にしています。
本番環境での確認はどうしているか
ローカルとは分けて考えています。テストの共通処理では、接続先が本番ドメインだと判定した時点でデータベースを直接触る処理をすべて止め、画面から見える内容だけで判定するようにしています。確認できなかった項目は「未確認」としてまとめに必ず出します。
また、本番のパスワードをAIに入力させることはしません。本番の確認は、人間がログイン済みのブラウザを使って「500エラーになっていないか」「期待する見出しや数値が出ているか」を見る形にしています。新規登録から決済解除までの長い導線は、まずローカルのDockerでDjangoのテストクライアントを使って予行演習し、ブラウザでしか分からない部分だけをE2Eで確かめる、という使い分けです。
よくある質問
Q. Seleniumではなく、Playwrightではだめですか?
A. もちろん選択肢です。私たちは既存のSelenium Gridがすでに動いていて、noVNCで見られる環境が整っていたためSeleniumを使っています。大事なのはツールより「本物のブラウザで動かし、人間が見て確かめられる」ことです。
Q. Claude Codeが書いたテストをそのまま信用して大丈夫ですか?
A. そのままは信用しません。noVNCで動きを見る、スクリーンショットを確認する、チェック項目が画面の中身まで見ているか読む、という三点で確かめています。「エラーが出なかった」だけでなく「期待する表示が出た」を判定させるのがコツです。
Q. ヘッドレスで回したほうが速いのでは?
A. 大量に流すときはヘッドレスにします。ただし普段は画面が見える既定モードを基本にしています。見えない状態が続くと、AIが何をしたのか人間が確かめられなくなるからです。
Q. Windows on ARMでもSeleniumは使えますか?
A. 私たちの環境ではSelenium Managerがchromedriverを取得できませんでした。ARMネイティブのSelenium GridをDockerで動かし、そこへ webdriver.Remote で接続することで解決しています。
自社のWebサービスのテストをAIで回したい方は、Posiiにご相談ください。
E2Eテストまで回る開発体制を、御社にも。
Selenium GridとClaude Codeを組み合わせたテストは、POSIIの自社サービス開発で実際に使っている構成です。既存のDjangoアプリへのテスト導入や、AIエージェントを使った開発体制づくりをご相談いただけます。