🔖

Spock + Testcontainers でJavaのテストコードを書いてみた

2024/10/14に公開

目的

勉強用にSpock(Groovy)とTestcontainersでテストコードを書いてみました。

準備

Testcontainersはデフォルトで/var/run/docker.sockからDocker daemonにアクセスしようとします。そこで実行環境のDockerソケットにシンボリックリンクを作ってあげます。

Rancher Desktopの場合
$ sudo ln -s $HOME/.rd/docker.sock /var/run/docker.sock

参考:
https://github.com/docker/docker-py/issues/3059#issuecomment-1294369344

依存ライブラリを追加

今回はGradleで管理するのでbuild.gradle.ktsに以下のライブラリを追加します。コンテナのDBエンジンはMySQLを選択しました。
のちほど説明しますがFlywayはDDLで使用します。

buil.gradle.kts
// Spock
testImplementation("org.spockframework:spock-core:2.4-M1-groovy-4.0")
testImplementation("org.spockframework:spock-spring:2.4-M1-groovy-4.0")
// Testcontainers
testImplementation("org.testcontainers:spock:1.20.2")
testImplementation("org.testcontainers:mysql:1.20.2")
// Flyway
testImplementation("org.flywaydb:flyway-core:10.19.0")
testImplementation("org.flywaydb:flyway-mysql:10.19.0")

テスト対象のソースコード

以下のMyBatisのMapperをテストしてみます。
熊の情報をDBから取得するコードになります。

DbMapper.java
@Mapper
public interface DbMapper {
    List<Bear> getAllBears();
}
DbMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ttsham6.bear.catalog.mapper.DbMapper">
    <select id="getAllBears" resultType="com.ttsham6.bear.catalog.model.Bear">
        SELECT * FROM bear ORDER BY id
    </select>
</mapper>

テストコード全体

最初にテストコード全体をお見せします。

DbMapperSpec.groovy
@SpringBootTest
@Testcontainers
class DbMapperSpec extends Specification {
    @Shared
    MySQLContainer mySQLContainer = new MySQLContainer("mysql:8.0")
            .withDatabaseName("bear_db")
            .withUsername("app")
            .withPassword("P@ssw0rd")

    @Autowired
    DbMapper dbMapper

    def setupSpec() {
        mySQLContainer.start()

        // DDL by flyway
        Flyway flyway = Flyway.configure()
                .dataSource(
                        mySQLContainer.getJdbcUrl(),
                        mySQLContainer.getUsername(),
                        mySQLContainer.getPassword()
                ).locations("classpath:sql/test")
                .load()
        flyway.migrate()

        // setup datasource
        System.setProperty("spring.datasource.url", mySQLContainer.getJdbcUrl())
        System.setProperty("spring.datasource.username", mySQLContainer.getUsername())
        System.setProperty("spring.datasource.password", mySQLContainer.getPassword())
        System.setProperty("spring.datasource.driver-class-name", mySQLContainer.getDriverClassName())
        System.setProperty("spring.datasource.hiakri.connection-timeout", "20000")
        System.setProperty("spring.datasource.hikari.maximum-pool-size", "5")
    }

    def cleanupSpec() {
        mySQLContainer.stop()
    }

    def setup() {
        def connection = mySQLContainer.createConnection("")
        def statement = connection.createStatement()
        statement.executeUpdate("DELETE FROM habitat")
        statement.executeUpdate("DELETE FROM bear")
        statement.close()
        connection.close()
    }

    def "getAllBears_正常系_全件取得"() {
        given:
        def connection = mySQLContainer.createConnection("")
        def statement = connection.createStatement()
        statement.executeUpdate(
                "INSERT INTO bear (id, name, scientific_name, species, color, is_alive)\n" +
                        "VALUES\n" +
                        "(1000, 'ヒグマ', 'Ursus Arctos', 'ヒグマ', '茶色', true),\n" +
                        "(1001, 'ツキノワグマ', 'Ursus Thibetanus', 'ツキノワグマ', '黒', true),\n" +
                        "(1002, 'ホッキョクグマ', 'Ursus Maritimus', 'ホッキョクグマ', '白', true);"
        )

        when:
        def result = dbMapper.getAllBears()

        then:
        result.size() == 3
        result.get(0) == new Bear(1000, "ヒグマ", "Ursus Arctos", "ヒグマ", "茶色", true)
        result.get(1) == new Bear(1001, "ツキノワグマ", "Ursus Thibetanus", "ツキノワグマ", "黒", true)
        result.get(2) == new Bear(1002, "ホッキョクグマ", "Ursus Maritimus", "ホッキョクグマ", "白", true)

        cleanup:
        statement.close()
        connection.close()
    }
}

ここからポイントに絞って解説していきます。

Testcontainers設定

DbMapperSpec.groovy
@Shared
MySQLContainer mySQLContainer = new MySQLContainer("mysql:8.0")
        .withDatabaseName("bear_db")
        .withUsername("app")
        .withPassword("P@ssw0rd")

MySQLコンテナの設定をします。重要なのはDB名のみでユーザー名とパスワードは適当なもので大丈夫です。

DbMapperSpec.groovy
def setupSpec() {
    mySQLContainer.start()

setupSpec()はこのクラス内で一度だけ実行されるメソッドになります。
最初にMySQLコンテナを起動します。

DbMapperSpec.groovy
// DDL
Flyway flyway = Flyway.configure()
        .dataSource(
                mySQLContainer.getJdbcUrl(),
                mySQLContainer.getUsername(),
                mySQLContainer.getPassword()
        ).locations("classpath:sql/test")
        .load()
flyway.migrate()

FlywayでDDLを実行します。接続情報はmySQLContainerから取得できます。

V0__ddl.sql
CREATE DATABASE IF NOT EXISTS bear_db;

USE bear_db;

CREATE TABLE IF NOT EXISTS bear (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(255),
    scientific_name VARCHAR(255),
    species VARCHAR(255),
    color VARCHAR(255),
    is_alive BOOLEAN
);

DDLファイルは.locationsで指定したディレクトリ(今回はtest/resources/sql/test)に配置しました。ファイル名のプレフィックスにV*__を付けないとFlywayが読み込んでくれないので注意してください。

DbMapperSpec.groovy
// setup datasource
System.setProperty("spring.datasource.url", mySQLContainer.getJdbcUrl())
System.setProperty("spring.datasource.username", mySQLContainer.getUsername())
System.setProperty("spring.datasource.password", mySQLContainer.getPassword())
System.setProperty("spring.datasource.driver-class-name", mySQLContainer.getDriverClassName())
System.setProperty("spring.datasource.hiakri.connection-timeout", "20000")
System.setProperty("spring.datasource.hikari.maximum-pool-size", "5")

Springのdatasource設定をします。Flywayと同様にDBの接続情報はmySQLContainerから取得しました。

ここまでが setupSpec()の内容になります。

DbMapperSpec.groovy
def cleanupSpec() {
    mySQLContainer.stop()
}

cleanupSpec()を使ってすべてのテストが終わったらMySQLコンテナを停止します。

DB初期化

テスト実行前にsetup()メソッドでDB内のデータを初期化します。

DbMapperSpec.groovy
def setup() {
    def connection = mySQLContainer.createConnection("")
    def statement = connection.createStatement()
    statement.executeUpdate("DELETE FROM bear")
    statement.close()
    connection.close()
}

setupSpec()がテストクラスの最初に1回だけ呼ばれるメソッドであるのに対し、setup()は各テストの実行前に呼ばれます。今回はデータの初期化を行なっていますが、すべてのテストで共通に利用するデータをinsertすることもできます。

テスト実行

最後にテストメソッドの解説になります。

DbMapperSpec.groovy
def "getAllBears_正常系_全件取得"() {
    given:
    def connection = mySQLContainer.createConnection("")
    def statement = connection.createStatement()
    statement.executeUpdate(
            "INSERT INTO bear (id, name, scientific_name, species, color, is_alive)\n" +
                    "VALUES\n" +
                    "(1000, 'ヒグマ', 'Ursus Arctos', 'ヒグマ', '茶色', true),\n" +
                    "(1001, 'ツキノワグマ', 'Ursus Thibetanus', 'ツキノワグマ', '黒', true),\n" +
                    "(1002, 'ホッキョクグマ', 'Ursus Maritimus', 'ホッキョクグマ', '白', true);"
    )

    when:
    def result = dbMapper.getAllBears()

    then:
    result.size() == 3
    result.get(0) == new Bear(1000, "ヒグマ", "Ursus Arctos", "ヒグマ", "茶色", true)
    result.get(1) == new Bear(1001, "ツキノワグマ", "Ursus Thibetanus", "ツキノワグマ", "黒", true)
    result.get(2) == new Bear(1002, "ホッキョクグマ", "Ursus Maritimus", "ホッキョクグマ", "白", true)

    cleanup:
    statement.close()
    connection.close()
}

各ブロックで行なっていることは以下のとおりです。

  • given: テストケース固有のデータをinsert
  • when: テスト対象メソッドの実行
  • then: テスト結果の評価
  • cleanup: MySQLコンテナのconnectionをクローズ

最後に

ソースコード全体をgithubに置いておきます。
https://github.com/ttsham6/bear-catalog-api

Discussion