Testcontainersでspring Data JPAをいい感じにテストする

始めまして(Qiita初投稿)、GxPでマネージャーをやっている高田です。
この記事は[グロースエクスパートナーズ Advent Calendar 2020](https://qiita.com/advent-calendar/2020/gxp)の16日目です。

# Testcontainersでspring Data JPAをいい感じにテストする

テーマは学びということで、あんまり関係ないんですがTestcontainersを試してみていい感じにできたのでまとめます。

## 動機
インクリメンタルにリリースしていくスタイルでは開発速度維持のためにもコードをクリーンに保っておく姿勢が欠かせません。積極果敢にリファクタリングしたいのですが、デグレードを防いで安全に行うには自動テストが必須です。データベースなど外部リソースに依存した部分も自動テストしたいと考えていました。
昨今では、Dockerなどコンテナを使うことで、気軽に、手間をかけずに環境構築ができるようになってきました。それでも、環境の準備とテストは別々のものとして扱われることが普通でした。
これが環境構築がテストフレームワークと統合されるようになればどうでしょうか？ボタン一発・コマンド一発で本番と同じ構成の環境が立ち上がりテストが実行され即時にフィードバックを得られるという素晴らしい世界が見えます。ああぁ・・

***要は、docker-compose書いてテスト開始前にコンテナ立ち上げてよしテスト、というのがめんどくさいのです。***


## 前提知識

### 使ってるもの
今回のサンプルコードで使ってるフレームワークについて紹介です。知ってる人は読み飛ばし可です。

#### spring-boot, Spring Data JPA
私の周りではJavaのフレームワークにspringおよび[Spring-boot](https://spring.io/projects/spring-boot)を採用することが多いです。
今回のサンプルでは[spring Data JPA](https://spring.io/projects/spring-data-jpa)を使いました。[query-methods](https://docs.spring.io/spring-data/jpa/docs/2.4.2/reference/html/#repositories.query-methods)と呼ばれる、ルールに従ったメソッドを定義することでクエリを生成できる仕組みが楽です。実体はhibernateなので理解して使う必要はありますが、テーブル数が50以下で小規模な場合には問題なく使える印象です。※個人の感想です。

#### Testcontainers

[Testcontainers公式サイト](https://www.testcontainers.org/) 概要の抄訳
> Testcontainersは Javaのライブラリです。 
一般的なデータベース, Selenium Webブラウザ,  またはDocker containerで実行できるものなんでも、これらJUnitテストで利用できる軽量な使い捨てのコンテナインスタンスを提供します。

Junitだけでなく、spockでも利用できます。[spockでの利用方法も公式サイトに載っています](https://www.testcontainers.org/quickstart/spock_quickstart/)ので簡単にためすことができます。
TestcontainersはDockerを使うため、テストを実行する環境にはDockerのインストールが必須です。

#### spock
[spock](http://spockframework.org/spock/docs/1.3/all_in_one.html)はgroovyでテストが書けるテストフレームワークです。JunitでJavaでテストコードを書くより簡潔に記述できるのが魅力です。Mock機能なども一つのフレームワーク内で統一されているのも便利です。

### お題について
AuthorクラスとBlogPostクラスの２つのエンティティクラスからなるJPAのサンプルです。ブログ投稿サービスをイメージしています。実装の理解は不要なので説明しないですが、テスト対象のサービス実装コードを載せておきます。エンティティ、リポジトリは詳細をご覧ください。
[コード全文はgithubのリポジトリから見ることができます。](https://github.com/YasuhiroTakada/testcontainers-spring-demo)

#### サービス実装
このサービスクラスが今回のテスト対象となります。依存する2つのリポジトリクラスは外部リソース（データベース）へ依存しています。

```java:BlogPostService.java
@Service
@RequiredArgsConstructor
public class BlogPostService {

  private final AuthorRepository authorRepository;

  private final BlogPostRepository blogPostRepository;

  @Transactional
  public void create(final String authorName, final String title, final String content) {

    // authorは作成済みであること
    Author author = authorRepository.findByName(authorName).get();

    BlogPost blogPost = blogPostRepository.findByAuthorAndTitle(author, title)
        .orElseGet(() -> BlogPost.of(author, title, content));
    blogPost.content = content;
    blogPostRepository.save(blogPost);
  }

  @Transactional(readOnly = true)
  public List<BlogPost> get(String authorName) {
    return blogPostRepository.findByAuthor_Name(authorName);
  }

}
```


<details>
<Summary>エンティティ、リポジトリの実装はここから</summary>
<div>

#### エンティティクラス

##### Author

```java:Author.java
package jp.co.gxp.testcontainerspringdemo.infrastracture.entity;

import java.io.Serializable;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.OneToMany;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import lombok.ToString;

@Entity(name = "author")
@Table(uniqueConstraints = {@UniqueConstraint(columnNames = {"name"})})
@ToString
public class Author implements Serializable {

  @Id
  @GeneratedValue
  public UUID id;

  @Column
  public String name;

  @OneToMany
  public List<BlogPost> blogPostList;

  public static Author of(String name) {
    Author insatnce = new Author();
    insatnce.name = name;
    return insatnce;
  }

  @Override
  public boolean equals(Object o) {
    if (this == o) {
      return true;
    }
    if (o == null || getClass() != o.getClass()) {
      return false;
    }
    Author author = (Author) o;
    return Objects.equals(id, author.id);
  }

  @Override
  public int hashCode() {
    return Objects.hash(id);
  }
}
```

##### BlogPost
ブログ投稿を表すBlogPostエンティティです。特徴としては、content列にはjson型を宣言しています。json型はH2でもpostgresでも利用できます。

```java:BlogPost.java
package jp.co.gxp.testcontainerspringdemo.infrastracture.entity;

import java.io.Serializable;
import java.util.Objects;
import java.util.UUID;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.ManyToOne;
import lombok.ToString;

@Entity(name = "blog_post")
@ToString
public class BlogPost implements Serializable {

  @Id
  @GeneratedValue
  public UUID id;

  @Column
  public String title;

  @Column(columnDefinition = "json")
  public String content;

  @Column
  public String category;

  @Column
  public Long likeCount;

  @ManyToOne
  public Author author;

  public static BlogPost of(Author author, String title, String content) {
    BlogPost instance = new BlogPost();
    instance.author = author;
    instance.title = title;
    instance.content = content;
    return instance;
  }

  @Override
  public boolean equals(Object o) {
    if (this == o) {
      return true;
    }
    if (o == null || getClass() != o.getClass()) {
      return false;
    }
    BlogPost blogPost = (BlogPost) o;
    return id.equals(blogPost.id);
  }

  @Override
  public int hashCode() {
    return Objects.hash(id);
  }
}
```

#### リポジトリクラス
##### AuthorRepository
`findByName`がquery-methodsによるメソッド宣言です。findByNameメソッドでは`SELECT * FROM author WHERE name = ?` を実行します。

```java:AuthorRepository.java
package jp.co.gxp.testcontainerspringdemo.infrastracture.repository;

import java.util.Optional;
import java.util.UUID;
import jp.co.gxp.testcontainerspringdemo.infrastracture.entity.Author;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface AuthorRepository extends JpaRepository<Author, UUID> {

  Optional<Author> findByName(String name);
}
```
##### BlogPostRepository
`findByAuthor_Name`メソッドもquery-methodsの宣言ですが、BlogPostのフィールドauthorが持つname属性の完全一致を条件としています。オブジェクト参照をたどって条件を指定することも可能です。

```java:BlogPostRepository.java
package jp.co.gxp.testcontainerspringdemo.infrastracture.repository;

import java.util.List;
import java.util.Optional;
import java.util.UUID;
import jp.co.gxp.testcontainerspringdemo.infrastracture.entity.Author;
import jp.co.gxp.testcontainerspringdemo.infrastracture.entity.BlogPost;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

@Repository
public interface BlogPostRepository extends JpaRepository<BlogPost, UUID> {

  Optional<BlogPost> findByAuthorAndTitle(Author author, String title);

  List<BlogPost> findByAuthor_Name(String authorName);
}
```


</div>
</details>

## テストやってみる

### 準備 
#### build.gradle
テストに必要な依存関係を追加します。spring-bootの依存関係以外はTestcontainersのものと、spockに関するものです。

```groovy:build.gradle
plugins {
    id 'org.springframework.boot' version '2.4.0'
    id 'io.spring.dependency-management' version '1.0.10.RELEASE'
    id 'groovy'
}

group = 'jp.co.gxp'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    testImplementation 'org.springframework.boot:spring-boot-starter-test' //・・・（１）

    // https://mvnrepository.com/artifact/org.projectlombok/lombok
    compileOnly 'org.projectlombok:lombok:1.18.16'
    annotationProcessor 'org.projectlombok:lombok:1.18.16'

    // https://mvnrepository.com/artifact/org.postgresql/postgresql
    implementation 'org.postgresql:postgresql:42.2.18'                    //・・・（２）

    // https://mvnrepository.com/artifact/org.spockframework/spock-core
    testImplementation 'org.spockframework:spock-core:2.0-M4-groovy-3.0'  //・・・（３）
    testImplementation 'org.spockframework:spock-spring:2.0-M4-groovy-3.0'

    // https://mvnrepository.com/artifact/org.codehaus.groovy/groovy-all
    testImplementation 'org.codehaus.groovy:groovy:3.0.7'

    // https://mvnrepository.com/artifact/org.testcontainers/testcontainers
    testImplementation "org.testcontainers:spock:1.15.0"
    testImplementation "org.testcontainers:postgresql:1.15.0"            //・・・（４）

    testImplementation 'com.h2database:h2:1.4.200'                       //・・・（５）
}

```

* （１）spring-bootのテストユーティリティ
* （２）今回のサンプルでは本番にはpostgreSQLを想定しているようです
* （３）実験的にspockの新バージョンである2.0を試しています。groovyは3系となるようです。現在の安定版は1.3です。
* （４）Testcontainersには、[いくつかのDockerイメージに対応したライブラリ](https://mvnrepository.com/artifact/org.testcontainers)が存在します。コンテナの起動確認や設定をサポートするような機能が実装されています。
* （５）テストデータベースです。記事中には出てこないですが、テストデータベースでのテストクラスも実装しています。

### Testcontainersを使ったテスト
今回のメインのテストクラスです。非常に簡潔に書くことができました。
これだけの記述で、postgresのコンテナをpullして起動し、完了を待ち合わせてspringのテスト用コンテキストを起動して、、、といったことが実現できています。

```groovy:TestcontainersBlogPostServiceSpec.groovy
@Testcontainers //・・・（１）
@DataJpaTest(excludeAutoConfiguration = [TestDatabaseAutoConfiguration]) // ・・・（２）
@TestPropertySource(properties = ["spring.jpa.hibernate.ddl-auto=update"]) //・・・（３）
class TestcontainersBlogPostServiceSpec extends Specification {

    @Autowired
    BlogPostService sut

    @Shared
    //・・・（４）
    PostgreSQLContainer container= new PostgreSQLContainer("postgres:11-alpine")
            .withDatabaseName("main")
            .withUsername("user")
            .withPassword("password") 

    void setupSpec() {
        container.start() //・・・（５）
        System.setProperty('spring.datasource.url', container.jdbcUrl)
        System.setProperty('spring.datasource.username', container.username)
        System.setProperty('spring.datasource.password', container.password)
    }

    @Sql(scripts = "classpath:/sql/testdata-postgres.sql")
    def 'ブログ投稿が取得できる'() {
        when:
        sut.create("Donald", "Java Programing", "{\"content\":\"\"}")
        List<BlogPost> actual = sut.get("Donald")

        then:
        actual.size() == 1
        actual[0].title == "Java Programing"
    }

    @Configuration
    @Import([JpaConfig, BlogPostService]) //・・・（６）
    static class LocalTestContext {

    }
}

```

#### （１）Testcontainersアノテーション
このアノテーションを付けておくとテスト時にコンテナをいい感じに管理してくれる便利アノテーションです。
テストクラス初期化自にコンテナを開始するなどを[spockframeworkのExctentionという仕組み](http://spockframework.org/spock/docs/1.3/extensions.html "Extensions")で実現しています。[コードをみれば](https://github.com/testcontainers/testcontainers-java/blob/master/modules/spock/src/main/groovy/org/testcontainers/spock/TestcontainersMethodInterceptor.groovy "github:testcontainers/testcontainers-java")やっていることは理解できると思います。

#### （２）excludeAutoConfiguration 
`@DataJpaTest`アノテーションはJPAのテスト設定をしてくれるspring-testが提供するアノテーションです。
``excludeAutoConfiguration = [TestDatabaseAutoConfiguration]``はテスト用データベースの起動や接続の初期化をするAutoConfigurationを除外する設定です。この除外指定が無いと、インメモリのテストDBの実装(H2, HSQLDB)をクラスパス上から検索し、存在しなければapplicationContextの起動に失敗します。今回は本番と同じDBがローカルのDockerにあるのでテスト用DBはいらないわけですね。

```
Caused by: java.lang.IllegalStateException: Failed to replace DataSource with an embedded database for tests. 
If you want an embedded database please put a supported one on the classpath or tune the replace attribute of @AutoConfigureTestDatabase.
```
#### （３）TestPropertySource
テスト用プロパティを宣言します。
今回は ``spring.jpa.hibernate.ddl-auto=update``でJPAの実装であるhibernateの機能でデータベースのスキーマを初期化していますが、これは単にCREATE文を用意するのが面倒だったからだけで、通常はファイルに記述されたDDLやflywayなどのDBマイグレーションツールで初期化することと思います。

#### （４）PostgreSQLContainer
postgresのコンテナを起動する設定です。今回のメインのはずなんですが、さらっとしてます。使いたいコンテナのタグを指定します。今回は`postgres:11-alpine`としました。そのほかにrootユーザのユーザ名、パスワードとデータベース名を指定しています。用意されたビルダーメソッドでそれぞれの設定を指定していますが、PostgreSQLContainerでは起動時にコンテナ環境変数として値を渡しています。使いたいコンテナでどんな設定ができるかを理解しておくとよいですね。大抵はdocker hubのページで知ることができます。[例：postgres](https://hub.docker.com/_/postgres)


#### （５）コンテナ開始
`container.start()`とすることでコンテナが立ち上がります。
`PostgreSQLContainer`にはログを監視してアクセス可能になることを待ってくれる設定が実装済みのため、コンテナが立ち上がるのを待つコードを自前で用意する必要はありません。また、用意されたアクセサメソッドを使うことで、ホスト・ポートを含んだJDBC URL,、ユーザID、パスワードが取得できます。これをシステムプロパティに設定することでテスト用データベースにコンテナを指定しています。

Testcontainersでは、コンテナ起動時に毎回空いているポートを探してコンテナとホストが通信するためのポートにします。このため、コンテナにアクセスするためにはコンテナ起動後にポートを参照する必要があるのです。コンテナのネットワークに関しては以下を参照してください。
https://www.testcontainers.org/features/networking/

#### （６）JpaConfig
データアクセスに関する設定です。プロダクトとテストで同じコンフィグを使用しています。そうすることで設定内容が正しいことの確認もできます。サービスクラスが依存するリポジトリクラスはこのコンフィグによってDI可能となっています。

### テスト結果
gradleタスクでテスト実行します。

```
./gradlew test
```

実行結果はspockのレポートから見ることができます。プロジェクトの`build/reports/tests/test/index.html`のような場所に出力されています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/925652/7550baf2-4343-0f2f-c41a-bffb9ce15c16.png)

Standard outputをみると、コンテナの準備、spring-bootテストの開始、JPAテストが実行されていることがわかります。クジラがかわいいですね。

```
～略～
10:17:03.818 [Test worker] DEBUG 🐳 [postgres:11-alpine] - Starting container: postgres:11-alpine
10:17:03.822 [Test worker] DEBUG 🐳 [postgres:11-alpine] - Trying to start container: postgres:11-alpine (attempt 1/1)
10:17:03.822 [Test worker] DEBUG 🐳 [postgres:11-alpine] - Starting container: postgres:11-alpine
10:17:03.822 [Test worker] INFO 🐳 [postgres:11-alpine] - Creating container for image: postgres:11-alpine
1
～略～
  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.4.0)

2020-12-15 10:17:08.169  INFO 49704 --- [    Test worker] .g.t.b.TestcontainersBlogPostServiceSpec : Starting TestcontainersBlogPostServiceSpec using Java 11.0.4 *******
～略～
TestcontainersBlogPostServiceSpec in 4.224 seconds (JVM running for 16.684)
Hibernate: select author0_.id as id1_0_, author0_.name as name2_0_ from author author0_ where author0_.name=?
Hibernate: select blogpost0_.id as id1_2_, blogpost0_.author_id as author_i6_2_, blogpost0_.category as category2_2_, blogpost0_.content as content3_2_, blogpost0_.like_count as like_cou4_2_, blogpost0_.title as title5_2_ from blog_post blogpost0_ where blogpost0_.author_id=? and blogpost0_.title=?
Hibernate: insert into blog_post (author_id, category, content, like_count, title, id) values (?, ?, ?, ?, ?, ?)
Hibernate: select blogpost0_.id as id1_2_, blogpost0_.author_id as author_i6_2_, blogpost0_.category as category2_2_, blogpost0_.content as content3_2_, blogpost0_.like_count as like_cou4_2_, blogpost0_.title as title5_2_ from blog_post blogpost0_ left outer join author author1_ on blogpost0_.author_id=author1_.id where author1_.name=?
～略～
```

## まとめ
Testcontainersを使うことで、データベースについては本番に近い構成での自動テストができるようになりました。素早く堅牢なまま、開発速度を上げていくことが可能となると感じています。
[docker hubからのpull実行に制限が掛かるというニュース](https://www.docker.com/pricing/resource-consumption-updates)もあり、微妙なタイミングではありますが、皆さんの参考になれば幸いです。

### H2すごい
実は、組み込みDBのテストはもっと制約が多い前提で記事を書き始めました。
テストデータベースにはこんな制限があるよね、使いづらいよね　⇒　そこでTestcontainersですよ、みたいなことを想像していたのですが、実際やってみたら思ったよりできることが多くてびっくりしました。ウインドウ関数は実行できる、json型は使える、で、「これであればDBはわざわざコンテナで実行させる必要ないんじゃないかな・・・」と、記事がお蔵入りになるくらいパワフルでした。H2すごい。※身近な例でDBを選んだだけなので、elasticsearchとか他にもネタはいくつかあった。

### spockいい
Junitでテスト書く機会ももちろんあるのですが、spockのMockが統合されている点やGroovyによる記述のしやすさに慣れてしまうと、Junitモッサリだな。。と思ってしまいます。Mockを使ったテストの実装もあります。中身はmockで定義した値を検証しているというどうしようもないものですが、簡単に書ける点だけに注目してください。

## おまけ
testcontainersとspockを活用させるための工夫を紹介します。

### Testcontainersによるテストを分けて実行したい
コンテナの起動は通常のJavaのユニットテストを実行するよりも時間がかかります。となると、実行するタイミングを制御したくなります。毎度のビルド時には不要だが、プルリクエスト対象ではTestcontainersでのテストも実施したくなるのが人情です。

spockには、[include/excludeを指定できる拡張機能](http://spockframework.org/spock/docs/1.3/all_in_one.html#_include_and_exclude
)があります。
include/excludeの条件は特定のアノテーションが付与されているテストクラス、特定のクラスを拡張したテストクラスを対象とする、の２種類が指定可能です。

[Junit5のTagアノテーション](https://junit.org/junit5/docs/current/user-guide/#writing-tests-tagging-and-filtering)を使って[制御する](https://junit.org/junit5/docs/current/user-guide/#running-tests-build
)方がよっぽど簡単なんですが、そんなことが理由でspock使わないなんてモッタイナイ、という使命感で対応方法をねじ込んでいます。


この仕組みを使って、Testcontainersアノテーションを通常のテスト実行からは除外します。さらに、Gradleのタスク定義と組み合わせることで、テスト実行タイミングを分けることができます。

#### テストサイズ
例に出てくるSmall、MediumというのはGoogleのブログで有名になった[テストサイズに関する考え方](https://testing.googleblog.com/2010/12/test-sizes.html)を参考にしています。
Mediumサイズも自動でテストできたほうがいいじゃないか。


#### includeする
MediumサイズのテストにはTestcontainersアノテーションを指定してincludeします。
こうすることで、Testcontainersアノテーションが付与されたSpecのみをテスト対象にすることができます。

```groovy:MediumTestConfig.groovy
import org.testcontainers.spock.Testcontainers

runner {
    // This is Groovy script and we
    // add arbitrary code.
    println "Medium Test Runner Config."

    // Include only test classes or test
    // methods with the @Testcontainers annotation
    include {
        annotation Testcontainers
    }
}
```

#### excludeする
通常のユニットテスト、SmallTestの実行時にはTestcontainersを除外する設定をします。

```groovy:SmallTestConfig.groovy
import org.testcontainers.spock.Testcontainers
runner {
    // This is Groovy script and we
    // add arbitrary code.
    println "Unit Test Runner Config."

    // Include only test classes or test
    // methods with the @Testcontainers annotation
    exclude {
        annotation Testcontainers
    }

}
```

#### build.gradle
testタスクではSmallTestConfigを指定することでTestcontainersによるテストを除外します。
また、新たにmediumTestというタスクを定義し、こちらではTestcontainersによるテストのみを実施するようにしました。


```groovy:build.gradle
test {
    useJUnitPlatform()
    systemProperty 'spock.configuration', 'SmallTestConfig.groovy'
}

task mediumTest(type: Test) {
    useJUnitPlatform()
    systemProperty 'spock.configuration', 'MediumTestConfig.groovy'
}
```


### 実行結果
バッチリTestcontainersアノテーションによってSmall,Mediumサイズのテスト対象選別ができているようです。

#### Testcontainersをincludeしたテスト（MediumTest）
TestcontainersBlogPostServiceSpecがテスト対象となっていることがtest-reportから見ることができます。reportもタスク名によって`build/reports/tests/mediumTest/index.html`に出力されています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/925652/e81e5f36-4033-d548-280a-a4919eb4670a.png)


#### Testcontainersをexcludeしたテスト（SmallTest）
TestcontainersBlogPostServiceSpecによって除外されていることがわかります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/925652/347c360e-4552-ad9b-7306-e4acf68bec71.png)

----

最後まで御覧いただきありがとうございました。
テスト間でコンテナインスタンスを共有する仕組みなども用意していたのですが、長くなったのでカットしました。
またどこかでお会いしましょう。