ScalaでTestcontainersを使ってみた

# ScalaでTestcontainersを使ってみた

## Testcontainersとは

https://www.testcontainers.org/

TestcontainersはJavaのJUnitをサポートするためのライブラリで、軽量で使い捨てのDockerコンテナを利用できるようにするものです。

TestcontainersはJavaのライブラリですが、Scala用のファサードライブラリも存在します。
ここではScalaで利用の仕方を紹介していきます。

## テストされるコード

今回以下のようなコードのテストをTestcontainersを使って実装していきたいと思います。

```scala
package model

import software.amazon.awssdk.regions.Region
import software.amazon.awssdk.services.dynamodb.DynamoDbClient
import software.amazon.awssdk.services.dynamodb.model._

import java.net.URI
import java.util.UUID
import scala.jdk.CollectionConverters.{MapHasAsJava, MapHasAsScala}

case class Message(id: String, text: String)
object Message {
  def apply(text: String): Message =
    Message(UUID.randomUUID().toString, text)
}

trait Messages {
  def add(message: Message)
  def get(id: String): Option[Message]
}

object MessagesOnDymamodb {
  private val tableName = "messages"

  def createTable(messages: MessagesOnDymamodb): Unit = {
  // dynamodbのテーブルを初期化する
  }
}

case class MessagesOnDymamodb(endpoint: URI, region: Region) extends Messages {
  // dynamodbを操作するコード
}

```

雰囲気掴めたかと思います。
MessagesOnDymamodbはaws-sdkを利用してdynamodbを利用するコードです。
このコードのUnitTestを書いて実行するにはどんな手段が考えられるでしょうか？

1. 実際にdynamodbを立てて使う。
2. テスト実行前にdockerを利用してlocalで動くdynamodbを起動させる

上記の手段について考えてみると

1. は中々剛気な手段ですね。テスト実行の頻度によってはテストが走るたびに課金が行われるでしょうからUnitTestとしては採用し辛いですね。
2. は悪くない手段ですね。READMEに`sbt test`を実行する間には`docker run ...`を実行して...と書いておくことになりそうですね。ただこの方法ではtestケースごとにdynamodbを初期化してしまいたい。など細かな制御が難しそうです。

Testcontainersを利用すると2のようにdockerを利用して手段をテストケースごとにコンテナを操作する手段が得られます。

## テストを実装してみる

まず、`Messages`クラスに対してSpecファイルを実装していきます。
以下のコードでは説明しやすいように`Messages`に対するテストの記述と、そこで利用する`Messages`の生成を分けています。

```scala
trait MessagesSpec
  extends AnyWordSpecLike
    with Matchers {

  val messages: Messages

  "messages" should {
    "追加済のメッセージを取得できる" in {
      val message = Message("hello")
      messages.add(message)
      val messageOpt = messages.get(message.id)
      messageOpt.value shouldBe message
    }
  }
}
```

ここからTestcontainersを利用した実装についてです。

まずはSpecファイルを実装していくうえで1番基本的なところから紹介してます。

まずはSpecファイルを実装します。
scalatest上のどのSpec traitを使うかは自由です。
Testcontainersを利用するSpec class は `ForAllTestContainer`または`ForEachTestContainer` をmixinして実装します。

このtraitでは`overrideしたフィールドで指定したcontainer`の起動と終了を、Specの実行に合わせてよしなにしてくれます。
`ForEachTestContainer`ではテストケースごとにコンテナを起動と停止を行います。
`ForAllTestContainer`ではテスト開始時にコンテナを起動して、全てのテストケースの終了時に停止させます。

また、`container.host`, `container.mappedPort(4566)`にあるように起動したコンテナのhostやportをメソッド経由で取得することができます。


```scala
package model

import com.dimafeng.testcontainers.{ForAllTestContainer, GenericContainer}
import org.scalatest.BeforeAndAfterAll
import software.amazon.awssdk.regions.Region

import java.net.URI

class GenericContainerMessagesSpec
    extends MessagesSpec
    with BeforeAndAfterAll
    with ForAllTestContainer {
  override val container: GenericContainer =
    GenericContainer(
      "localstack/localstack",
      env = Map(
        "DEFAULT_REGION" -> "ap-northeast-1",
        "SERVICES" -> "dynamodb"
      ),
      exposedPorts = Seq(4566)
    )

  override def beforeAll(): Unit = {
    super.beforeAll()

    MessagesOnDymamodb.createTable(messages)
  }

  lazy val messages: MessagesOnDymamodb = {
    val (localstackContainerHost, localstackContainerHostPort) =
      (
        container.host,
        container.mappedPort(4566)
      )

    MessagesOnDymamodb(
      new URI(
        s"http://${localstackContainerHost}:${localstackContainerHostPort}"
      ),
      Region.of("ap-northeast-1")
    )
  }
}
```

## container フィールドについて

container は前述したように、ここで指定したコンテナを`ForAllTestContainer`または`ForEachTestContainer`がよしなに起動と停止を管理してくれます。

上の例では`GenericContainer`のインスタンスを生成していますが、
このcontainerの指定には以下のようにいくつか手段があります。

1. docker imageを指定する
2. 作成済のDockerfileを指定する
3. Dockerfile DSLを利用してScalaのコード上でDockerfileを作成し、指定する
4. docker-composeを指定する
5. Testcontainersのmoduleとして提供されているContainerを利用する

1.は上の例にあるような`GenericContainer`を利用して、公開されているイメージ指定をするパターンです。

2.では以下のように指定できます。Scalaを利用していればご存知の`resources`以下にDockerfileを配置し読み込むことなどできます。

```scala
override val container: GenericContainer =
    GenericContainer(
      new ImageFromDockerfile().withDockerfile(
        Path.of(???) // Dockerfileのパスを指定
      )
    )
```

3.では以下のように指定できます。

```scala
  override val container: GenericContainer =
    GenericContainer(
      new ImageFromDockerfile()
        .withDockerfileFromBuilder(builder =>
          builder
            .from(???)
            .env(???)
            .run(???)
            .cmd(???)
            .build()
        )
    )
```

4.では以下のように指定できます。

```scala
  override val container: DockerComposeContainer =
    new DockerComposeContainer(
      composeFiles =
        new File(getClass.getResource("/docker-compose.yml").getFile),
      Seq(
        ExposedService("localstack", 4566)
      )
    )
```

GenericContainerから変わってDockerComposeContainerを利用することになるため、
コンテナのポートとホスト取得が少し変化します。
`ExposedService`で指定したサービス名とポート番号を利用して
`getServiceHost`, `getServicePort`を利用して取得できます。

```scala
    val (localstackContainerHost, localstackContainerHostPort) =
      (
        container.getServiceHost("localstack", 4566),
        container.getServicePort("localstack", 4566)
      )

    val messages = MessagesOnDymamodb(
      new URI(s"http://${localstackContainerHost}:${localstackContainerHostPort}"),
      Region.of("ap-northeast-1")
    )
```

5.では

```scala
  override val container: LocalStackContainer = LocalStackContainer(services =
    Seq(JavaLocalStackContainer.Service.DYNAMODB)
  )
```

のような指定になります。
GenericContainerから変わってLocalStackContainerを利用するようになったので、コンテナのポートとホスト取得が少し変化します。
LocalStackContainerではホストとポートの直接的な取得は提供されておらず、Endpointという形式でServiceを指定して接続先が取得ができます。

```scala
    MessagesOnDymamodb(
      new URI(
        container
          .endpointConfiguration(JavaLocalStackContainer.Service.DYNAMODB)
          .getServiceEndpoint
      ),
      Region.of("ap-northeast-1")
    )
```


moduleとは[このリンク先で](https://github.com/testcontainers/testcontainers-scala/tree/505a69c0a3eaa9cc566dd4cfc54b5cede8f4ac2a#modules)説明されているモノです。DockerコンテナをTestcontainersを利用したコードから扱いやすくしてくれるものです。
LocalStackContainerのmoduleならこのコンテナを使う上で指定が必要なSERVICESという環境変数をapplyメソッドで受け取れるようになっています。
また、`endpointConfiguration(SERVICE).getServiceEndpoint`もこのコンテナを扱う上で便利なメソッドの一つです。


# まとめ

簡単なtestcontainersの利用の仕方の紹介と
containerの指定方法について紹介しました。

Wait StrategiesやStartup check Strategiesなど、
この記事では紹介していない実際に利用していく上で便利、不可欠なトピックもまだまだあるので興味が湧いたら公式のドキュメントを一読することをお勧めします。
https://www.testcontainers.org/features/startup_and_waits