fakes3 と fakefs で Amazon S3 連携のテストを書いてみた
Amazon S3 からファイルを取得して、ローカルでごにょごにょするようなスクリプトに対してテストを書きたくて、なんとかそれっぽいものが書けたので備忘録として残しておきます。
fakes3 とは
Amazon S3 のように振る舞う WEBrick サーバを提供する gem です。
リポジトリはこちら => https://github.com/jubos/fake-s3
今回は fakes3 0.1.5.2 を利用しました。
gem install fakes3 でインストールしたら、次のようなコマンドを実行することでサーバを起動できます。
% fakes3 --root=/tmp/fakes3 --port=12345
Ruby の aws-sdk からアクセスするには次のように s3_endopint, s3_port などを指定します。
s3 = AWS::S3.new(
:access_key_id => 'YOUR_ACCESS_KEY_ID',
:secret_access_key => 'YOUR_SECRET_ACCESS_KEY',
:s3_endpoint => 'localhost',
:s3_force_path_style => true,
:s3_port => 12345,
:use_ssl => false
)
s3_force_path_style を true にしないと、例えば bucket name が ‘test’ だと ‘test.localhost’ に対してリクエストが飛ぶことになります。bucket name の名前だけ /etc/hosts にホスト名を追加するのも辛いので、true にすると良いと思います。
あるいは、次のように lvh.me を s3_endpoint に指定しても良いかもしれません。いつまでこのドメインが使えるかわかりませんが・・・
s3 = AWS::S3.new(
:access_key_id => 'YOUR_ACCESS_KEY_ID',
:secret_access_key => 'YOUR_SECRET_ACCESS_KEY',
:s3_endpoint => 'lvh.me',
:s3_port => 12345,
:use_ssl => false
)
なお、サーバは次のように直接起動することもできます。
FakeS3::Server.new('0.0.0.0', 12345, FakeS3::FileStore.new('/tmp/fakes3'), 'localhost').serve
fakefs とは
ファイルシステムを操作するメソッドを置き換えることで、擬似的なファイルシステムを提供する gem です。fakefs を使うことで、ファイルシステムに変更を加えることなくテストを行うことができるようになります。
リポジトリはこちら => https://github.com/defunkt/fakefs
今回は fakefs 0.5.3 を利用しました。
rspec で利用する際は基本的に FakeFS::SpecHelpers を include することになるかと思います。FakeFS::SpecHelpers を include することで before examples で FakeFS.activate!、after examples で FakeFS.deactivate! が実行されるようになります。
require 'spec_helper'
require 'fakefs/spec_helpers'
describe SomeClass do
describe '#some_method' do
include FakeFS::SpecHelpers
it '...' do
...
end
end
end
fakefs の効果は require で gem を読み込む際にも影響するので、Kernerl.#require を次のように require の際には無効化するようにしないと gem の読み込みに失敗することがあります。
require 'fakefs/safe'
module Kernel
alias_method :require_with_fakefs, :require
def require_without_fakefs(path)
FakeFS.without { require_with_fakefs(path) }
end
alias_method :require, :require_without_fakefs
end
FakeFS.activate!
色々面倒なので、極力 fakefs は使わず tmpdir とかでテストできるような実装にした方が良いかもしれません・・・
fakes3 と fakefs を使った rspec の例
特定の bucket のオブジェクトを全てダウンロードするシンプルな module を作成して、それの spec を書くことにします。
% bundle gem s3_downloader -t
create s3_downloader/Gemfile
create s3_downloader/Rakefile
create s3_downloader/LICENSE.txt
create s3_downloader/README.md
create s3_downloader/.gitignore
create s3_downloader/s3_downloader.gemspec
create s3_downloader/lib/s3_downloader.rb
create s3_downloader/lib/s3_downloader/version.rb
create s3_downloader/.rspec
create s3_downloader/spec/spec_helper.rb
create s3_downloader/spec/s3_downloader_spec.rb
create s3_downloader/.travis.yml
Initializating git repo in /Users/takeshi-arabiki/work/ruby/s3_downloader
例えば、S3Downloader を次のように定義します。テストしたいのは S3Downloader.download_all です。
require "s3_downloader/version"
require 'aws-sdk'
module S3Downloader
BUCKET_NAME = 'test'
DOWNLOAD_DIR = '/path/to/Downloads'
class << self
def download_all
s3.buckets[BUCKET_NAME].objects.each do |object|
File.open(File.join(DOWNLOAD_DIR, object.key), 'wb') do |file|
object.read do |chunk|
file.write(chunk)
end
end
end
end
private
def s3
@s3 ||= AWS::S3.new(
:access_key_id => ENV['AWS_ACCESS_KEY_ID'],
:secret_access_key => ENV['AWS_SECRET_ACCESS_KEY'],
)
end
end
end
rspec で ‘fakes3’ と ‘fakefs’ を使うため、gemspec の development_dependency を追加しておきます。
# coding: utf-8
lib = File.expand_path('../lib', __FILE__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require 's3_downloader/version'
Gem::Specification.new do |spec|
spec.name = "s3_downloader"
spec.version = S3Downloader::VERSION
spec.authors = ["a_bicky"]
spec.email = ["a_bicky@example.com"]
spec.description = %q{TODO: Write a gem description}
spec.summary = %q{TODO: Write a gem summary}
spec.homepage = ""
spec.license = "MIT"
spec.files = `git ls-files`.split($/)
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
spec.require_paths = ["lib"]
spec.add_dependency "aws-sdk", "~> 1.51.0"
spec.add_development_dependency "bundler", "~> 1.3"
spec.add_development_dependency "rake"
spec.add_development_dependency "rspec"
spec.add_development_dependency "fakefs"
spec.add_development_dependency "fakes3"
end
あとは fakes3 と fakefs を使うように spec を書くだけです。
# spec/s3_downloader_spec.rb
require 'spec_helper'
require 'fakes3/server'
require 'fakefs/spec_helpers'
describe S3Downloader do
PORT = 12345
before(:all) do
# bundle exec rake spec だと何故かこれがなくても動く(rake spec だと必要)
# module Kernel
# alias_method :require_with_fakefs, :require
#
# def require_without_fakefs(path)
# FakeFS.without { require_with_fakefs(path) }
# end
# alias_method :require, :require_without_fakefs
# end
# fakes3 のサーバを起動。テストが終了した時にシグナルを送ることでサーバを落とす。
pid = Process.fork do
Dir.mktmpdir do |dir|
def $stderr.write(*args); end # Suppress outputs
FakeS3::Server.new('0.0.0.0', PORT, FakeS3::FileStore.new(dir), 'localhost').serve
end
end
at_exit do
Process.kill(:TERM, pid)
end
end
describe '.download_all' do
include FakeFS::SpecHelpers
# S3Downloader.s3 を stub することで fakes3 に対してリクエストを送る
let(:s3) do
AWS::S3.new(
:access_key_id => 'access_key',
:secret_access_key => 'secret_access',
:s3_endpoint => 'localhost',
:s3_port => PORT,
:s3_force_path_style => true,
:use_ssl => false,
)
end
let(:objects) do
[{ :key => 'foo', :value => 'bar' }, { :key => 'hoge', :value => 'fuga' }]
end
before do
FileUtils.mkdir_p(S3Downloader::DOWNLOAD_DIR)
# ダウンロードするためのファイルを作成
objects.each do |obj|
s3.buckets[S3Downloader::BUCKET_NAME].objects.create(obj[:key], obj[:value])
end
allow(S3Downloader).to receive(:s3) { s3 }
end
it 'downloads files' do
objects.each do |obj|
expect(File.exists?(File.join(S3Downloader::DOWNLOAD_DIR, obj[:key]))).to be false
end
S3Downloader.download_all
objects.each do |obj|
expect(File.read(File.join(S3Downloader::DOWNLOAD_DIR, obj[:key]))).to eq obj[:value]
end
end
end
end
テストを実行してみます。
% bundle exec rake spec
/path/to/.rbenv/versions/2.0.0-p353/bin/ruby -I/path/to/.rbenv/versions/2.0.0-p353/lib/ruby/gems/2.0.0/gems/rspec-core-3.1.4/lib:/path/to/.rbenv/versions/2.0.0-p353/lib/ruby/gems/2.0.0/gems/rspec-support-3.1.0/lib /path/to/.rbenv/versions/2.0.0-p353/lib/ruby/gems/2.0.0/gems/rspec-core-3.1.4/exe/rspec --pattern spec/\*\*\{,/\*/\*\*\}/\*_spec.rb
S3Downloader
.download_all
downloads files
Finished in 0.33311 seconds (files took 0.66379 seconds to load)
1 example, 0 failures
いい感じですね!!
AWS.stub! について
「Stubbing AWS Responses - AWS Developer Blog - Ruby」で言及されているように、aws-sdk には AWS.stub! というメソッドが用意されており、これを実行することで AWS にリクエストを投げなくなります。
これを使えば良さそうに見えますが、必ず空のレスポンスが返るようになるだけなので、次のように酷い挙動になります。
[1] pry(main)> require 'aws-sdk'
=> true
[2] pry(main)> AWS.stub!
=> nil
[3] pry(main)> s3 = AWS::S3.new(:access_key_id => 'key', :secret_access_key => 'secret')
=> <AWS::S3>
[4] pry(main)> obj = s3.buckets['test'].objects.create('key', 'value')
=> <AWS::S3::S3Object:test/key>
[5] pry(main)> obj.key
=> "key"
[6] pry(main)> obj.read # 'value' が返ってほしい
=> nil
[7] pry(main)> obj.content_length # 'value'.length が返ってほしい
=> nil
上記の obj.read, obj.content_length に対して見かけ上所望の結果を得るには、stub_for を使ってレスポンスを定義してやる必要があります。
[8] pry(main)> response = s3.client.stub_for(:get_object)
=> {}
[9] pry(main)> response[:data] = 'value'
=> "value"
[10] pry(main)> obj.read
=> "value"
[11] pry(main)> response = s3.client.stub_for(:head_object)
=> {}
[12] pry(main)> response[:content_length] = 'value'.length
=> 5
[13] pry(main)> obj.content_length
=> 5
超面倒くさい!!
しかも、stub_for は次のような定義になっていて、各メソッドに対して 1 つのレスポンスしか定義することができません。オブジェクトごとにレスポンスを定義することは不可能な上、@stubs[method_name] を無理やり削除しない限り上書きすることもできません。
def stub_for method_name
@stubs ||= {}
@stubs[method_name] ||= new_stub_for(method_name)
end
というわけで、今回のようなテストでは AWS.stub! を使うことができなさそうです。