🐙

ActiveJob でリトライ回数が多い場合のテストを高速化したい

2024/09/25に公開

はじめに

こんにちは、terandard です。

弊社のサービスでは、ActiveJob を用いて非同期処理を行っています。
ActiveJob の例外処理では retry_on でリトライしたり、 discard_on でジョブを破棄することができます。
またブロックに処理を記述することで、リトライにも失敗した場合はログに記録したりエラーを通知したりすることができます。

class HogeJob < ActiveJob::Base
  retry_on Exception, wait: 5.seconds, attempts: 3 do |_job, e|
    logging_error(e)
    Bugsnag.notify(e)
  end

  def perform
    hoge.do_something
  end
end

ジョブのテストでは正常系だけでなく、リトライ時の挙動もテストすることが重要です。
テストする内容としては、「リトライ回数が指定通りになっているか」「リトライに失敗した時のブロック処理が意図したものになっているか」などがあります。

RSpec.describe HogeJob do
  before do
    allow(Bugsnag).to receive(:notify)
    allow(Rails.logger).to receive(:error)
  end

  it 'performs the job 3 times and notifies the error to Bugsnag' do
    assert_performed_jobs 2 do
      described_class.perform_now
    end
    expect(Rails.logger).to have_received(:error)
    expect(Bugsnag).to have_received(:notify)
  end
end

大抵の場合は上記のようなテストで問題はありません。

しかし assert_performed_jobs では実際にジョブを実行し、実行されたジョブの回数を比較しているため、「30秒間隔で最大30分リトライする」などリトライ回数が多い場合にテストが遅くなってしまいます。(上記の例はリトライ回数が 3 回の場合は 0.52 秒に対して、リトライ回数を 30 回に変更した場合は 2.42 秒になりました)

上記のテストではログの記録とエラー通知の確認を1つのテストで行っていますが、それぞれテストを分けた場合はさらに時間がかかります。テストの実行時間は開発体験に大きく影響するため、できるだけ短くしたいところです。

そこで今回はこのようなリトライ回数が多いジョブのテストを高速化する方法を考えました。

  1. リトライ後に行う処理を単体でテストし、リトライ自体のテストは行わない
  2. ActiveJob::Exceptions.executions_for をモックする
  3. テストは CI に任せて、CI 側での並列実行数を増やす

執筆当時の Rails バージョンは 7.1.3.4 です。

1. リトライ後に行う処理を単体でテストし、リトライ自体のテストは行わない

そもそもリトライ処理自体は ActiveJob 側の実装です。
従って「指定回数リトライされること」や「リトライ処理に失敗した後にブロック内の処理を実行すること」はフレームワークの責務と考えることができます。

そこでリトライ後に行う処理を単体でテストし、リトライ自体のテストは行わない方法が考えられます。

class HogeJob < ActiveJob::Base
  retry_on Exception, wait: 30.seconds, attempts: 30 do |_job, e|
    something_after_retry(e)
  end

  def perform
    hoge.do_something
  end

  private

  def something_after_retry(e)
    logging_error(e)
    Bugsnag.notify(e)
  end
end


RSpec.describe HogeJob do
  before do
    allow(Bugsnag).to receive(:notify)
    allow(Rails.logger).to receive(:error)
  end

  describe '#something_after_retry' do
    subject(:execute) { described_class.new.send(:something_after_retry, StandardError.new) }

    it 'notifies the error to Bugsnag' do
      execute
      expect(Bugsnag).to have_received(:notify)
    end

    it 'logs the error' do
      execute
      expect(Rails.logger).to have_received(:error)
    end
  end
end

こちらの場合はリトライしないので、テストの実行時間が短縮できます。

しかしリトライ周りの結合テストができないことになるため、採用するかどうかはチームの方針によりそうです。

2. ActiveJob::Exceptions.executions_for をモックする

ActiveJob のリトライ周りの実装を確認してみました。

https://github.com/rails/rails/blob/6f57590388ca38ed2b83bc1207a8be13a9ba2aef/activejob/lib/active_job/exceptions.rb#L70-L73

executions_for が実行したリトライ回数を返しており、attempts と比較してリトライするかどうかを判断しています。

従って executions_for をモックして最大リトライ回数を返すようにすることで、初回実行後にリトライせず retry_on で指定したブロックの実行を確認できます。

RSpec.describe HogeJob do
  before do
    allow(Bugsnag).to receive(:notify)
    allow(Rails.logger).to receive(:error)

    allow_any_instance_of(HogeJob)
      .to receive(:executions_for)
      .and_return(30) # 実行したリトライ回数を 30 回に変更
  end

  it 'performs the job 30 times then logs and notifies the error to Bugsnag' do
    described_class.perform_now
    expect(Rails.logger).to have_received(:error)
    expect(Bugsnag).to have_received(:notify)
  end
end

しかし Rails の内部実装に依存しているので、将来のバージョンで動作しなくなる可能性があります。

3. テストは CI に任せて、CI 側での並列実行数を増やす

普段の開発状況を考えると、開発中の機能のテストはローカルで確認しますが、他の機能を含めた全体のテストは CI に任せることが多いです。
CircleCI などの CI サービスを利用している場合、テストを並列実行することができます。またタイミングベースのテスト分割を行うことで、特定のテストが遅い場合でも分割後のテスト時間が均等になるように調整してくれます。
https://circleci.com/docs/ja/parallelism-faster-jobs/#how-test-splitting-works

従ってテストは CI に任せて、CI 側での並列実行数を増やすことでテストの実行時間を短縮できます。
しかし開発中の機能のみ先にテストを通したい場合や、コスト面で CI の並列実行数を増やすことが難しい場合はこの方法は適していません。

まとめ

今回はリトライ回数が多いジョブのテストを高速化する方法を考えてみました。
どの方法もメリット・デメリットがあり、チームの方針や状況によって採用するかどうかが変わると思います。

自分達はこのようにしている、などの情報があればコメントしていただけると幸いです。

余談

リトライ回数を定数にして、テストでその定数をモックする方法を検討しましたが上手くいきませんでした。

詳細
class HogeJob < ActiveJob::Base
  MAX_RETRY = 30

  retry_on Exception, wait: 30.seconds, attempts: MAX_RETRY do |_job, e|
    logging_error(e)
    Bugsnag.notify(e)
  end
end

RSpec.describe HogeJob do
  before do
    allow(Bugsnag).to receive(:notify)
    allow(Rails.logger).to receive(:error)
    stub_const('HogeJob::MAX_RETRY', 3) # リトライ回数を 3 回に変更
  end

  it 'performs the job 3 times then logs and notifies the error to Bugsnag' do
    assert_performed_jobs 2 do
      described_class.perform_now
    end
    expect(Rails.logger).to have_received(:error)
    expect(Bugsnag).to have_received(:notify)
  end
end

上記実装では stub_const の評価前に HogeJob が読み込まれてリトライ周りの定義が確定してしまうため、テストを実行してもリトライ回数が変更されませんでした。

そこで別案として HogeJob を継承したクラスをテストで作成し、そのクラスでリトライ回数を変更する方法を考えました。

class HogeTestJob < HogeJob
  MAX_RETRY = 3 # リトライ回数を 3 回に変更

  # リトライ処理をオーバーライド
  retry_on Exception, wait: 30.seconds, attempts: MAX_RETRY do |_job, e|
    logging_error(e)
    Bugsnag.notify(e)
  end
end

RSpec.describe HogeJob do
  before do
    allow(Bugsnag).to receive(:notify)
    allow(Rails.logger).to receive(:error)
  end

  it 'performs the job 3 times then logs and notifies the error to Bugsnag' do
    assert_performed_jobs 2 do
      HogeTestJob.perform_now
    end
    expect(Rails.logger).to have_received(:error)
    expect(Bugsnag).to have_received(:notify)
  end
end

しかし定数のみを定義してもリトライ処理は HogeJob に記述されているため、リトライ回数が変更されませんでした。結局リトライ周りの実装もオーバーライドする必要があり、実態とテスト対象が異なることになります。

実装を変更してもテストが通ってしまうので、この方法は筋が悪そうです。

参考

GitHubで編集を提案
SocialPLUS Tech Blog

Discussion