DynamoDB Localとは
DynamoDB Localは、AWSのNoSQLデータベースサービスであるAmazon DynamoDBをPCなどのローカル端末でエミュレートできる仕組みです。
Medium size のテストとは
自動テストを大きさによって分類した際の中程度のテストのことです。
ここではt-wadaさんやGoogleのAndroid開発チームに倣って、単一のマシンの中で複数のプロセスを利用するものとします。
例えばJava言語であればJUnitTestから同一マシンのコンテナで起動したデータベースにアクセスするものなどを指します。
参考
動機
昨年半ばから、初めて DynamoDB を使ったアプリケーションを開発してきました。
その際 DynamoDB の保存処理と読み出し処理のテストを実装したくなったことがきっかけです。
DynamoDBを使った開発を進める中で、機能追加や修正によって保存処理と読み出し処理に齟齬が発生してしまい、読み出し処理のランタイムで不具合が発生してしまうということが多くありました。
これはRDBでも起きうることですが、DynamoDBはNoSQLデータベースであるため、RDBのテーブル定義のように格納するデータの型などを厳密に事前定義する必要がなく、より頻繁にミスが発生しました。
この問題の発生を防ぐために、高頻度で実行ができ、データ管理の手間が少なく、かつある程度の忠実性の高いテストを用意したい、ということでMedium sizeのテストを整備することにしました。
DynamoDB Localのセットアップと利用
DynamoDB Localを実行する方法は公式ドキュメントに記載の通り、いくつかあります。
今回はJUnit Testでのみ利用するため、Dockerイメージとして実行する方法を選択しました。
またデータを保持する必要もないため、inMemoryで実行することにしました。
参考
Dockerを実行できる環境が整っていれば以下のようなコマンドを実行するだけで起動することができます。
docker run -d --name dynamodb-local -p 8000:8000 amazon/dynamodb-local:latest -jar DynamoDBLocal.jar -sharedDb -inMemory
もちろんCI上でも実行することができ、あとは指定したポートに対して接続するDynamoDBClientをインジェクションできる実装にしておけば、テストが実行できます。
Java言語での記述例
DynamoDbClient dynamoDbClientForTest = DynamoDbClient.builder()
.endpointOverride(URI.create("http://localhost:8000"))
.region(Region.of("ap-northeast-1"))
.credentialsProvider(DefaultCredentialsProvider.builder().build())
.build();
RegionやCredentialProviderは実際には利用されませんが、ダミーの値を指定する必要があります。
テストの冪等性の担保のための実装例
データベースに接続するMedium sizeのテストを実装する際の重要なポイントとして、テストデータの準備や実行後のクリーンアップ戦略があります。
RDBの場合であれば、トランザクション管理をしてテスト実行後にFixtureデータも含めてロールバックをする方法などが取れますが、DynamoDBの場合はそのような方法を取ることができません。
どうしようかと少し悩みましたが、InMemoryのDynamoDB LocalではテーブルのCreateやDropを高速に行うことができたため、テスト実行ごとにテーブルを再作成する方法を取ることにしました。
実装例
// プロダクションコードのRepositoryを継承しテスト用のCreateTable,DropTableを実装
public class HogeRepositoryLocal extends HogeRepositoryImpl {
public void createTable() {
// id 属性をPartition keyに持ち、codeという属性でも検索できるテーブルの場合
CreateTableRequest createTableRequest = CreateTableRequest.builder()
.tableName(TABLE_NAME)
.keySchema(k -> k
.attributeName("id")
.keyType(KeyType.HASH)
)
.attributeDefinitions(
a -> a.attributeName("id").attributeType(ScalarAttributeType.S),
a -> a.attributeName("code").attributeType(ScalarAttributeType.S)
)
.globalSecondaryIndexes(g -> g
.indexName("code-index")
.keySchema(k -> k
.attributeName("code")
.keyType(KeyType.HASH)
)
.projection(p -> p
.projectionType(ProjectionType.ALL)
)
.provisionedThroughput(t -> t
.readCapacityUnits(5L)
.writeCapacityUnits(5L)
)
)
.provisionedThroughput(t -> t
.readCapacityUnits(5L)
.writeCapacityUnits(5L)
).build();
dynamoDbClient.createTable(createTableRequest);
waitForTableActive();
}
public void dropTable() {
DeleteTableRequest deleteRequest = DeleteTableRequest.builder()
.tableName(TABLE_NAME)
.build();
dynamoDbClient.deleteTable(deleteRequest);
waitForTableDeletion();
}
/**
* テーブルがアクティブになるまで待機
*/
private void waitForTableActive() {
DescribeTableRequest describeRequest = DescribeTableRequest.builder()
.tableName(TABLE_NAME)
.build();
try (DynamoDbWaiter waiter = dynamoDbClient.waiter()) {
waiter.waitUntilTableExists(describeRequest);
}
}
/**
* テーブルの削除完了を待機
*/
private void waitForTableDeletion() {
DescribeTableRequest describeRequest = DescribeTableRequest.builder()
.tableName(TABLE_NAME)
.build();
try (DynamoDbWaiter waiter = dynamoDbClient.waiter()) {
waiter.waitUntilTableNotExists(describeRequest);
}
}
}
class HogeRepositoryImplIntegrationTest {
@Autowired
private HogeRepositoryLocal hogeRepositoryLocal;
@BeforeEach
void setup() {
hogeRepositoryLocal.createTable();
}
@Test
void test() {
...
}
@AfterEach
void teardown() {
hogeRepositoryLocal.dropTable();
}
都度テーブルをCreate,Dropすることで、常にクリーンなDBでテストを実行することができます。
テストを並列実行する場合は同一テーブルを扱うテスト間で干渉しないように、実装を工夫する必要があります。
おわりに
ひとまずこちらでやりたいテストを実装することができました。
運用してみて必要があれば設計を見直していきたいと思います。
おわり。