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! を使うことができなさそうです。