AIエージェントがRailsで自走できるかは、検証が何秒で返るかで決まるのか?

コードを読まないAIエンジニア
サムネイル

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回の実行(割引と送料の2ファイル)
0.80秒
0.81秒
失敗をもう一度出すまでの回数(5試行)
4, 2, 5, 5, 1回(中央値4回)
毎回1回
失敗をもう一度出すまでの秒数
中央値3.2秒
0.8秒

「え、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)
  end

spec側でシードを渡すと、割引率が毎回同じになります(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.rb

Railsの画面確認に、ブラウザは要るのか?

一覧画面で「合計が円で表示される」「期限超過のバッジが1つ出る」を確かめるsystem spec 2件を、request specに移しました。ヘッドレスのChromiumを起動してスクリーンショットを保存していた確認を、get で受け取ったHTMLをNokogiriで読む形にしています。データも確かめる内容も同じです。

system spec
request spec
同じ2つの確認
1.71秒
0.83秒
表示を壊して、落ちて返ってくるまで
3.70秒
0.90秒
全件59件
5.70秒
4.86秒

ここ、ちょっと注目してください。落ちる回のほうが、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つ編集してから流した結果です。

編集したファイル
流れた例数
中央値
全件(比較の基準)
59
4.92秒
app/models/order_item.rb
9
0.83秒
app/views/orders/index.html.erb
11
0.92秒
app/models/order.rb
48
4.97秒

モデル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"
end
ruby 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はいくつありそうですか?見つかったら、そこが最初の直しどころです。