Spring Boot, Kotest, Testcontainers で、AWS S3 のテストを書いてみる

# はじめに

他システムへのデータ連携などで、S3 に CSV を保存する。ということはよくあるのではないでしょうか。
ただ、S3 へファイルを保存するリポジトリクラスはテストしない。サービスクラスのテストでは理想的な返り値を返すモックに差し替える。といった経験もあるのではないでしょうか？
この記事では、LocalStack という Docker イメージをしようして S3 リポジトリのテストを書いたコードをご紹介します。(簡易的なコードではありますが)

# リポジトリ

https://github.com/contour-gara/spring-s3-kotlin

Kotlin, Spring Boot で動きます。
今回の説明に必要な最低限の実装しかしていません。
テスティングフレームワークには、Kotest を選んでみました。

# プロダクションコード

まずはプロダクションコードについて、かいつまんで説明します。

## S3Client をプロファイルによって切り替える

プロダクションとテストで使用する S3Client を切り替えることで、テストを簡単に実行できるようにします。

https://github.com/contour-gara/spring-s3-kotlin/blob/main/spring-s3-kotlin-app/src/main/kotlin/org/contourgara/S3ClientConfig.kt

default プロファイルの場合は、AWS のクレデンシャルから S3Client を構築します。

```kotlin
@Bean
@Profile("default")
fun createS3Client(): S3Client {
    return S3Client.builder()
        .credentialsProvider {
            AwsBasicCredentials.create(awsConfig.accessKeyId, awsConfig.secretKey)
        }
        .region(Region.of(awsConfig.region))
        .build()
}
```

localstack プロファイルの場合、AWS クレデンシャルに加えてエンドポイントを指定することで、LocalStack への接続が可能になります。

```kotlin
@Bean
@Profile("localstack")
fun createLocalStackClient(): S3Client {
    return S3Client.builder()
        .credentialsProvider {
            AwsBasicCredentials.create(awsConfig.accessKeyId, awsConfig.secretKey)
        }
        .region(Region.of(awsConfig.region))
        .forcePathStyle(true)
        .endpointOverride(URI.create(awsConfig.s3.endpoint))
        .build()
}
```

## S3 リポジトリクラス

https://github.com/contour-gara/spring-s3-kotlin/blob/main/spring-s3-kotlin-app/src/main/kotlin/org/contourgara/S3Repository.kt

簡易的なクラスで恐縮ですが、引数のバケット名に、引数のキーで `Hello S3!!!` という文字が入力されたファイルを保存します。

```kotlin
@Component
class S3Repository(private val s3Client: S3Client) {
    fun save(bucket: String, key: String) {
        s3Client.putObject(
            PutObjectRequest.builder()
                .bucket(bucket)
                .key(key)
                .build(),
            RequestBody.fromString("Hello S3!!!")
        )
    }
}
```

本来はインターフェースを切ったうえで実装するべきですが、今回は簡単のため省略しています。

# テストコード

本題のテストコードです。
テスト自体は、GitHub でコードを見ていただければ理解していただけると思うので、この記事では LocalStack や Spring の設定に注目して解説します。

## LocalStackContainer を使用したテスト

https://github.com/contour-gara/spring-s3-kotlin/blob/main/spring-s3-kotlin-app/src/test/kotlin/org/contourgara/S3RepositoryLocalStackTest.kt

### コンテナの起動と Spring の設定

```kotlin
companion object {
    const val BUCKET_NAME: String = "test-bucket"

    val container = LocalStackContainer(DockerImageName.parse("localstack/localstack:s3-latest"))
        .apply { start() }

    @DynamicPropertySource
    @JvmStatic
    fun awsProperties(registry: DynamicPropertyRegistry) {
        registry.add("aws.access-key-id") { container.accessKey }
        registry.add("aws.secret-key") { container.secretKey }
        registry.add("aws.region") { container.region }
        registry.add("aws.s3.endpoint") { container.endpoint }
    }
}
```

上の抜粋にはありませんが、localstack プロファイルで SpringBootTest を実行しましょう。

`@DynamicPropertySource` を使用してコンテナの動的な値を Spring に設定したいので、companion object 内で実装します。
AWS のクレデンシャルとエンドポイントは、起動したコンテナから取得され、S3Client の構築に使用されます。

### コンテナの初期化

```kotlin
beforeEach {
    for (bucket in s3Client.listBuckets().buckets().map { it.name() }) {
        for (key in s3Client.listObjectsV2(ListObjectsV2Request.builder().bucket(bucket).build()).contents().map { it.key() }) {
            s3Client.deleteObject(DeleteObjectRequest.builder().bucket(bucket).key(key).build())
        }

        s3Client.deleteBucket(DeleteBucketRequest.builder().bucket(bucket).build())
    }

    s3Client.createBucket(CreateBucketRequest.builder().bucket(BUCKET_NAME).build())
}
```

一番悩んだのが、コンテナの初期化です。

すべてのバケット名を取得し、その中身のオブジェクトを順に削除する。
空になったバケットは削除する。
最後に、テスト用のバケットを作成する。

という手順で初期化しました。

余談ですが、コンテナの初期化はテスト実行前にやりましょう。
テストデータの後始末のタイミングについては、**単体テストの考え方/使い方** という本で詳しく解説されています。

https://www.amazon.co.jp/%E5%8D%98%E4%BD%93%E3%83%86%E3%82%B9%E3%83%88%E3%81%AE%E8%80%83%E3%81%88%E6%96%B9-%E4%BD%BF%E3%81%84%E6%96%B9-Vladimir-Khorikov/dp/4839981728


### LocalStackContainer を使用したテストで気になる点

プロファイルによって切り替わる S3Client と、Testcontainers で起動する LocalStack により理想的な UT を実装することができましたが、少し気になる点があります。
同じ意見の方も多いと思いますが、コンテナの初期化コードが長いです。
一発で全消去コマンドとかうてると良いんですけどね。

そこで、コンテナをテストメソッドごとに再起動するという方法を考えました。
が、これがうまくいかず、コンテナを再起動することで localhost と接続しているポートが変更されてしまいます。
つまり、LocalStack のエンドポイントが変更され、S3Client がコンテナに接続できなくなってしまいました。

## FixedHostPortGenericContainer を使用したテスト

https://github.com/contour-gara/spring-s3-kotlin/blob/main/spring-s3-kotlin-app/src/test/kotlin/org/contourgara/S3RepositoryFixedHostPortContainerTest.kt

コンテナと localhost との接続ポートを固定は、FixedHostPortGenericContainer を使用すると実現できます。

```kotlin
companion object {
    const val BUCKET_NAME: String = "test-bucket"

    val container = FixedHostPortGenericContainer("localstack/localstack:s3-latest")
        .withCopyFileToContainer(MountableFile.forClasspathResource("init.py"), "/etc/localstack/init/ready.d/init.py")
        .withExposedPorts(4566)
        .withFixedExposedPort(14566, 4566)
        .apply { start() }

    @DynamicPropertySource
    @JvmStatic
    fun awsProperties(registry: DynamicPropertyRegistry) {
        registry.add("aws.access-key-id") { "dummy" }
        registry.add("aws.secret-key") { "dummy" }
        registry.add("aws.region") { "ap-northeast-1" }
        registry.add("aws.s3.endpoint") { "http://localhost:14566" }
    }
}
```

テストコンテナのポートを固定する注意点としては、ポートが競合するとテストがコケるという点です。
CI での同時実行などがある場合は他の方法を選択しましょう。

```kotlin
beforeEach {
    container.stop()
    container.start()
}
```

コンテナの初期化がシンプルになりましたね。

# まとめ

Testcontainers と LocalStack を使用して、S3リポジトリのテストを行うことができました。
業務で使用しているわけではないので、あまり洗練されたコードではないと思います。
何か気付いた点などがありましたら、フィードバックしてくださると嬉しいです。