AIエージェントにRailsの修正を任せたのに、テストが本当に通ったのかを結局自分で見に行っている。そこから抜けるために、検証用の小さなRailsアプリで、AIが回す検証ループ1周の秒数と、落ちたspecがもう一度落ちるまでの回数を、直す前と後で測りました。1回の秒数がほとんど変わらなかった直しで、失敗の再現は4回から1回になっています。
AIエージェントが確かめられないRailsとは、どんな状態か?
Claude Codeのベストプラクティスには、こう書かれています。
実行できるチェックがないと、「完了したように見える」が唯一の利用可能なシグナルであり、あなたが検証ループになります。
AIが「テスト通りました」と言ったとき、あなたはそれをそのまま信じられますか?信じきれずに自分でもう一度流しているなら、検証ループの1周に人が挟まっています。
ゲーム開発者のKyle Kukshtelさんは「Your Game Is A Harness」(2026年10月10日)で、AIがゲームに苦戦するのは状態がコードの外にあって読めないからだと書いています。乱数を1つのシードから出す、スクリーンショットをodiffで比べて「85%一致」のように数値で返す、ロジックを描画から切り離す。そうすればエージェントの1ターンでループ全体が「数秒で」回る、という主張です。数秒は著者の見立てで、測った値は載っていません。
これをRailsに移すと、直す場所は次の3か所になると考えました。
- 時刻と乱数を固定して、失敗を再現できるようにする
- 画面の確認を、ブラウザ起動からHTMLレスポンスの検証に置き換える
- 変更したファイルに関係するspecだけを1コマンドで流す
題材は検証用に作った注文アプリで、割引、税、土日だけの送料、一覧画面があり、テストは56件。遅く不安定になる原因をわざと入れてあります。Ruby 4.0.7とRails 8.1.4のコンテナで、各条件1回捨て打ちしてから5回測った中央値を見ています。
1回0.8秒のspecでも、失敗の再現に4回かかっていた
キャンペーンの割引率を0・5・10・20%からランダムに選ぶ処理に、丸めのバグを1つ仕込みました。切り捨てるはずの割引額を四捨五入しているので、5%が選ばれたときだけ1,990円の割引が99円でなく100円になります。同じコマンドを40回流すと、落ちたのは10回でした。
「え、1回の秒数は変わらないの?」って思いますよね。変わったのは、同じ失敗が確実に出ることです。4回に1回しか落ちないspecだと、AIは修正後に通ったのが直したからなのか、たまたま5%が選ばれなかっただけなのかを区別できません。
ここが意外だったんですが、失敗時に出る Randomized with seed N の番号を --seed に渡して10回流し直しても、再現したのは3回、1回、0回でした。spec_helper.rb に Kernel.srand config.seed が入っていても、引数なしの Random.new はRuby本体の乱数とは別の状態を持つので、RSpecのseedでは揃いません。
直し方は、乱数源を引数で受け取るだけです。モデル側はこうしました。
# キャンペーンで割引率をランダムに決める
def assign_campaign_discount(rng: Random.new)
self.coupon_percent = CAMPAIGN_PERCENTS.sample(random: rng)
endspec側でシードを渡すと、割引率が毎回同じになります(build_order はこのアプリのテスト用ヘルパーです)。
# 乱数は Random.new(固定のシード) を渡して、毎回同じ割引率にする
# Random.new(seed) の結果: 1 -> 5%、2 -> 0%、3 -> 10%、5 -> 20%
{ 2 => 0, 1 => 5, 3 => 10, 5 => 20 }.each do |seed, percent|
it "seed #{seed} picks #{percent}% and floors the discount to whole yen" do
order = build_order(items: [[1990, 1]])
order.assign_campaign_discount(rng: Random.new(seed))
expect(order.coupon_percent).to eq(percent)
expect(order.discount).to eq(1990 * percent / 100)
end
end失敗メッセージも読みやすくなりました。直す前は expected: 99 got: 100 だけで、どの割引率が選ばれたかは出ません。直した後は、落ちた例の名前に「seed 1 picks 5%」と出ます。
時刻も同じ構図です。平日を前提に Time.current を使う送料specが2件あり、測った日は土曜だったので、実際に落ちました。libfaketime の faketime でコマンドに渡す日付を10月12日から18日まで変えると、落ちるのは土日だけ。spec側で travel_to を使って水曜に固定したら、7日間すべて通りました。
around { |example| travel_to(wednesday) { example.run } }faketime "2026-10-17 12:00:00" bundle exec rspec spec/models/order_shipping_spec.rbRailsの画面確認に、ブラウザは要るのか?
一覧画面で「合計が円で表示される」「期限超過のバッジが1つ出る」を確かめるsystem spec 2件を、request specに移しました。ヘッドレスのChromiumを起動してスクリーンショットを保存していた確認を、get で受け取ったHTMLをNokogiriで読む形にしています。データも確かめる内容も同じです。
ここ、ちょっと注目してください。落ちる回のほうが、system specでは約2秒長いんです。Capybaraは要素が見つかるまで再試行してから落とし、その待ち時間の既定が2秒だからです。直している最中のAIが流すのは、たいてい落ちる回ですよね。
移した後のspecはこれです。
require "rails_helper"
# system spec(ブラウザ起動 + スクリーンショット)でやっていた確認を、HTMLレスポンスの検証に置き換えたもの
RSpec.describe "Orders page (HTML response)", type: :request do
before do
create_order(customer: "Sato", items: [[1000, 2]], status: "paid", placed_at: 1.day.ago)
create_order(customer: "Suzuki", items: [[1980, 1]], status: "pending", placed_at: 10.days.ago)
end
it "shows the totals in yen" do
get orders_path
text = Nokogiri::HTML(response.body).css("td.total").map(&:text)
expect(text).to include("¥2,200", "¥2,178")
end
it "shows the overdue badge on the old pending order" do
get orders_path
doc = Nokogiri::HTML(response.body)
expect(doc.css(".badge-overdue").size).to eq(1)
end
end失敗したとき、AIが原因をテキストで読めるかも見ました。通貨記号を ¥ から $ に壊すと、system specの have_text は画面のテキストを丸ごと出すので、$2,200 になっているのがすぐ分かります。ところがバッジのCSSクラス名を変えた場合は、system specの have_css が「一致なし」と出すだけで、手がかりはスクリーンショットのPNGしかありません。request specも expected: 1 got: 0 で、HTMLは出ません。
クラス名が変わったという原因には、どちらもテキストだけでは届きませんでした。あなたのsystem specのうち、本当にブラウザでしか確かめられないものはいくつありますか?
変更分だけspecを流すスクリプトと、効かなかった1ファイル
3か所目は、変更したファイルに対応するspecだけを流す bin/spec-changed です。git diff と未追跡のファイルから、app/models/foo.rb なら spec/models/foo_spec.rb と foo_*_spec.rb を、コントローラーとビューなら対応するrequest specを選びます。config/ や spec/support/ が変わったときは、影響を決められないので全件を流します。
#!/usr/bin/env bash
# 変更したファイルに対応するspecだけを、1コマンドで流す。
# bin/spec-changed # HEADからの差分(未コミット・未追跡を含む)に対応するspec
# BASE=main bin/spec-changed # mainからの差分
# DRY_RUN=1 bin/spec-changed # 流さずに、対象のspecだけ表示
# 対応づけ: models/foo.rb -> spec/models/foo_spec.rb と foo_*_spec.rb
# controllers/foos_controller.rb, views/foos/* -> spec/requests/foos_spec.rb と foos_*_spec.rb
# config/ db/ Gemfile spec/support などは、どのspecに効くか決められないので全件を流す。
set -euo pipefail
cd "$(dirname "$0")/.."
base=${BASE:-HEAD}
changed=$( (git diff --name-only "$base"; git ls-files --others --exclude-standard) | sort -u)
specs=()
full=0
add() { for p in "$@"; do [ -e "$p" ] && specs+=("$p"); done; return 0; }
for f in $changed; do
name=$(basename "$f" .rb)
case "$f" in
spec/*_spec.rb) add "$f" ;;
app/models/*.rb) add spec/models/${name}_spec.rb spec/models/${name}_*_spec.rb ;;
app/services/*.rb) add spec/models/$(echo "$name" | sed -E 's/([a-z])([A-Z])/\1_\2/g')_spec.rb ;;
app/controllers/*_controller.rb) n=${name%_controller}; add spec/requests/${n}_spec.rb spec/requests/${n}_*_spec.rb ;;
app/views/*/*) d=$(basename "$(dirname "$f")"); add spec/requests/${d}_spec.rb spec/requests/${d}_*_spec.rb ;;
config/*|db/*|Gemfile*|spec/rails_helper.rb|spec/spec_helper.rb|spec/support/*) full=1 ;;
esac
done
if [ "$full" = 1 ]; then
echo "共通の設定が変わっているので全件を流す" >&2
targets=(spec)
else
targets=($(printf '%s\n' "${specs[@]:-}" | sort -u))
fi
[ "${#targets[@]}" -eq 0 ] || [ -z "${targets[0]}" ] && { echo "対応するspecがない(変更: $(echo $changed | tr '\n' ' '))" >&2; exit 0; }
echo "対象: ${targets[*]}" >&2
[ "${DRY_RUN:-0}" = 1 ] && exit 0
exec bundle exec rspec "${targets[@]}"ファイルを1つ編集してから流した結果です。
app/models/order_item.rbapp/views/orders/index.html.erbapp/models/order.rbモデル1つやビュー1つなら1秒を切ります。ところが order.rb を触ると、名前が order_* に一致する集計のspecまで拾い、全件とほぼ同じ秒数になりました。この集計specは毎回150注文×4明細を作る10件で、直す前の --profile ではこのグループだけで3.96秒、全件の約7割を占めていたものです。
対応づけを速くしても、遅いspecが対象に入れば速くなりません。名前の規則だけで選ぶので、明細の変更が一覧画面の合計に出るようなケースを拾えない弱さもあります。
小さなアプリでは、検証ループの差は数秒どまり
このアプリはRailsの起動だけで0.6〜0.8秒かかり、spec1ファイルの下限が約0.8秒。直す前でも全件5〜6秒と、もともと速いんです。3か所を直した後の全件は6.10秒から5.00秒になりましたが、後半はホストの負荷が上がっていたので、この約1秒は揺れの範囲かもしれません。
検証に数十秒から数分かかる実際のアプリで、同じ直しがどれだけ効くかは、この実験からは言えません。AIエージェントを実際に回して、人を呼ばずに進める時間が延びるかも測っていません。測ったのは、AIが回すはずの検証1周の秒数と、失敗の再現回数です。
それでも私は、アプリが大きくなっても残る差が2つあると見ています。4回に1回しか落ちないspecは、アプリの大きさに関係なく4回に1回しか落ちません。Capybaraの2秒待ちも、落ちる確認1つごとに乗ります。
自分のRailsで最初に測るのは、失敗がもう一度出るまでの回数
最後に落ちたspecを、同じコマンドで落ちるまで流してみてください。使ったのはこのスクリプトです。
# 使い方: ruby until_fail.rb "コマンド" [試行回数=5] [1試行の上限=30]
# 「失敗したテストをもう一度失敗させる」までに、同じコマンドを何回・何秒流したかを測る
cmd, trials, cap = ARGV[0], (ARGV[1] || 5).to_i, (ARGV[2] || 30).to_i
system(cmd, out: File::NULL, err: File::NULL) # 捨て打ち
trials.times do |i|
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
runs = 0
failed = false
while runs < cap
runs += 1
system(cmd, out: File::NULL, err: File::NULL)
if !$?.success?
failed = true
break
end
end
secs = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
puts "trial #{i + 1}: #{failed ? "failed at run #{runs}" : "no failure in #{runs} runs"} / #{secs.round(1)}s"
endruby until_fail.rb "bundle exec rspec spec/models/order_discount_spec.rb -e \"floors the discount\"" 5 30毎回「failed at run 1」なら、そのspecはAIにとっても再現できる失敗です。2回目以降にばらつくなら、時刻や乱数、実行順のように、流すたびに変わるものに依存しています。秒数を見るときは、1回捨て打ちしてから5回測って中央値を取ると、キャッシュの影響を避けられます。
あなたのリポジトリで、1回で落ちないspecはいくつありそうですか?見つかったら、そこが最初の直しどころです。
コメント
ログイン か 会員登録 するとコメントできます