データウェアハウス開発部 医療機関基盤グループでシステム開発をしている堀です。開発中のRuby on Railsのプロジェクトではレビュアーとしてソースを見ることが多いのですが、なかでもActiveJobとそのユニットテストとなるRSpecを見ていて思うことがあったので記事にします。
ActiveJobといけてないRSpec
ActiveJobはRailsのフレームワークで用意された仕組みです。非同期実行できるのでAWSのRedshiftやAthenaといったレスポンスに時間がかかるサービスを呼び出す処理に利用しています。
- ActiveJobでRedshift/Athenaを操作するイメージ図
- 本記事ではActiveJobのセットアップについては割愛
ActiveJobとは
- ActiveJobはパブリックなメソッドperformを持ち、ジョブを実行するとperformが処理される
- 実行時はクラスメソッドを呼び出す
- 同期ならperform_now
- 非同期なら perform_later
# 同期実行 MyJob.perform_now() # 非同期実行 MyJob.perform_later()
私はメンバーの実装したコードをレビューする機会が多いのですが、どうもRSpecのソースに違和感を感じました。具体的な箇所をあげると、、、
- モックとスタブが乱立
テストのために本当に必要なものだけが書かれてるのだろうか? - 一つのテスト実行のための手順が複雑
本当に必要十分な、、、(以下略) - 縦スクロールしまくらないと見切れない行数の準備コード
チョー長すぎ!
といったところです。
[ActiveJobの継承クラスの例]
class MyJob < ApplicationJob # ApplicationJobから派生させることでActiveJobを継承する def perform(*args) # インプットされた引数の検証処理 # ジョブの事前準備処理 # 条件判定での処理の振り分け # ループによる繰り返し処理 # といった感じでつらつらと〜〜〜 end end
[RSpecのソースの例]
require "rails_helper" Rspec.describe "MyJob", type: "job" do describe "正常系のテスト" let!(:param) { 'hogehoge' } #ジョブに渡す引数 let!(:preset_data) { # ジョブ内部で使われる想定データの準備、スタブの戻り値などに設定するデータセット { data1: [ 'foo1', 'bar1'], data2: [ 'foo2', 'bar2'] } } # ほかにも大量に変数が定義されてるよ! before do # MyJobの内部で呼ばれているオブジェクトのモック1 mock_obj1 = instance_double(TargetClass) allow(TargetClass).to receive(:new).and_return { mock_obj1 } allow(mock_obj1).to receive(:func1).and_return { "RETURN" } # MyJobの内部で呼ばれているオブジェクトのモック2 mock_obj2 = instance_double(TargetClass) allow(TargetClass).to receive(:new).and_return { mock_obj2 } allow(mock_obj2).to receive(:func1).and_return { "RETURN" } # 他にも必要なモックとスタブが大量に記述、、、、 # MyJobを実行 # ユニットテストでの動作確認が目的なので非同期実行ではなく同期実行とする MyJob.perform_now end it "ジョブが実行された結果のテスト" do # アウトプットが期待値と一致するかを確認する # モックとスタブが想定通りに呼ばれたかを確認する end end end
実装の仕方のせいもあるでしょうが、とにかくテストコードの見通しが悪くてテスト要件が満たされているか把握しづらくなっています。これだとレビューも大変だし今後のメンテを考えると気が重かったりします。
ユニットテストを書くには
RSpec(ユニットテスト)を書くにあたりパブリックメソッドであるperformのテストを書くことになります。これは「オブジェクトの振る舞いをテストする」というユニットテストの原則に従ったものになります。
しかしperformの処理が複雑化しているとperformのアウトプットだけをみてテストするには不十分に感じることもあります。そして実際のソースコードでは、プライベートメソッドに切り分けてあり、performではそれを呼びだすようになっているはずです(なってなかったら、、、嫌ですね。。)。なので原則のことは一旦忘れてプライベートメソッドをテストすればプログラムの挙動を網羅できます。
[ActiveJobの派生クラスの例]
class MyJob < ApplicationJob def perform(*args) # 細かい処理は、プライベートメソッドに切り分ける! # performの中はプライベートメソッドを呼び出すようにする! raise StandardError, "引数の検証失敗" unless validate?(*args) ready(args[0]) classify calc_loop output end private def validate?(*args) # インプットされた引数の検証処理 end def ready(param) # ジョブの事前準備処理 end def classify # 条件判定での処理の振り分け end def calc_loop # ループによる繰り返し処理 end def output # 出力処理 end end
[プライベートメソッドをテストするRspecの例]
Rspec.describe "MyJob" do describe "プライベート関数のテスト" do before do @job = MyJob.new end context "validate?のテスト" do before do # validate?を実行するにあたり必要な準備 end it "正常系" do expect { @job.send(:validate?).with(param1) }.to eq true end end context "ready" do # 同様にプライベート関数のユニットテストを実装 end # その他のテスト〜〜〜 end end
ActiveJobの継承クラスをnewして、sendにプライベートメソッドのシンボルを渡すことでプライベートメソッドを実行できます。インスタンス変数をinitializerやbefore_performで指定したメソッドで初期化している場合は、呼び出されないので要注意です。
とにかく、これでperfromから呼び出されるプライベートメソッドをテストできて細かい制御も確認できるようになりました。
オブジェクトの振る舞いをテストするには?
今回の業務のなかでは工数の都合もありプライベートメソッドのユニットテストまでとしました。
しかし、このやり方ではジョブクラスの初期化時の挙動に違いがあるのが気になります。クラス内のインスタンス変数はテストコードで都度準備する必要があり、これではクラスの実装を知らないとテストコードが書けないです。そこで「オブジェクトの振る舞いをテストする」を念頭に設計自体を見直してみたのが次の図です。
内部で行っていた処理を別クラスに切り出し、performではクラスをnewしてオブジェクトを生成し処理をさせるようにします。こうすることで各クラスに対してテストが書けるようになります。ActiveJob自体のテストは、オブジェクトのモックとスタブを用意すればperform自体の挙動のテストに集中できます。クラス単位でのテストができるようになり、またオブジェクト単位での処理フローとなったことでActiveJobの実装も見通しが良くなりそうです。
まとめ
RSpecの改善とテストを書く観点からソースのあるべき形の模索について考えてみました。見直した設計は次回ActiveJobを実装する機会があれば取り入れたいと思っています。そしてソースの質が上がればソースをレビューする側もされる側も幸せになれるはずです。
IT分野ではまだまだ課題があり、そこに従事する現場では常にブラッシュアップしていく姿勢が求められます。弊社ではそんな課題に真摯に取り組んでいける仲間を募集しております。IT x 医療 = ヘルステックの分野で自分の力を試したい方はぜひJMDCを選択肢に加えていただければ幸いです!
JMDCでは、ヘルスケア領域の課題解決に一緒に取り組んでいただける方を積極採用中です!フロントエンド /バックエンド/ データベースエンジニア等、様々なポジションで募集をしています。詳細は下記の募集一覧からご確認ください。 hrmos.co
まずはカジュアルにJMDCメンバーと話してみたい/経験が活かせそうなポジションの話を聞いてみたい等ございましたら、下記よりエントリーいただけますと幸いです。 hrmos.co
★最新記事のお知らせはぜひ X(Twitter)、またはBlueskyをご覧ください!