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

[この投稿は Sansan Advent Calendar 2015 の 13 日目の記事です。](http://qiita.com/advent-calendar/2015/sansan)

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

ただ、質問を変えて、

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

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

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

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

* DynamoDB
* SQS

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

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

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

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

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

実際に

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

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

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

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

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

* [DynamoDB Local](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/Tools.DynamoDBLocal.html)
* [Fake Dynamo](https://github.com/ananthakumaran/fake_dynamo)

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

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

# DynamoDB Localとは
* 特徴
    * aws製公式エミュレータ
    * DynamoDB API と互換性がある。(最近加わったDynamoDB Streamも扱えたりします :clap: )
    * 中身はSQLite
* 詳細仕様は[こちら](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/Tools.DynamoDBLocal.html)で

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

# テスト環境構築
## 1. DynamoDB Localの導入
ダウンロードは[こちら](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/Tools.DynamoDBLocal.html)から

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

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

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

```bash: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`といったファイルを作って以下のように定義しておくと良いでしょう。

```rb: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に以下のコードを追加します。

```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のエンドポイントへのアクセスを許可しておいてあげましょう。

```rb
config.before(:suite) do
  # snip

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

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

```rb:超サンプルコード
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が落ちてデータを消しています。運がよいｗｗｗ

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

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

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

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

SQS : [ElasticMQ](https://github.com/adamw/elasticmq)
S3 : [Fake S3](https://github.com/jubos/fake-s3)
これらを使って環境構築すれば、テストが書けますよ！
(SQSでのユニットテストのやり方は弊社エンジニア@halhideが別途[こちら](http://qiita.com/halhide/items/59665e7c5ee1d2b20603)で記事書いていますので参考に。)

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