この投稿は 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の予約語で使えなかった!
こんなことが、普通によくありました。
実機でテストしなきゃわからない。でもこれが、テストの段階で気付けたら生産性も品質もマジ上がります。精神衛生の面でも雲泥の差が出てきます。
レコード挿入して、保存できていること、取得できること
といったようにRDBに対して普通に書いているテスト
と同じことを実現したい。
ということで、なんとかできないかと模索し次の方法で実現しました。
(今回はDynamoDBに焦点を当てさせてもらいます)
DynamoDBのユニットテストを実現する
結論、__外部サービスが使えないなら、ローカルに同じ(同等の)ものを立ててしまえば良い__んです。
DynamoDBのエミュレータを探してみると、以下の様なものがみつかります。
FakeDynamoでテスト書くといった試みをされた方もいたようですが、現在Fake Dynamoはここ数年更新がなくAPIもv1のみに対応っぽい。(とっても危険な香りがします )
ということで、今回はDynamoDB Localを使って環境構築をしました。
DynamoDB Localとは
- 特徴
- aws製公式エミュレータ
- DynamoDB API と互換性がある。(最近加わったDynamoDB Streamも扱えたりします )
- 中身はSQLite
- 詳細仕様はこちらで
あくまで開発用のものらしいですが、そこを__あえてテスト用に使う暴挙__に出ましたw
テスト環境構築
1. DynamoDB Localの導入
ダウンロードはこちらから
よく出来てて、コマンド一発で動きだしてくれます。
java -Djava.library.path=./DynamoDBLocal_lib -jar DynamoDBLocal.jar
# => そのあとは、http://localhost:8000 とかにリクエストすればおk
弊社のCI環境はCircleCI
なので、Dockerを使ってDynamoDB Local
をのせたコンテナで動かすようにしています。
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
といったファイルを作って以下のように定義しておくと良いでしょう。
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に以下のコードを追加します。
# 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点
-
Aws::DynamoDB::Client
インスタンスが常にDynamoDB Local
に見にいかせる。 - 各テストごとにテーブルのデータを初期化している。いわゆるtruncateみたいなことを実現する。
-
DynamoDBにはテーブルのデータをtruncateするAPIは存在していません。\(^o^)/
なので、__各テストケースごとに新DBと新テーブルをつくるという荒業__を行っています。
ただこの作成オペレーションのコスト高く、めちゃくちゃ重いです。なので以下の工夫をしています。-
dynamodb: true
といったタグが与えられたテストケースのみ、初期化処理をする。 -
消す⇛作る
は2倍のオペレーションコストがかかるので、消すことはしない。新たに作るのみ。- DynamoDB Localは
アクセスキーIDとリージョンの組み合わせ
でDBを決定します。これを利用して、テストケースごとにリージョンを変更しています。
- DynamoDB Localは
-
また、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のユニットテストは動くようになっているはずです。
が、、、、
消す⇛作るは2倍のオペレーションコストがかかるので、消すことはしない。新たに作るのみ。
としているため、テーブルを削除していないため残ってしまっているので要注意です。
- DynamoDB Localにはメモリ起動というオプションがあり、停止時にデータが全削除されます。
弊社ではこれを利用して、CircleCIでテストが終了したら、Dockerが落ちてデータを消しています。運がよいwww
最後に
非常に長い投稿になってしまい恐縮ですが、みなさまのお助けになれれば幸いです。
ちなみにこの機構を入れてから、弊社ではDynamoDBでのエラーはほぼなくなりました!!
外部サービスを使っていても、常に安心してリリースができるということは非常にでかいです。
今回はDynamoDBをメインにしましたが、世の中使えるものはまだまだあります。
SQS : ElasticMQ
S3 : Fake S3
これらを使って環境構築すれば、テストが書けますよ!
(SQSでのユニットテストのやり方は弊社エンジニア@halhideが別途こちらで記事書いていますので参考に。)