完全コンテナベースのローカル開発環境を構築する（Docker+Spring Boot+MySQL+Flyway+Spock）

[KINTO Technologies Advent Calendar 2021 - Qiita](https://qiita.com/advent-calendar/2021/kinto-technologies)の14日目の記事です。

# この記事を書いた理由

最初は「バックエンド開発者にSpockデータ駆動テストの素晴らしさを知ってもらいたい」というのが目的でした。

が、その記事を書くための環境を構築するうちに、前段であるアプリ開発環境構築の過程も需要があるのでは？というかむしろそちらのほうがナレッジとしては多くの人に必要とされているのでは？という思いが強くなってきました。

弊社システムのプラットフォームはすべてクラウドベースのコンテナサービスが基本であるため、ローカルでの開発であっても、MacにMySQLをインストールしましょうとかではなく、最初からコンテナベースで構築したほうがDevOps的にも移行が容易になります。

以上の理由で、最終的にタイトルのようなテーマの記事とすることにしました。

# 読んでほしい人

* うっかりクラウドベースの会社に転職しちゃったオンプレ系エンジニア
* 開発はコンテナベースでできてるけどテストまでコンテナベースで動かしたい
* テストコードを書けと言われるけどJUnit書きづらいし読みづらいしなんかイヤ

# 環境

MacBook Pro
Docker Desktop for Mac 4.9.1（有償化されてしまいましたね...）
Java 17
Gradle 7.3.1
Spring Boot 2.6.1
Groovy 3.0
Spock 2.0
Testcontainers 1.16.2

# サンプルソース

https://github.com/chig1215/rest-api-test-by-spock

## 動作の概要

### Rest API

```
http://localhost:8080/v1/cars/{price}
```

KINTOの人気ランキング入り車種（2021年12月現在）のうち、指定した価格、またはそれよりお安い月額で乗れるクルマのデータが取得できます:car:

[【KINTO】クルマのサブスク、トヨタから](https://kinto-jp.com/)

MySQLコンテナのテーブルには以下のクルマデータを入れています。

![名称未設定2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2082655/11579e9c-cbea-5046-d39a-d3a844ef9a4b.png)

### テスト内容

* HTTPステータス200でレスポンスが返るか
* 価格パターンによるデータ駆動テスト

# 構築手順

## Rest APIの開発

Spring Bootのクラス構成はこんな感じです。

```
.
├── java
│   └── com
│       └── example
│           └── restapitestbyspock
│               ├── RestApiTestBySpockApplication.java
│               ├── application
│               │   └── CarController.java
│               ├── domain
│               │   ├── model
│               │   │   └── Car.java
│               │   ├── repository
│               │   │   └── CarRepository.java
│               │   └── service
│               │       └── CarService.java
│               └── infrastructure
│                   ├── entity
│                   │   └── CarEntity.java
│                   └── repository
│                       ├── CarJpaRepository.java
│                       └── CarRepositoryImpl.java
└── resources
    ├── application.yml
    ├── static
    └── templates
```

Spring Data JPAのクエリ生成機能を活用して、「findByPriceLessThanEqualOrderByPriceAsc」つまり「指定した価格、またはそれよりお安い月額で乗れるクルマのデータをお安い順に」取得しています:car:

その他特にトリッキーなこともしていないのでサクッとコードだけ載せていきます。

### domain層

```Car.java
@Data
@Builder
public class Car {
    @Id
    private Integer id;
    private String name;
    private Integer price;
}
```

```CarRepository.java
public interface CarRepository {

    List<Car> findByPriceLessThanEqualOrderByPriceAsc(Integer price);

}
```

```CarService.java
@Service
@RequiredArgsConstructor
public class CarService {

    @NonNull
    private final CarRepository carRepository;

    public List<Car> findByPriceLessThanEqualOrderByPriceAsc(Integer price) {
        return this.carRepository.findByPriceLessThanEqualOrderByPriceAsc(price);
    }

}

```

### infrastructure層

```CarEntity.java
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "cars")
public class CarEntity {
    @Id
    private Integer id;
    private String name;
    private Integer price;

    public Car toDomainCar() {
        return Car.builder()
                .id(this.id)
                .name(this.name)
                .price(this.price)
                .build();
    }
}
```

```CarJpaRepository.java
public interface CarJpaRepository extends JpaRepository<CarEntity, Integer> {
    List<CarEntity> findByPriceLessThanEqualOrderByPriceAsc(Integer price);
}
```

```CarRepositoryImpl.java
@Repository
@RequiredArgsConstructor
public class CarRepositoryImpl implements CarRepository {

    @NonNull
    private final CarJpaRepository carJpaRepository;

    @Override
    public List<Car> findByPriceLessThanEqualOrderByPriceAsc(Integer price) {
        return this.carJpaRepository.findByPriceLessThanEqualOrderByPriceAsc(price)
                .stream().map(CarEntity::toDomainCar)
                .collect(Collectors.toList());
    }
}
```

### application層

```CarController.java
@RestController
@RequiredArgsConstructor
@RequestMapping(path = "/v1/cars")
public class CarController {

    @NonNull
    private final CarService carService;

    @GetMapping("/{price}")
    @ResponseStatus(HttpStatus.OK)
    public List<Car> findByPriceLessThanEqualOrderByPriceAsc(@PathVariable("price") Integer price) {
        return this.carService.findByPriceLessThanEqualOrderByPriceAsc(price);
    }
}
```

## Docker Desktopのインストール

それではいよいよコンテナ化の準備をしていきましょう。

[Docker Desktop for Mac and Windows](https://www.docker.com/products/docker-desktop)

上記からお使いの端末にあわせてダウンロード、インストールしてください。

## Dockerコンテナの設定

設定ファイル構成はこんな感じ。

```
.
├── docker
│   ├── flyway
│   │   ├── conf
│   │   │   └── flyway.conf
│   │   └── sql
│   │       ├── V1.0.0__schema.sql
│   │       └── V1.1.0__data.sql
│   ├── log
│   │   └── mysql
│   │       └── mysqld.log
│   ├── mysql
│   │   ├── Dockerfile
│   │   └── conf.d
│   │       └── my.cnf
│   └── spring
│       └── Dockerfile
├── docker-compose.yml
```
Docker Composeベースで設定ファイルの実装を進めていきます。
「Spring BootからMySQLに接続、かつFlywayでマイグレーション管理する」という構成にしたいため、必要なコンテナイメージは下記の3つになります。

* MySQL
* Flyway
* Spring Boot

```docker-compose.yml
version: "3.7"
services:
  dbserver:
    container_name: mysql-db
    build:
      context: ./docker/mysql
      dockerfile: Dockerfile
    image: chig1215/mysql:latest
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_USER: chig1215
      MYSQL_PASSWORD: chig1215
      MYSQL_DATABASE: kinto
    restart: always
    command: --default-authentication-plugin=mysql_native_password
    ports:
      - "3306:3306"
    volumes:
      - ./docker/mysql/conf.d:/etc/mysql/conf.d
      - ./docker/log/mysql:/var/log/mysql
      - mysql_db:/var/lib/mysql
  flyway-repair:
    container_name: flyway-repair
    image: flyway/flyway
    command: repair
    volumes:
      - ./docker/flyway/conf:/flyway/conf
    depends_on:
      - dbserver
  flyway-migration:
    container_name: flyway-migration
    image: flyway/flyway
    command: -url=jdbc:mysql://dbserver -schemas=kinto -user=chig1215 -password=chig1215 -connectRetries=60 migrate
    volumes:
      - ./docker/flyway/conf:/flyway/conf
      - ./docker/flyway/sql:/flyway/sql
    depends_on:
      - flyway-repair
  spring:
    container_name: spring-app
    build: ./docker/spring
    depends_on:
      - flyway-migration
    ports:
      - "8080:8080"
    volumes:
      - .:/app
    environment:
      spring.datasource.driverClassName: "com.mysql.cj.jdbc.Driver"
      spring.datasource.url: "jdbc:mysql://dbserver/kinto"
      spring.datasource.username: "chig1215"
      spring.datasource.password: "chig1215"
    working_dir: /app
    command: sh -c "java -jar ./build/libs/rest-api-test-by-spock-0.0.1-SNAPSHOT.jar"
volumes:
  mysql_db:
    driver: local
```

各コンテナ毎の設定をコメントで解説していきます。

### MySQL

```docker-compose.yml
  dbserver:
    container_name: mysql-db  # コンテナ名（Docker Desktop上はこの名前で表示される）
    build:
      context: ./docker/mysql # Dockerfileを含むディレクトリへのパス
      dockerfile: Dockerfile  # Dockerfile名
    image: chig1215/mysql:latest # イメージ名
    environment:
      MYSQL_ROOT_PASSWORD: root # 環境変数（rootユーザのパスワード）
      MYSQL_USER: chig1215      # 環境変数（ユーザ）
      MYSQL_PASSWORD: chig1215  # 環境変数（パスワード）
      MYSQL_DATABASE: kinto     # 環境変数（データベース名）
    restart: always # 再起動ポリシー
    command: --default-authentication-plugin=mysql_native_password  # mysql_native_password を使用したネイティブ認証
    ports:
      - "3306:3306" # ポートマッピング
    volumes:
      - ./docker/mysql/conf.d:/etc/mysql/conf.d # mysql.confディレクトリのマッピング
      - ./docker/log/mysql:/var/log/mysql       # mysqld.logディレクトリのマッピング
      - mysql_db:/var/lib/mysql                 # データ永続化ボリュームのマッピング
```

```docker-compose.yml
# DBの永続化先
volumes:
  mysql_db:
    driver: local
```

### Flyway

```docker-compose.yml
  flyway-repair:
    container_name: flyway-repair # コンテナ名（Docker Desktop上はこの名前で表示される）
    image: flyway/flyway          # イメージ名
    command: repair               # 前回のSQLエラー解消（サンプルコンテンツのため。本番稼働アプリでは不要）
    volumes:
      - ./docker/flyway/conf:/flyway/conf # flyway.confディレクトリのマッピング
    depends_on:
      - dbserver  # MySQLコンテナが起動した後に起動させる
  flyway-migration:
    container_name: flyway-migration  # コンテナ名（Docker Desktop上はこの名前で表示される）
    image: flyway/flyway              # イメージ名
    # MySQLの接続先を指定してマイグレーションを実行する（host:port部分はコンテナ名を指定する）
    command: -url=jdbc:mysql://dbserver -schemas=kinto -user=chig1215 -password=chig1215 -connectRetries=60 migrate
    volumes:
      - ./docker/flyway/conf:/flyway/conf # flyway.confディレクトリのマッピング
      - ./docker/flyway/sql:/flyway/sql   # マイグレーションSQLファイルディレクトリのマッピング
    depends_on:
      - flyway-repair # repairが完了した後に起動させる
```

### Spring Boot

```docker-compose.yml
  spring:
    container_name: spring-app  # コンテナ名（Docker Desktop上はこの名前で表示される）
    build: ./docker/spring      # Dockerfileを含むディレクトリへのパス
    depends_on:
      - flyway-migration        # マイグレーションが完了した後に起動させる
    ports:
      - "8080:8080"             # ポートマッピング
    volumes:
      - .:/app                  # ボリュームマッピング
    environment:
      # MySQLの接続設定
      spring.datasource.driverClassName: "com.mysql.cj.jdbc.Driver"
      spring.datasource.url: "jdbc:mysql://dbserver/kinto"  # host:port部分はコンテナ名を指定する
      spring.datasource.username: "chig1215"
      spring.datasource.password: "chig1215"
    working_dir: /app # 作業ディレクトリ
    # jarから起動
    command: sh -c "java -jar ./build/libs/rest-api-test-by-spock-0.0.1-SNAPSHOT.jar"
```

各コンテナのDockerfileやconfの内容はサンプルソースをご参照ください。

## 動作確認

### アプリケーションのビルド

```
 $ ./gradlew clean build
```

buildディレクトリにjarができていることを確認しましょう。

```
.
├── build
│   ├── libs
│   │   ├── rest-api-test-by-spock-0.0.1-SNAPSHOT-plain.jar
│   │   └── rest-api-test-by-spock-0.0.1-SNAPSHOT.jar
```

### コンテナの起動

```
$ docker-compose up --build
```

起動に成功すれば、Docker DesktopのContainers/Appsに以下のように表示されるはずです。

![名称未設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2082655/dd3211d4-3b4c-1478-f1ec-12b500d08ab5.png)

Rest APIをGETして、このように結果が返却されれば正常に起動しています。

```
http://localhost:8080/v1/cars/20000
```

```
[{
	"id": 3,
	"name": "ルーミー",
	"price": 14630
}, {
	"id": 7,
	"name": "ヤリス2WD",
	"price": 14960
}, {
	"id": 2,
	"name": "RAIZE",
	"price": 16170
}, {
	"id": 10,
	"name": "プリウス",
	"price": 18700
}, {
	"id": 4,
	"name": "アクア",
	"price": 19580
}]
```

### コンテナの停止

```
$ docker-compose down
```
## ちょっと休憩

コーヒーブレイク:coffee:

## テスト開発

さて、いよいよ当初のメインテーマになるはずだったSpockの登場です。

今回はテストもコンテナベースで実施したいので、Testcontainersも併用していきます。

クラス構成はこんな感じです。

```
.
└── src
    └── test
        ├── groovy
        │   └── com
        │       └── example
        │           └── restapitestbyspock
        │               └── application
        │                   └── CarControllerTest.groovy
        ├── java
        │   └── com
        │       └── example
        │           └── restapitestbyspock
        │               └── helper
        │                   └── test
        │                       └── MySQLContainerContextInitializer.java
        └── resources
```

### テストビルドに必要なライブラリの追加

build.gradleに下記の通り追記します。

```build.gradle
plugins {
    id 'org.springframework.boot' version '2.6.1'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
    id 'groovy' // 追記（SpockはGroovyで記述するため）
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    maven { url 'https://repo.spring.io/release' }
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-rest'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-jdbc'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'javax.persistence:javax.persistence-api'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'mysql:mysql-connector-java'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    annotationProcessor 'org.projectlombok:lombok'
    // ここから追記
    testImplementation 'org.springframework.boot:spring-boot-test:2.6.2'
    testImplementation 'org.springframework.boot:spring-boot-starter-test:2.6.2'
    testImplementation 'org.spockframework:spock-core:2.0-groovy-3.0'
    testImplementation 'org.spockframework:spock-spring:2.0-groovy-3.0'
    testImplementation 'org.testcontainers:testcontainers:1.16.2'
    testImplementation 'org.testcontainers:mysql:1.16.2'
    testImplementation 'com.jayway.jsonpath:json-path:2.6.0'
}

test {
    useJUnitPlatform()
}

bootBuildImage {
    builder = 'paketobuildpacks/builder:tiny'
    environment = ['BP_NATIVE_IMAGE': 'true']
}

// 追記（FlywayのマイグレーションSQLをテストリソースとして使いたいため）
sourceSets.test {
    resources.srcDirs = ["src/test/resources", "docker"]
}
```

### テスト用データベースコンテナ設定用ヘルパークラス

```MySQLContainerContextInitializer.java
@SuppressWarnings({"rawtypes", "unchecked"})
public class MySQLContainerContextInitializer
        implements ApplicationContextInitializer<ConfigurableApplicationContext> {

    private static final Logger LOGGER = LoggerFactory.getLogger(MySQLContainerContextInitializer.class);

    private static final MySQLContainer MYSQL =
            new MySQLContainer("mysql:latest") {
                {
                    withDatabaseName("kinto");
                    withUsername("chig1215");
                    withPassword("chig1215");
                    withExposedPorts(3306);
                    withLogConsumer(new Slf4jLogConsumer(LOGGER));
                    withClasspathResourceMapping(
                            "mysql/conf.d",
                            "/etc/mysql/conf.d", BindMode.READ_ONLY);
                    withClasspathResourceMapping(
                            "flyway/sql",
                            "/docker-entrypoint-initdb.d", BindMode.READ_ONLY);
                }
            };

    static {
        MYSQL.start();
    }

    @Override
    public void initialize(ConfigurableApplicationContext applicationContext) {
        TestPropertyValues.of("spring.datasource.url=" + MYSQL.getJdbcUrl())
                .applyTo(applicationContext.getEnvironment());
    }
}
```

Springの起動時に動作させたいので、ApplicationContextInitializerを実装するクラスとして作成します。

接続情報はdocker-compose.ymlに設定した内容と同じ。
mysql.confディレクトリとマイグレーションSQLディレクトリは、階層的には

```
.
├── docker
│   ├── flyway
│   │   ├── conf
│   │   │   └── flyway.conf
│   │   └── sql
│   │       ├── V1.0.0__schema.sql
│   │       └── V1.1.0__data.sql
│   ├── mysql
│   │   ├── Dockerfile
│   │   └── conf.d
│   │       └── my.cnf
```

こんなところにいたので、src/test/resourcesに同じものをコピーするしかないか？と思ってたのですが、しばらくしてから

```build.gradle
// 追記（FlywayのマイグレーションSQLをテストリソースとして使いたいため）
sourceSets.test {
    resources.srcDirs = ["src/test/resources", "docker"]
}
```

これでいいことに気づき、めでたくアプリとテストでファイルを一元化できました。

### テストを書く

```CarControllerTest.groovy
@SpringBootTest
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [MySQLContainerContextInitializer.class])
class CarControllerTest extends Specification {
    @Autowired
    MockMvc mockMvc

    @Unroll
    def "FindByPriceLessThanEqualOrderByPriceAsc HttpStatus"() {

        when:
        def result =
                mockMvc.perform(MockMvcRequestBuilders.get("/v1/cars/30000"))
                        .andReturn().getResponse()

        then:
        result.getStatus() == HttpStatus.OK.value
    }

    @Unroll
    def "FindByPriceLessThanEqualOrderByPriceAsc Data Pattern"() {

        expect:
        mockMvc.perform(MockMvcRequestBuilders.get("/v1/cars/" + price))
                .andExpect(MockMvcResultMatchers.jsonPath("\$.*", Matchers.hasSize(size)))

        where:
        price   || size
        "50000" || 10
        "40000" || 10
        "30000" || 9
        "20000" || 5
        "15000" || 2
        "10000" || 0

    }

}
```

`@ContextConfiguration(initializers = [MySQLContainerContextInitializer.class])`

で先ほどのヘルパークラスを指定し、コンテナの起動と接続情報の設定を行っています。
これで、テストコードからテスト用MySQLコンテナにアクセスできるようになります。

テスト内容は下記の2つです。

* HTTPステータス200でレスポンスが返るか（`FindByPriceLessThanEqualOrderByPriceAsc HttpStatus`）
* 価格パターンによるデータ駆動テスト（`FindByPriceLessThanEqualOrderByPriceAsc Data Pattern`）

私がSpockを推す最大の理由はなんといっても「読みやすさ」です。

```CarControllerTest.groovy
        expect:
        mockMvc.perform(MockMvcRequestBuilders.get("/v1/cars/" + price))
                .andExpect(MockMvcResultMatchers.jsonPath("\$.*", Matchers.hasSize(size)))

        where:
        price   || size
        "50000" || 10
        "40000" || 10
        "30000" || 9
        "20000" || 5
        "15000" || 2
        "10000" || 0
```

コメントがなくても何をテストしているかわかりますよね。
Rest APIに渡す価格のデータパターンを`price`、データパターン毎に返却されるはずのクルマオブジェクトの数を`size`に定義しています。

## テストを通す

```
 $ ./gradlew clean test
```

コンソールにテスト結果が表示されますが、レポートも出力されるので、ブラウザで表示してみましょう。

```
.
├── build
│   ├── reports
│   │   └── tests
│   │       └── test
│   │           ├── classes
│   │           │   └── com.example.restapitestbyspock.application.CarControllerTest.html
│   │           ├── css
│   │           │   ├── base-style.css
│   │           │   └── style.css
│   │           ├── index.html
│   │           ├── js
│   │           │   └── report.js
│   │           └── packages
│   │               └── com.example.restapitestbyspock.application.html

```

### 全テスト成功

![名称3設定３.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2082655/e0102783-9856-473d-580d-ea6193d0f6fa.png)

### 失敗テストあり

![名称未設定4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2082655/af971f5b-0f71-8f11-3e11-45965b05c6b1.png)

# 参考資料

[https://matsuand.github.io/docs.docker.jp.onthefly/reference/](https://matsuand.github.io/docs.docker.jp.onthefly/reference/)
[https://www.testcontainers.org/](https://www.testcontainers.org/)

# おわりに

いかがでしたでしょうか。
少しでも皆様のお役に立てるナレッジとなりましたら幸いです。

当社では、トヨタ車のサブスク「KINTO」等の企画/開発を行っており、エンジニアを募集中です。
[KINTO Technologies コーポレートサイト](https://www.kinto-technologies.com/)