RSpecでS3オブジェクトの挙動をスタブする
前回記事の続き。前回は1回のRakeタスクで発行されるクエリ数の削減に取り組んだ。
やりたいこと
リファクタによってコードを壊さないために、RSpecでテストを追加したい。
Rakeタスク内でS3オブジェクトを使用しているため、これをスタブ化する。
スタブ化したい理由は2つ。
- S3からのファイル取得による料金発生を防ぎたい
- S3上のファイルではなくローカルのテストデータを使用したい
該当のコード
s3 = Aws::S3::Client.new( region: region, access_key_id: ENV['AWS_ACCESS_KEY_ID'], secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'] )
file = s3.get_object(bucket: bucket, key: key) lines = CSV.parse(file.body.read)
実装したコード
describe 'import_with_line' do subject(:task) { Rake.application['master_csv:import_with_line'] } it 'import_with_line' do file = file_fixture('master.csv').read allow_any_instance_of(Aws::S3::Client).to receive_message_chain(:get_object, :body, :read).and_return(file) expect { task.invoke }.not_to raise_error end end
以下が今回の味噌となる部分。
allow_any_instance_of(Aws::S3::Client).to receive_message_chain(:get_object, :body, :read).and_return(file)
実装過程
前提
そもそもRakeタスクのコードが上から順番に処理していくようないわゆる手続き型になっているためテストするのが難しいが、
そこに手を加えるモチベがなかったので、ひとまずこのままでテストを書くことにした。
Aws::S3::Client
から生成されたs3
インスタンスはimport_with_line
タスク内で、get_object
メソッドを呼んでいる。
さらに、file.body.read
というメソッドチェーンでデータの中身を読み込んでいる。
file = s3.get_object(bucket: bucket, key: key) lines = CSV.parse(file.body.read)
(ちなみにCSV.parseではCSV形式の文字列を配列に変換している)
CSV.parse (Ruby 3.1 リファレンスマニュアル)
テスト環境の設定にもよるが、このままテストを実行するとS3上の実ファイルが読み込まれるか、もしくはS3への接続エラーが発生する。
どちらのケースにしても避けたいので、s3インスタンスをスタブ化してこちらで戻り値を指定するようにする。
手順
改めて実装したテストコードを貼る。
describe 'import_with_line' do subject(:task) { Rake.application['master_csv:import_with_line'] } it 'import_with_line' do file = file_fixture('master.csv').read allow_any_instance_of(Aws::S3::Client).to receive_message_chain(:get_object, :body, :read).and_return(file) expect { task.invoke }.not_to raise_error end end
file = file_fixture('master.csv').read
では、spec/fixtures/files
配下に置いたローカルデータを読み込んでいる。(このpathはRSpecのデフォルト設定だったはず)
続いて今回のメインテーマ。
allow_any_instance_of(Aws::S3::Client).to receive_message_chain(:get_object, :body, :read).and_return(file)
それぞれの引数で指定している内容は以下である。
allow_any_instance_of(クラス名).to receive_message_chain(:インスタンスメソッド群).and_return(戻り値)
これで、指定したクラスから作られた全てのインスタンスに対して、指定したメソッドチェーンを実行すると、設定した戻り値を返すことができるようになる。
ちなみに単一のメソッドを指定する場合は以下でOK。
allow_any_instance_of(クラス名).to receive(:インスタンスメソッド).and_return(戻り値)
マッチャはひとまずexpect { task.invoke }.not_to raise_error
という形でエラーが発生しないことを指定しているが、 調整後、以下のように変更する予定だ。
expect { task.invoke }.to change { LineNotification.count }.from(0).to(1)
[追記]
書き忘れていたが、Rakeタスクのコードをメソッドチェーン形式になるように微修正した。
file = s3.get_object(bucket: bucket, key: key).body.read lines = CSV.parse(file)
終わりに
今回はallow_any_instance_of
を使用して、全てのインスタンスに同じ挙動を指定しているが、あまり柔軟性がないように思う。
ただ、色々と調べて試したが、Rakeタスク内のs3インスタンスの挙動を上書きする方法がこれ以外に思いつかなかった。
もしかすると以下記事の方法を上手く使えばよりスマートに書けるのかもしれない。少なくともmodel_specではこちらの方法がベターだろう。
例えばrspecでテストする場合は、allow(Aws::S3::Client).to receive(:new).and_return(client)のように常にスタブしたclientを返すでも良いかもしれません
client = Aws::S3::Client.new(stub_responses: true) client.stub_responses( :list_objects_v2, { contents: [ {key: 'test/my-directory-object', size: 0}, {key: 'test/my-object', size: 100}, ] }, ) bucket = Aws::S3::Bucket.new(name: 'test', client: client) # スタブしたclientを使う # 例えばrspecでテストする場合は、allow(Aws::S3::Client).to receive(:new).and_return(client)のように常にスタブしたclientを返すでも良いかもしれません objects = Aws::S3::Bucket.new(name: 'test', client: client).objects(prefix: 'dummy') objects.map{|object| [object.key, object.size]} #=> [["test/my-directory-object", 0], ["test/my-object", 100]]
実装前はモックとは?スタブとは?というところからよく分かっていなかったので、とても学びになった。(記事内のスタブという言葉の使い方が間違っている恐れすらある)
こうすればいいんじゃないか的なコメントはいつでも大歓迎です。
読んでいただきありがとうございました。
〜第2回おわり〜