はじめに
aws-sdk-s3を使用して、sorted-setに依存したFakeS3の仕組みを再現しました。
FakeS3は2024年2月にメンテナンスが終了し、クローズとなっています。主にS3へのアップロードや、S3の中身を参照する自動テストでこのライブラリはよく使用されており、下記のようなテストケースを記述できます。
その名の通りS3の挙動を模倣してくれるので、アップロードした時のPostリクエストに対するレスポンスや閲覧時のGetリクエストに対するレスポンスを完全に再現してくれます。
it 'CSVファイルが作成されてS3にアップロードされ、ログにファイルパスとダウンロード期限が定められ、statusがdoneになる' do
subject
csv_export_log.reload
aggregate_failures do
expect(get_s3_csv_data_url(bucket_name, csv_file_path)).to be_present
expect(csv_export_log.file_path).to be_present
expect(csv_export_log.expired_at).to be_present
expect(csv_export_log.status).to eq 'done'
expect { 〇〇〇〇〇〇CsvExportFinishNoticeSgJob.perform_later(csv_export_log) }.to have_enqueued_job.with(csv_export_log).exactly(:once)
end
end
課題
このFakeS3はSortedSetに依存しており、残念ながらSortedSet自体のメンテナナンスはしばらく止まってしまっています。そのためこのライブラリ内部で使用されているSetのバージョンはまだ1.1.0です。
FakeS3 < SortedSet < Set 1.1.0 って感じです。
Ruby3.4.1では、Set1.1.1が標準で組み込まれています。そのため、Ruby3.4.1にアップグレードしようとするとRuby内部に元々組み込まれているSetの1.1.1とFakeS3がSortedSetへの依存で副次的に依存しているSet1.1.0が競合を起こし、アプリケーションが動かなくなります。
そのためRuby3.4.1アップグレード時には必ずSortedSetとSortedSetに依存したライブラリを剥がす必要があります。この問題を解決するために今回の作業を行いました。
aws-sdk-s3を使用して実装
AWSのS3を使用する時は、aws-sdk-s3を利用することが一般的だと思います。
基本的にS3に関する実装がこのaws-sdk-s3のライブラリに依存することから、Rspec内のS3挙動の模倣をaws-sdk-s3を使って実装できないか考えました。これならS3を使用する処理とテストがどちらもaws-sdk-s3に依存するので、テストに使っているライブラリだけ個別にメンテナンスする手間がなくなり、aws-sdk-s3のバージョンを上げていけばS3に関する処理とテストを同時に面倒見ることができます。
テストケースの中では処理の流れに幾つかのパターンがありました。
- Controller内でアップロード→ファイル参照
- Concern内でS3からのレスポンスを確認する
- S3へのアップロードのみ
- ファイル参照のみ
なのでaws-sdk-s3を使用したS3の模倣に関しても、テストケース内で既にアップロードされたファイルがあればそのファイルを返す。ない場合は、拡張子に合わせて適切にレスポンスを分けるように実装しました。
require 'aws-sdk-s3'
RSpec.configure do |config|
config.before(:suite) do
Aws.config[:s3] = { stub_responses: true }
end
fake_s3_data = {}
config.before do
s3_client = Aws::S3::Client.new
# S3ObjectConcernをincludeしているクラスでupload_s3_csv_dataを呼び出すと、fake_s3_dataにデータを保存する
allow_any_instance_of(S3ObjectConcern).to receive(:upload_s3_csv_data) do |_, data, _, path| # rubocop:disable RSpec/AnyInstance
fake_s3_data[path] = StringIO.new(data)
end
s3_client.stub_responses(:get_object, ->(context) {
key = context.params[:key]
if fake_s3_data.key?(key)
{ body: fake_s3_data[key], content_length: fake_s3_data[key].size }
else
case key
when /\.pdf$/
fake_pdf_path = Rails.root.join('spec/fixtures/s3_stub/test.pdf')
fake_pdf_data = File.exist?(fake_pdf_path) ? StringIO.new(File.binread(fake_pdf_path)) : raise('Missing test PDF file')
{ body: fake_pdf_data, content_length: fake_pdf_data.size }
when /\.csv$/
{ body: StringIO.new('1\n'), content_length: 2 }
when /\.jpg$/
fake_jpg_path = Rails.root.join('spec/fixtures/s3_stub/test.jpg')
fake_jpg_data = File.exist?(fake_jpg_path) ? StringIO.new(File.binread(fake_jpg_path)) : StringIO.new("\xFF\xD8\xFF\xE0JPEG_DATA")
{ body: fake_jpg_data, content_length: fake_jpg_data.size }
else
raise Aws::S3::Errors::NoSuchKey, 'The specified key does not exist.'
end
end
})
s3_client.stub_responses(:list_objects_v2, ->(_context) {
{ contents: fake_s3_data.keys.map { |key| { key: } } }
})
s3_client.stub_responses(:delete_object, ->(context) {
fake_s3_data.delete(context.params[:key])
})
s3_client.stub_responses(:put_object, {})
s3_client.stub_responses(:head_bucket, {})
s3_client.stub_responses(:head_object, {})
allow(Aws::S3::Client).to receive(:new).and_return(s3_client)
end
end
# rubocop:disable RSpec/AnyInstance
でrubocopからテストケース間を跨いで参照できるインスタンスを定義するなと怒られるのを回避しています。今回はアップロードとアップロードしたファイルの閲覧がテストケースの単位であるit文を跨いで書かかれていたため。テストケース間跨いだ参照は許してもらいます。
成果
今回の実装では、既存のS3のテストケース修正に1度も手をつけることなくFakeS3とSoretedSetをアプリケーションから剥がすことに成功しました。FakeS3の代替手段としてかなり有効なのではと思います。独自でライブラリ作ってしまうのも良いかもですが、メインのアプリケーション開発の優先度を下げてまでやることではない気がします。aws-sdk-s3が今後メンテナンスされなくなる世界線は今々見えないので、しばらくは安心です。