ActiveJob でリトライ回数が多い場合のテストを高速化したい
はじめに
こんにちは、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つのテストで行っていますが、それぞれテストを分けた場合はさらに時間がかかります。テストの実行時間は開発体験に大きく影響するため、できるだけ短くしたいところです。
そこで今回はこのようなリトライ回数が多いジョブのテストを高速化する方法を考えました。
- リトライ後に行う処理を単体でテストし、リトライ自体のテストは行わない
-
ActiveJob::Exceptions.executions_for
をモックする - テストは 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
こちらの場合はリトライしないので、テストの実行時間が短縮できます。
しかしリトライ周りの結合テストができないことになるため、採用するかどうかはチームの方針によりそうです。
ActiveJob::Exceptions.executions_for
をモックする
2. ActiveJob のリトライ周りの実装を確認してみました。
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 サービスを利用している場合、テストを並列実行することができます。またタイミングベースのテスト分割を行うことで、特定のテストが遅い場合でも分割後のテスト時間が均等になるように調整してくれます。
従ってテストは 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 に記述されているため、リトライ回数が変更されませんでした。結局リトライ周りの実装もオーバーライドする必要があり、実態とテスト対象が異なることになります。
実装を変更してもテストが通ってしまうので、この方法は筋が悪そうです。
参考
- https://railsguides.jp/active_job_basics.html
- https://stackoverflow.com/questions/51773822/how-to-properly-test-activejobs-retry-on-method-with-rspec
- https://api.rubyonrails.org/classes/ActiveJob/TestHelper.html#method-i-assert_performed_jobs
- https://circleci.com/docs/ja/parallelism-faster-jobs/#how-test-splitting-works
Discussion