40
34

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

RSpecでDynamoDBなどの外部サービス機能のユニットテストを実現する

Last updated at Posted at 2015-12-13

この投稿は Sansan Advent Calendar 2015 の 13 日目の記事です。

みなさんテスト書いてますか?

この時代テスト書いていない人なんて、いないですよね。
いるわけないと思います。

ただ、質問を変えて、

外部サービスに依存するコードのテスト書いてますか?

となると、結構テスト書いてない人多いんじゃないかなと思います。
あるいは書いていても、中途半端なものになっちゃうんじゃないかなと思うんです。

そこに課題感があったので、この記事書くことにしました。

背景

弊社ではAWSサービス群を結構使ってまして、最近はコアな機能の部分に

  • DynamoDB
  • SQS

などをガッツリと採用しています。

正直今までも上記サービスは使っていたはいたんですが一部だったということもあり、テストに力を入れてませんでした。

たとえば、DynamoDB周りのテストとかは

expect_any_instance_of(Aws::DynamoDB::Client).to receive(:query).with(expected_query_parameters)

みたいにこの引数で呼び出されることぐらいの感じでしか書いてませんでした。
というか、それぐらいしかできないんですよね。外部サービスだから。(ホントはAPIを実際に叩いた結果のあれこれをテストしたいんです。)

課題

ただ、メインどころで使うとなると、このテストじゃ不安なんですよね。

実際に

  • リクエストパラメータが不足していた!
  • シンボルをキーにしてはダメだった!
  • 返り値が間違っていた!
  • テーブルの属性名が実はDynamoDBの予約語で使えなかった!

こんなことが、普通によくありました。:sweat_smile:
実機でテストしなきゃわからない。でもこれが、テストの段階で気付けたら生産性も品質もマジ上がります。精神衛生の面でも雲泥の差が出てきます。

レコード挿入して、保存できていること、取得できること
といったようにRDBに対して普通に書いているテストと同じことを実現したい。

ということで、なんとかできないかと模索し次の方法で実現しました。
(今回はDynamoDBに焦点を当てさせてもらいます)

DynamoDBのユニットテストを実現する

結論、__外部サービスが使えないなら、ローカルに同じ(同等の)ものを立ててしまえば良い__んです。
DynamoDBのエミュレータを探してみると、以下の様なものがみつかります。

FakeDynamoでテスト書くといった試みをされた方もいたようですが、現在Fake Dynamoはここ数年更新がなくAPIもv1のみに対応っぽい。(とっても危険な香りがします :bomb: )

ということで、今回はDynamoDB Localを使って環境構築をしました。

DynamoDB Localとは

  • 特徴
    • aws製公式エミュレータ
    • DynamoDB API と互換性がある。(最近加わったDynamoDB Streamも扱えたりします :clap: )
    • 中身はSQLite
  • 詳細仕様はこちら

あくまで開発用のものらしいですが、そこを__あえてテスト用に使う暴挙__に出ましたw

テスト環境構築

1. DynamoDB Localの導入

ダウンロードはこちらから

よく出来てて、コマンド一発で動きだしてくれます。

java -Djava.library.path=./DynamoDBLocal_lib -jar DynamoDBLocal.jar
# => そのあとは、http://localhost:8000 とかにリクエストすればおk

弊社のCI環境はCircleCIなので、Dockerを使ってDynamoDB Localをのせたコンテナで動かすようにしています。

Dockerfileはこんな感じ
FROM java:8

# Create working space
RUN mkdir /var/dynamodb_local
WORKDIR /var/dynamodb_local

# Default port for DynamoDB Local
EXPOSE 8000

# Get the package from Amazon
RUN wget -qO - http://dynamodb-local.s3-website-us-west-2.amazonaws.com/dynamodb_local_latest | tar xzf -

# Default command for image
ENTRYPOINT ["/usr/bin/java", "-Djava.library.path=.", "-jar", "DynamoDBLocal.jar"]
CMD ["-dbPath", "/var/dynamodb_local"]

Jenkinsとかその他のCI環境を使っている方はそれぞれの環境に合わせていれてみてください。

2. DynamoDBのテーブルスキーマを定義

DynamoDB Localがローカルで動くことが確認できたら、
次は、データベース初期化のためのテーブルスキーマを定義しておきます。

db/dynamodb_schema.ymlといったファイルを作って以下のように定義しておくと良いでしょう。

DynamoDBのテーブル定義
tables:
  your_table_name1:
    :table_name: your_table_name1
    :key_schema:
      - :attribute_name: your_prymary_hash_name
        :key_type: HASH
      - :attribute_name: your_prymary_range_name
        :key_type: RANGE
    :provisioned_throughput:
      :read_capacity_units: 1
      :write_capacity_units: 1
    :attribute_definitions:
      - :attribute_name: your_prymary_hash_name
        :attribute_type: N
      - :attribute_name: your_prymary_range_name
        :attribute_type: S
      - :attribute_name: lsi_hash_name
        :attribute_type: N
    :local_secondary_indexes:
      - :index_name: lsi_hash_name-index
        :key_schema:
          - :attribute_name: lsi_hash_name
            :key_type: HASH
        :projection:
          :projection_type: ALL
  your_table_name2:
    ...

なかなか意味わからないかもですが、
Aws::DynamoDB::Client#describe_table(table_name: テーブル名)
↑のような定義が取得できるので参考になるかと思います。

3. rspecで動くようにする

__ここから少し黒魔術感が出てきます__が、ついてきてくださいw

spec_helper.rbに以下のコードを追加します。

spec_helper.rb
# DynamoDB Localの設定
ddb_schema = YAML.load(ERB.new(File.new('db/dynamodb_schema.yml').read).result)['tables']
ddb_client = Aws::DynamoDB::Client.new(
  endpoint:           'http://127.0.0.1:8000',  # DynamoDB Localのendpointを指定
  region:             'your_region',            # 適当でOK
  access_key_id:      'your_access_key_id',     # 適当でOK
  secret_access_key:  'your_secret_access_key', # 適当でOK
  stub_responses:     false,
)

config.prepend_before(:each, dynamodb: true) do
  # DynamoDBの初期化(都度新規テーブルを作成)
  # テストケースごとに新DB&テーブルを作成
  ddb_client.config.sigv4_region = SecureRandom.uuid
  ddb_schema.values.each do|table_schema|
    ddb_client.create_table table_schema
  end

  # Aws::DynamoDB::Client.newは常に設定済み(DynamoDB Localに向いている)インスタンスを返す
  allow(::Aws::DynamoDB::Client).to receive(:new).and_return(ddb_client)
end

(設定値は設定ファイルとかから読むようにしたほうがスマートです。下のコードはわかりやすさのために割愛してます。)

やっていることは以下2点

  1. Aws::DynamoDB::Clientインスタンスが常にDynamoDB Localに見にいかせる。
  2. 各テストごとにテーブルのデータを初期化している。いわゆるtruncateみたいなことを実現する。
  • DynamoDBにはテーブルのデータをtruncateするAPIは存在していません。\(^o^)/
    なので、__各テストケースごとに新DBと新テーブルをつくるという荒業__を行っています。
    ただこの作成オペレーションのコスト高く、めちゃくちゃ重いです。なので以下の工夫をしています。
    • dynamodb: trueといったタグが与えられたテストケースのみ、初期化処理をする。
    • 消す⇛作るは2倍のオペレーションコストがかかるので、消すことはしない。新たに作るのみ。
      • DynamoDB LocalはアクセスキーIDとリージョンの組み合わせでDBを決定します。これを利用して、テストケースごとにリージョンを変更しています。

また、WebMockなどを使ってる場合は、ローカルのDynamoDB Localのエンドポイントへのアクセスを許可しておいてあげましょう。

config.before(:suite) do
  # snip

  WebMock.disable_net_connect!(
    allow_localhost: true,
    allow: [
      'http://127.0.0.1:8000', # DynamoDB Localへのアクセスを許可
    ],
  )
end

あとはテストを書いて実行するだけ!

超サンプルコード
describe 'DynamoDBのテスト', dynamodb: true do
 describe '#query' do
   let!(:client) { Aws::DynamoDB::Client.new }

   before do
     # itemを作っておく
     client.put_item(
       table_name: self.class.table_name,
       item:  {
         your_prymary_hash_name:  'hash_value',
         your_prymary_range_name: "range_value",
       }
     )
   end

   subject do
     # 作られたitemを取得する
     client.query(
       table_name: 'your_table_name1',
       key_condition_expression: 'your_prymary_hash_name = :hash_value',
       expression_attribute_values: { ':hash_value' => 'hash_value' },
     )
   end

   it '期待する値が取得できること' do
     expect(subject.items).to eq xxx # お好きなテストを書いてください
   end
 end
end

注意

ここまでで、おそらくDynamoDBのユニットテストは動くようになっているはずです。:tada:

が、、、、

消す⇛作るは2倍のオペレーションコストがかかるので、消すことはしない。新たに作るのみ。

としているため、テーブルを削除していないため残ってしまっているので要注意です。

  • DynamoDB Localにはメモリ起動というオプションがあり、停止時にデータが全削除されます。

弊社ではこれを利用して、CircleCIでテストが終了したら、Dockerが落ちてデータを消しています。運がよいwww

最後に

非常に長い投稿になってしまい恐縮ですが、みなさまのお助けになれれば幸いです。

ちなみにこの機構を入れてから、弊社ではDynamoDBでのエラーはほぼなくなりました!!:tada: :tada: :tada:

外部サービスを使っていても、常に安心してリリースができるということは非常にでかいです。

今回はDynamoDBをメインにしましたが、世の中使えるものはまだまだあります。

SQS : ElasticMQ
S3 : Fake S3
これらを使って環境構築すれば、テストが書けますよ!
(SQSでのユニットテストのやり方は弊社エンジニア@halhideが別途こちらで記事書いていますので参考に。)

外部サービスに依存しているコードこそ、テスト書いていきたいですね!!!

40
34
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
40
34

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?