ChaoticJobで「本番でしか起きないバグ」を先回りして潰す

こんにちは、ヘンリーです。

今年2月にタイのバンコクで開催された RubyConfTHにシェアウィズのエンジニアのみんなと参加しました。

RubyConf Thailand 2026
RubyConfTH 2026の会場の様子

RubyConfTH 2026の会場の様子

実践的で非常に興味深いトピックにいくつも出会いました。その中でも、「これはRailsを使う開発チームなら知っておく価値がある」と感じた内容を、今回共有したいと思います。

ChaoticJobで「本番でしか起きないバグ」を先回りして潰す

通常のテストでは見落としてしまうもの

私たちが開発しているプロダクトでも、バックグラウンドジョブは日々重要な役割を担っています。メール送信、ファイル処理、外部サービス連携、通知配信——ユーザーからは見えない場所で、サービス体験を支える“縁の下の力持ち”です。

しかし、ここには多くのチームが見落としがちな落とし穴があります。

多くのテストは「正常系が通った」で終わってしまう。

けれど実際に障害が起きるのは、そんな理想的な状況ではありません。

  • 処理の途中で一瞬ネットワークが切れたら?
  • 2つのジョブが同時実行され、同じデータを書き換えたら?
  • リトライ時に副作用が重複したら?

こうした問題は、開発環境では静かに潜伏し、本番で最悪のタイミングで顔を出します(夜中とか土日とか…)。

今回は、ChaoticJobというGemを紹介します。

Chaos Engineering(カオスエンジニアリング)の考え方をActive Jobテストへ持ち込み、“本番でしか起きないバグ”を開発段階で発見するためのツールです。

GitHub - fractaledmind/chaotic_job: 👾 Resilience test helpers for Active Job
👾 Resilience test helpers for Active Job. Contribute to fractaledmind/chaotic_job development by creating an account on ...

Railsには ActiveJob::TestHelper が標準で提供されています。非常に便利な仕組みですが、「障害耐性」を検証する観点では弱点があります。

たとえばジョブのリトライをテストするとき、多くの人は次のように書くでしょう。

perform_enqueued_jobs do
  MyJob.perform_later
end

一見正しく見えます。

しかし内部では enqueue を上書きし、ジョブを即時実行しています。

つまり本来は

1回目 → 失敗 → 再キュー → 2回目実行

となるはずが、実際には同じ実行コンテキスト内でリトライされてしまいます。

これは本番環境の動作とは異なります。

そしてこの違いが、実際の障害を見逃す原因になることがあります。

ChaoticJobは、この問題を解決します。しかも、できることはそれだけではありません。

導入は数分

Gemfileへ追加します。

bundle add chaotic_job

テストクラスでHelperを読み込みます。

class MyJobTest < ActiveJob::TestCase
  include ChaoticJob::Helpers

  # your tests
end

MinitestとRSpecの両方に対応しています。

導入の敷居は非常に低く、既存のテストへ段階的に組み込める点も魅力です。

本番に近い形でジョブを実行する

ChaoticJobでは perform_enqueued_jobs の代わりに perform_all_jobs を利用します。

MyJob.perform_later
perform_all_jobs

これによりジョブとリトライが、本番同様に「波(Wave)」として順序通り実行されます。

テスト環境だけ特殊な挙動をする問題を避けられます。

ジョブがさらに別ジョブを起動するケースも、時間範囲を指定して自然にテストできます。

JobThatSchedules.perform_later

perform_all_jobs_before(4.seconds)

assert_equal 1, enqueued_jobs.size
assert_equal 2, performed_jobs.size

perform_all_jobs_after(1.day)

assert_equal 0, enqueued_jobs.size
assert_equal 3, performed_jobs.size

ChaoticJobの核となる「Glitch」

ChaoticJobの中心的な考え方が Glitch です。

Rubyの TracePoint を使い、ジョブ実行中の特定箇所へ一時的な障害を注入します。

特徴は次の3つです。

  • 一度だけ発生する
  • 発生場所を正確に指定できる
  • 現実的な障害を再現できる

例えば次のような問題を再現できます。

  • APIタイムアウト
  • ネットワーク断
  • 一時的なDB障害
  • インフラ側の瞬間的なエラー

現実のシステムでは「永続的な障害」より「一時的な障害」のほうがはるかに多く、しかも再現が難しいケースがほとんどです。

ChaoticJobはそこを狙い撃ちできます。

Glitchの種類

種類 発生タイミング 指定方法
glitch_before_call メソッド呼び出し前 Class#method
glitch_before_return メソッド終了前 Class#method
glitch_before_line 特定行実行前 path/file.rb:42

障害シナリオを再現する

特定の障害パターンを再現したい場合は run_scenario を使います。

test "job recovers from failure before step_3" do
  run_scenario(
    SimpleJob.new,
    glitch: glitch_before_call("SimpleJob#step_3")
  )

  assert_equal 5, ChaoticJob::Journal.total
end

このケースでは step_3 の直前で障害が発生します。

その後ジョブはリトライされ、最終的に成功します。

ここで重要なのは、「副作用が何回発生したか」を検証できることです。

ジョブ開発では、同じ処理が複数回実行されても問題ない Idempotency(冪等性) が非常に重要になります。

この考え方は、大規模なシステムほど価値を発揮します。

すべての失敗パターンを網羅する

さらに強力なのが test_simulation です。

test_simulation(SimpleJob.new) do
  assert_operator ChaoticJob::Journal.total, :>=, 3
end

これはジョブのコールスタックを解析し、考えられるすべての障害ポイントを自動生成します。

3ステップのシンプルなジョブでも、内部的には複数パターンのテストケースが生成されます。

つまり開発者は「漏れなく障害ケースを書く」ことに苦労する必要がありません。

テストを書く労力より、設計そのものの品質改善へ時間を使えるようになります。

レースコンディションまで再現できる

もう一つ厄介なのがレースコンディションです。

複数ジョブが同時に動き、共有データへ想定外の順番でアクセスしてしまう問題です。

通常、この種のバグは再現が非常に難しく、「たまに起きる」が最悪の敵になります。

ChaoticJobでは、ジョブ同士の実行順序を制御し、決定論的に再現できます。

test_races(Job1.new, Job2.new) do
  assert_equal 6, ChaoticJob::Journal.size
  assert race.success?
end

これにより、偶然に頼らず並行処理の安全性を検証できます。

バックグラウンド処理が増えるほど、この価値は大きくなります。

実運用に近い例:Welcomeメール送信

class WelcomeEmailJob < ActiveJob::Base
  retry_on StandardError, attempts: 3

  def perform(user_id)
    user = User.find(user_id)

    UserMailer.welcome(user).deliver_now

    AuditLog.create!(
      event: "welcome_email_sent",
      user_id: user_id
    )
  end
end

このジョブで怖いのは、メールだけ二重送信されるケースです。

ユーザーから見ると「同じメールが何通も届く」という地味ながら不快な障害になります。

ChaoticJobなら、あらゆる失敗タイミングを試しながら「メールは1回だけ送られる」ことを検証できます。

ChaoticJobと従来テストの比較

項目 従来のテスト ChaoticJob
正常系
リトライ挙動 ⚠️ 実環境との差分あり ✅ 本番同等
個別障害シナリオ ❌ 手動モック中心 ✅ run_scenario
全障害パターン ✅ test_simulation
レースコンディション ❌ 再現困難 ✅ run_race / test_races

まとめ

バックグラウンドジョブは非同期で動き、リトライし、複数ジョブと複雑に関係し合います。

だからこそ、本当に危険なのは「普段は起きない障害」です。

ChaoticJobはChaos Engineeringの考え方をActive Jobへ持ち込み、次のことを可能にします。

  1. 本番に近い形でジョブを実行する
  2. 狙った場所へ障害を注入する
  3. 考えられる失敗を網羅的に検証する
  4. レースコンディションを再現する

私たちも日々プロダクトを開発する中で、「正常系が通る」より「壊れたときも正しく動く」ことの重要性を強く感じています。

信頼されるプロダクトは、順調なときではなく、想定外が起きたときに真価が問われます。

もしバックグラウンドジョブを多用するRailsアプリケーションを運用しているなら、ChaoticJobは一度試す価値のあるツールだと思います。

目指すべきは「うまく動くこと」ではなく、壊れても正しく振る舞うことなのかもしれません。

(ヘンリー)

 


Testing for Background Jobs: Testing with ChaoticJob

Hi, I’m Henry. In February, I joined the RubyConfTH, and discovered several exciting key topics that I’d like to share with you in this article. I hope this will be helpful when you’re working with Ruby on Rails.

RubyConfTH 2026の会場の様子

Background jobs are the backbone of any serious Rails application. They handle emails, process uploads, sync data, send notifications — the things your users depend on, running quietly behind the scenes. But here’s the uncomfortable truth: most teams test the happy path and call it a day.

What happens when a network blip interrupts your job midway? What if two jobs race each other and corrupt shared state? These are the bugs that only appear in production, at the worst possible moment.

This post introduces ChaoticJob, a gem that brings chaos engineering principles to Active Job testing — so you can find those bugs before your users do.

Why Standard Testing Falls Short

Rails ships with ActiveJob::TestHelper, and it’s quite useful. But when it comes to resilience testing — testing retries, transient failures, and race conditions — it has a significant blind spot.

Consider the most natural way to test job retries:

perform_enqueued_jobs do
  MyJob.perform_later
end

This looks right, but it isn’t. Under the hood, perform_enqueued_jobs overwrites the enqueue method to immediately execute the job inline. Instead of your job running in waves (attempt 1 → fails → retry → attempt 2), the retry executes inside the original job’s execution context. This is not how your job behaves in production, and it can mask real bugs.

ChaoticJob fixes this, and goes much further.

Getting Started

Add it to your Gemfile:

bundle add chaotic_job

Then include the helpers in your test case:

class MyJobTest < ActiveJob::TestCase
  include ChaoticJob::Helpers

  # your tests here
end

ChaoticJob works with both Minitest and RSpec.

Performing Jobs the Right Way

ChaoticJob provides perform_all_jobs as a drop-in replacement for perform_enqueued_jobs:

MyJob.perform_later
perform_all_jobs

This performs the job and all of its retries in proper waves — just like production. No more confusing inline-retry behavior.

Need to test a job that schedules another job? Use the time-scoped helpers:

JobThatSchedules.perform_later

perform_all_jobs_before(4.seconds)
# or: perform_all_jobs_within(4.seconds)

assert_equal 1, enqueued_jobs.size
assert_equal 2, performed_jobs.size

perform_all_jobs_after(1.day)

assert_equal 0, enqueued_jobs.size
assert_equal 3, performed_jobs.size

Core Concept: Glitches

The heart of ChaoticJob is the glitch — a transient error injected at a precise point in your job’s execution using Ruby’s TracePoint. Glitches are:

  • Transient: they fire once, and only once
  • Targeted: you specify exactly where in the callstack they occur
  • Realistic: they simulate real-world failures like network errors, API timeouts, or infrastructure hiccups

There are three kinds of glitches:

Kind Triggers before… Key format
glitch_before_call A method is called "ClassName#method_name"
glitch_before_return A method returns "ClassName#method_name"
glitch_before_line A specific line runs "path/to/file.rb:42"

By default, ChaoticJob raises a ChaoticJob::RetryableError — a custom error that the gem ensures your job is configured to retry on. You can also raise your own specific errors for more targeted scenarios.

Scenario Testing: Injecting a Specific Failure

Use run_scenario to test what happens when a glitch is injected at a precise location:

class SimpleJob < ActiveJob::Base
  def perform
    step_1
    step_2
    step_3
  end

  def step_1; ChaoticJob::Journal.log; end
  def step_2; ChaoticJob::Journal.log; end
  def step_3; ChaoticJob::Journal.log; end
end

test "job recovers from failure before step_3" do
  run_scenario(SimpleJob.new, glitch: glitch_before_call("SimpleJob#step_3"))

  # step_1 and step_2 ran on attempt 1
  # step_1, step_2, and step_3 ran on attempt 2 (after retry)
  assert_equal 5, ChaoticJob::Journal.total
end

The glitch fires just before step_3 is called. The job retries, runs all three steps again, and completes successfully. Your assertion verifies the job is idempotent — side effects happened the right number of times.

The Journal

ChaoticJob::Journal is a simple utility for tracking what your job does during a test:

Method Description
Journal.log Log that something happened
Journal.log(value, scope: :name) Log a specific value under a named scope
Journal.total Count of all logs
Journal.size(scope: :name) Count of logs under a named scope
Journal.entries All logged values

Simulation Testing: Exhaustive Failure Coverage

What if you want to guarantee your job handles any possible transient failure? That’s what test_simulation is for.

class TestSimpleJob < ActiveJob::TestCase
  include ChaoticJob::Helpers

  class SimpleJob < ActiveJob::Base
    def perform
      step_1; step_2; step_3
    end
    def step_1 = ChaoticJob::Journal.log
    def step_2 = ChaoticJob::Journal.log
    def step_3 = ChaoticJob::Journal.log
  end

  test_simulation(SimpleJob.new) do
    assert_operator ChaoticJob::Journal.total, :>=, 3
  end
end

This macro dynamically generates a separate test case for every possible glitch point in your job’s callstack. For a three-step job, it generates 12 scenarios — one for each call, line, and return event traced during execution.

Custom Callstacks

tracer = ChaoticJob::Tracer.new { |tp| tp.path.start_with?(Rails.root.to_s) }
tracer.capture { MyJob.perform_now }

test_simulation(MyJob.new, callstack: tracer.callstack) do
  # assertions
end

Race Condition Testing

Transient failures are one danger; race conditions are another. They happen when two concurrent jobs read and write shared state in an unfortunate order. ChaoticJob can simulate race conditions deterministically.

Running a Specific Race

test "jobs don't corrupt state when interleaved" do
  job1 = Job1.new
  job2 = Job2.new

  job1_callstack = trace(job1)
  job2_callstack = trace(job2)

  schedule = job1_callstack.to_a.zip(job2_callstack.to_a).flatten(1)

  ChaoticJob::Journal.reset!
  race = run_race([job1, job2], schedule: schedule)

  assert_equal [1.1, 2.1, 1.2, 2.2, 1.3, 2.3], ChaoticJob::Journal.entries
  assert race.success?
end

Exhaustive Race Coverage

test_races(Job1.new, Job2.new) do
  assert_equal 6, ChaoticJob::Journal.size
  assert race.success?
end

This generates a test for every possible interleaving of the two jobs’ callstacks, giving exhaustive coverage of all potential race conditions.

Real-World Example: A Welcome Email Job

class WelcomeEmailJob < ActiveJob::Base
  retry_on StandardError, attempts: 3

  def perform(user_id)
    user = User.find(user_id)
    UserMailer.welcome(user).deliver_now
    AuditLog.create!(event: "welcome_email_sent", user_id: user_id)
  end
end
class WelcomeEmailJobTest < ActiveJob::TestCase
  include ChaoticJob::Helpers

  test_simulation(WelcomeEmailJob.new(user.id)) do
    assert_equal 1, ActionMailer::Base.deliveries.count
    assert_equal 1, AuditLog.where(event: "welcome_email_sent").count
  end
end

ChaoticJob vs. Traditional Testing

Concern Traditional Testing ChaoticJob
Happy path
Retry behavior ⚠️ Inaccurate with perform_enqueued_jobs ✅ Accurate with perform_all_jobs
Specific failure scenarios ❌ Manual mocking run_scenario
All possible failures test_simulation
Race conditions ❌ Non-deterministic run_race / test_races

Conclusion

Background jobs are tricky. They run asynchronously, they retry, and they interact with other jobs in ways that are hard to predict. The bugs in these edge cases are the most dangerous — silent, rare, and painful when they surface in production.

ChaoticJob brings the discipline of chaos engineering and deterministic simulation testing to Active Job. It gives you the tools to:

  1. Perform jobs correctly in tests, with proper retry waves
  2. Inject glitches at precise points to test specific failure scenarios
  3. Simulate all possible failures exhaustively with a single macro
  4. Reproduce and test race conditions deterministically

If your application relies on background jobs — and most do — ChaoticJob is worth adding to your test suite. The goal isn’t to test that your jobs work when everything goes right. It’s to guarantee they work when everything goes wrong.

(Henry)

タイトルとURLをコピーしました