脈絡はありません

Railsエンジニアです。ハードルは低いほうが飛びやすい。

RSpecでS3オブジェクトの挙動をスタブする

前回記事の続き。前回は1回のRakeタスクで発行されるクエリ数の削減に取り組んだ。

inoway46.hatenablog.com

やりたいこと

リファクタによってコードを壊さないために、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を返すでも良いかもしれません

qiita.com

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回おわり〜