CLOVER🍀

That was when it all began.

Spring Test × Database Riderで、データを作成する時にテーブル間の依存関係を記録する

これは、なにをしたくて書いたもの?

こちらのエントリーで、Database Riderをデフォルト設定で使う時に外部キーがたくさん使われているデータベースを対象にすると、
時間がかかるということを書きました。

Database Riderで、外部キーを大量に使っているデータベースを対象にすると実行が遅くなるという話 - CLOVER🍀

回避方法としては自分でテーブル間の依存関係を書けばいいのですが、これはこれで面倒です。

では、テストデータを作成する時にどういう順番でテーブルにデータを登録すればいいのかを書き出せばいいのでは?というのが今回の
お題です。

考え方

データベースを使ったテストを書く場合、そのテストデータの準備がとても大変になります。

SQLを書いていってもいいのですが、Javaで開発をしている場合はなんらかのデータベースアクセスライブラリーを使用している場合が
多く、テストデータもこちらを使うのが楽になるのではないでしょうか?

Database Riderを使う場合には、@ExportDataSetアノテーションを使うとテストでデータを作成できます。

Database Rider / Export DataSets

テストデータの作成はテストメソッド内で完結すると仮定すると、登録したデータの順を記録しておけばよいのではないでしょうか?

ということでSpring TestのTestExecutionListenerを使い、データ作成のテスト終了時にテーブルの記録順を出力&アサーションすることを
考えてみました。

環境

今回の環境はこちら。

$ java --version
openjdk 21.0.1 2023-10-17
OpenJDK Runtime Environment (build 21.0.1+12-Ubuntu-222.04)
OpenJDK 64-Bit Server VM (build 21.0.1+12-Ubuntu-222.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.9.6 (bc0240f3c744dd6b6ec2920b3cd08dcc295161ae)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 21.0.1, vendor: Private Build, runtime: /usr/lib/jvm/java-21-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.15.0-91-generic", arch: "amd64", family: "unix"

データベースにはMySQLを使用します。172.17.0.2で動作しているものとします。

 MySQL  localhost:3306 ssl  practice  SQL > select version();
+-----------+
| version() |
+-----------+
| 8.0.35    |
+-----------+
1 row in set (0.0007 sec)

Spring Bootプロジェクトを作成し、Database Riderのセットアップを行う

Spring Bootプロジェクトを作成。依存関係には、jpamysqlを加えています。

$ curl -s https://start.spring.io/starter.tgz \
  -d bootVersion=3.2.1 \
  -d javaVersion=21 \
  -d type=maven-project \
  -d name=database-rider-record-table-dependencies \
  -d groupId=org.littlewings \
  -d artifactId=database-rider-record-table-dependencies \
  -d version=0.0.1-SNAPSHOT \
  -d packageName=org.littlewings.spring.dbrider \
  -d dependencies=jpa,mysql \
  -d baseDir=database-rider-record-table-dependencies | tar zxvf -

データベースアクセスライブラリーはなんでもいいのですが、今回はデータの永続化が簡単なJPAにしました。

プロジェクト内へ移動。

$ cd database-rider-record-table-dependencies

Maven依存関係など。

        <modelVersion>4.0.0</modelVersion>
        <parent>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-parent</artifactId>
                <version>3.2.1</version>
                <relativePath/> <!-- lookup parent from repository -->
        </parent>
        <groupId>org.littlewings</groupId>
        <artifactId>database-rider-record-table-dependencies</artifactId>
        <version>0.0.1-SNAPSHOT</version>
        <name>database-rider-record-table-dependencies</name>
        <description>Demo project for Spring Boot</description>
        <properties>
                <java.version>21</java.version>
        </properties>
        <dependencies>
                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-data-jpa</artifactId>
                </dependency>

                <dependency>
                        <groupId>com.mysql</groupId>
                        <artifactId>mysql-connector-j</artifactId>
                        <scope>runtime</scope>
                </dependency>
                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-test</artifactId>
                        <scope>test</scope>
                </dependency>
        </dependencies>

        <build>
                <plugins>
                        <plugin>
                                <groupId>org.springframework.boot</groupId>
                                <artifactId>spring-boot-maven-plugin</artifactId>
                        </plugin>
                </plugins>
        </build>

自動生成されたソースコードは削除しておきます。

$ rm src/main/java/org/littlewings/spring/dbrider/DatabaseRiderRecordTableDependenciesApplication.java src/test/java/org/littlewings/spring/dbrider/DatabaseRiderRecordTableDependenciesApplicationTests.java

Spring向けのDatabase Rider(rider-spring)への依存関係を追加。

 <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.github.database-rider</groupId>
            <artifactId>rider-spring</artifactId>
            <version>1.41.0</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

今回のお題はカテゴリーと書籍にします。

src/main/resources/schema.sql

drop table if exists book;
drop table if exists category;

create table category (
  id varchar(3),
  name varchar(20),
  primary key(id)
);

create table book (
  isbn varchar(14),
  title varchar(200),
  price int,
  category_id varchar(20),
  primary key(isbn),
  foreign key(category_id) references category(id) on delete no action on update no action
);

JPAのエンティティを作成。

src/main/java/org/littlewings/spring/dbrider/entity/Category.java

package org.littlewings.spring.dbrider.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "category")
public class Category {
    @Id
    @Column(name = "id")
    private String id;

    @Column(name = "name")
    private String name;

    // getter/setterは省略
}

src/main/java/org/littlewings/spring/dbrider/entity/Book.java

package org.littlewings.spring.dbrider.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "book")
public class Book {
    @Id
    @Column(name = "isbn")
    private String isbn;

    @Column(name = "title")
    private String title;

    @Column(name = "price")
    private Integer price;

    @Column(name = "category_id")
    private String categoryId;

    // getter/setterは省略

今回はJPAの関連の機能は使わず、愚直にテーブル定義の内容を書きました。

リポジトリーの作成。

src/main/java/org/littlewings/spring/dbrider/repository/CategoryRepository.java

package org.littlewings.spring.dbrider.repository;

import org.littlewings.spring.dbrider.entity.Category;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface CategoryRepository extends JpaRepository<Category, String> {
}

src/main/java/org/littlewings/spring/dbrider/repository/BookRepository.java

package org.littlewings.spring.dbrider.repository;

import org.littlewings.spring.dbrider.entity.Book;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface BookRepository extends JpaRepository<Book, String> {
}

今回はテストが実行できればよいので、@SpringBootApplicationアノテーションを付与しただけのクラスを作成。

src/main/java/org/littlewings/spring/dbrider/App.java

package org.littlewings.spring.dbrider;

import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class App {
}

Spring Bootの設定。schema.sqlは毎回実行するようにしました。

src/main/resources/application.properties

spring.datasource.url=jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&connectionCollation=utf8mb4_0900_bin
spring.datasource.username=kazuhira
spring.datasource.password=password

spring.sql.init.mode=always

Database Riderの設定。

src/test/resources/dbunit.yml

cacheConnection: false
properties:
  caseSensitiveTableNames: true

これで、準備はできました。

テストデータを作成するテストを書く

それでは、テストデータを作成する部分を書いていきましょう。

以下の3種類のクラスを作ることにします。

  • Spring Data JPARepository#saveを実行させ、その時のテーブル名を記録するクラス
  • テストデータを作成するクラス
  • @ExportDataSetアノテーションの内容と記録されたテーブル名を比較してアサーションするTestExecutionListener

まずは、Spring Data JPAJpaRepository#saveを実行させ、その時のテーブル名を記録するクラス。

src/test/java/org/littlewings/spring/dbrider/TestDataRegistrationService.java

package org.littlewings.spring.dbrider;

import jakarta.persistence.Table;
import org.springframework.boot.test.context.TestComponent;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.LinkedHashSet;
import java.util.Set;

@TestComponent
public class TestDataRegistrationService {
    private Set<String> registeredTableNames = new LinkedHashSet<>();

    public <R extends JpaRepository<E, ?>, E> void save(R repository, E entity) {
        Table table = entity.getClass().getAnnotation(Table.class);
        registeredTableNames.add(table.name());

        repository.save(entity);
    }

    public Set<String> getRegisteredTableNames() {
        return registeredTableNames;
    }

    public void clear() {
        registeredTableNames.clear();
    }
}

やっていることは大したことはありません。JpaRepositorインスタンスを受け取りsaveを呼び出します。

その際に@Tableアノテーションからテーブル名を保存しておきます。

        Table table = entity.getClass().getAnnotation(Table.class);
        registeredTableNames.add(table.name());

今回はJPAアノテーションを使いましたが、エンティティからテーブル名が導出できれば方法はなんでもいいと思います。

記録内容は状態として持つことになるのがちょっと微妙ですが、まあ仕方ないかなと。現在保持しているテーブル名を返すメソッドや
保持している内容をクリアするメソッドも定義しています。

    private Set<String> registeredTableNames = new LinkedHashSet<>();

続いては、テストデータを作成するメソッド。

src/test/java/org/littlewings/spring/dbrider/TestDataGenerateTest.java

package org.littlewings.spring.dbrider;

import com.github.database.rider.core.api.dataset.DataSetFormat;
import com.github.database.rider.core.api.exporter.ExportDataSet;
import com.github.database.rider.spring.api.DBRider;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.littlewings.spring.dbrider.entity.Book;
import org.littlewings.spring.dbrider.entity.Category;
import org.littlewings.spring.dbrider.repository.BookRepository;
import org.littlewings.spring.dbrider.repository.CategoryRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Tag("generateData")
@DBRider
@Import(TestDataRegistrationService.class)
@SpringBootTest
class TestDataGenerateTest {
    @Autowired
    private TestDataRegistrationService testDataRegistrationService;

    @Autowired
    private CategoryRepository categoryRepository;

    @Autowired
    private BookRepository bookRepository;

    @Autowired
    private EntityManager entityManager;

    @ExportDataSet(
            format = DataSetFormat.YML,
            outputName = "src/test/resources/org/littlewings/spring/dbrider/DataGenerateTest/initial_dataset.yml",
            includeTables = {
                    "category", "book"
            }
    )
    @Transactional
    @Test
    void generateInitialData() {
        List<Category> categories = List.of(
                createCategory("001", "Java"),
                createCategory("002", "Spring"),
                createCategory("003", "MySQL")
        );

        categories.forEach(c -> testDataRegistrationService.save(categoryRepository, c));

        List<Book> books = List.of(
                createBook("978-4774189093", "Java本格入門 ~モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", 3278, "001"),
                createBook("978-4297136130", "プロになるためのSpring入門ーーゼロからの開発力養成講座", 3960, "002"),
                createBook("978-4798161488", "MySQL徹底入門 第4版 MySQL 8.0対応", 4180, "003")
        );

        books.forEach(b -> testDataRegistrationService.save(bookRepository, b));

        entityManager.flush();
    }

    @ExportDataSet(
            format = DataSetFormat.YML,
            outputName = "src/test/resources/org/littlewings/spring/dbrider/DataGenerateTest/expected_dataset.yml",
            includeTables = {
                    "category", "book"
            }
    )
    @Transactional
    @Test
    void generateExpectedData() {
        List<Category> categories = List.of(
                createCategory("001", "Java"),
                createCategory("002", "Spring"),
                createCategory("003", "MySQL"),
                createCategory("004", "Python")
        );

        categories.forEach(c -> testDataRegistrationService.save(categoryRepository, c));

        List<Book> books = List.of(
                createBook("978-4774189093", "Java本格入門 ~モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", 3278, "001"),
                createBook("978-4297136130", "プロになるためのSpring入門ーーゼロからの開発力養成講座", 3960, "002"),
                createBook("978-4798161488", "MySQL徹底入門 第4版 MySQL 8.0対応", 4180, "003"),
                createBook("978-4798142470", "Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発", 4400, "002"),
                createBook("978-4873119328", "入門 Python 3 第2版", 4180, "004")
        );

        books.forEach(b -> testDataRegistrationService.save(bookRepository, b));

        entityManager.flush();
    }

    Category createCategory(String id, String name) {
        Category category = new Category();
        category.setId(id);
        category.setName(name);

        return category;
    }

    Book createBook(String isbn, String title, Integer price, String categoryId) {
        Book book = new Book();
        book.setIsbn(isbn);
        book.setTitle(title);
        book.setPrice(price);
        book.setCategoryId(categoryId);

        return book;
    }
}

テストの初期データと、期待値のデータを作成するようにしています。EntityManager#flushが入っているのは、これを入れないと
@ExportDataSetアノテーションによる処理を実行した際にはテーブルにデータが反映されていないからです。
@Transactionalアノテーションを付与しているので、その時点でトランザクションが完了していないからなのですが。

すでにincludeTablesを指定していますが、こちらはまた後で説明します。

    @ExportDataSet(
            format = DataSetFormat.YML,
            outputName = "src/test/resources/org/littlewings/spring/dbrider/DataGenerateTest/initial_dataset.yml",
            includeTables = {
                    "category", "book"
            }
    )

また、このようなテストデータを作成するクラスには@Tagアノテーションを付与することにしました。

@Tag("generateData")
@DBRider
@Import(TestDataRegistrationService.class)
@SpringBootTest
class TestDataGenerateTest {

そして、@ExportDataSetアノテーションに書かれている内容とテストデータを作成したテーブル名が一致するかどうかを検証する
TestExecutionListenerの実装。

src/test/java/org/littlewings/spring/dbrider/TableOrderAssertTestExecutionListener.java

package org.littlewings.spring.dbrider;

import com.github.database.rider.core.api.exporter.ExportDataSet;
import org.springframework.test.context.TestContext;
import org.springframework.test.context.support.AbstractTestExecutionListener;

import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

import static org.assertj.core.api.Assertions.assertThat;

public class TableOrderAssertTestExecutionListener extends AbstractTestExecutionListener {
    @Override
    public int getOrder() {
        return 20000;
    }

    @Override
    public void afterTestMethod(TestContext testContext) throws Exception {
        Method method = testContext.getTestMethod();

        ExportDataSet exportDataSet = method.getAnnotation(ExportDataSet.class);

        if (exportDataSet == null) {
            return;
        }

        String[] tables = exportDataSet.includeTables();
        List<String> includeTables = Arrays.stream(tables).toList();

        TestDataRegistrationService testDataRegistrationService =
                testContext.getApplicationContext().getBean(TestDataRegistrationService.class);

        List<String> registeredTableNames = testDataRegistrationService.getRegisteredTableNames().stream().toList();
        testDataRegistrationService.clear();

        if (!registeredTableNames.equals(includeTables)) {
            System.out.println("========================================");
            System.out.println();

            String tableNames = registeredTableNames.stream().map(t -> "\"" + t + "\"").collect(Collectors.joining(", "));

            System.out.println("@ExportDataSet(includeTables = { ... }) には、以下を指定してください");
            System.out.println();
            System.out.println("includeTables = {");
            System.out.println("        " + tableNames);
            System.out.println("}");

            System.out.println();

            System.out.println("また @DataSet(tableOrdering = { ... }) には、以下を指定してください");
            System.out.println();

            System.out.println("tableOrdering = {");
            System.out.println("        " + tableNames);
            System.out.println("}");

            System.out.println();

            System.out.println("========================================");

            assertThat(registeredTableNames).isEqualTo(includeTables);
        }
    }
}

テストメソッドに@ExportDataSetアノテーションが付与されていたら、includeTablesの内容を取得します。

        Method method = testContext.getTestMethod();

        ExportDataSet exportDataSet = method.getAnnotation(ExportDataSet.class);

        if (exportDataSet == null) {
            return;
        }

        String[] tables = exportDataSet.includeTables();
        List<String> includeTables = Arrays.stream(tables).toList();

取得したincludeTablesの内容と記録したテーブル名を比較し、一致していなければどのような内容を記述すべきかを出力して
最後にアサーションします(これは失敗します)。

        TestDataRegistrationService testDataRegistrationService =
                testContext.getApplicationContext().getBean(TestDataRegistrationService.class);

        List<String> registeredTableNames = testDataRegistrationService.getRegisteredTableNames().stream().toList();
        testDataRegistrationService.clear();

        if (!registeredTableNames.equals(includeTables)) {
            System.out.println("========================================");
            System.out.println();

            String tableNames = registeredTableNames.stream().map(t -> "\"" + t + "\"").collect(Collectors.joining(", "));

            System.out.println("@ExportDataSet(includeTables = { ... }) には、以下を指定してください");
            System.out.println();
            System.out.println("includeTables = {");
            System.out.println("        " + tableNames);
            System.out.println("}");

            System.out.println();

            System.out.println("また @DataSet(tableOrdering = { ... }) には、以下を指定してください");
            System.out.println();

            System.out.println("tableOrdering = {");
            System.out.println("        " + tableNames);
            System.out.println("}");

            System.out.println();

            System.out.println("========================================");

            assertThat(registeredTableNames).isEqualTo(includeTables);
        }

記録したテーブル名は、取得後するにクリアします。

        List<String> registeredTableNames = testDataRegistrationService.getRegisteredTableNames().stream().toList();
        testDataRegistrationService.clear();

この作成したTestExecutionListenerをテストで動作するように組み込む必要があるのですが、通常思いつくようなこんなアノテーション
作成して付与してもうまくいきません。

src/test/java/org/littlewings/spring/dbrider/TableOrderAssert.java

package org.littlewings.spring.dbrider;

import org.springframework.test.context.TestExecutionListeners;

@TestExecutionListeners(
        listeners = TableOrderAssertTestExecutionListener.class,
        mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS
)
public @interface TableOrderAssert {
}

これはなぜかというと、同じ仕組みが@DBRiderアノテーションによって使われており、こちらが有効になっているからです。

@Tag("generateData")
@DBRider
@Import(TestDataRegistrationService.class)
@SpringBootTest
class TestDataGenerateTest {

なので、どちらからのアノテーションしか有効にできない状態になるというわけですね。

@DBRiderアノテーションを付与しなくても動くようにすることもできますが、それはやりすぎな気もするので今回は自作の
TestExecutionListenerをデフォルトのTestExecutionListenerに加えることにしました。

こちらを参考にして

https://github.com/spring-projects/spring-framework/blob/v6.1.2/spring-test/src/main/resources/META-INF/spring.factories

こんなファイルを作成。

src/test/resources/META-INF/spring.factories

org.springframework.test.context.TestExecutionListener = \
        org.springframework.test.context.web.ServletTestExecutionListener,\
        org.springframework.test.context.support.DirtiesContextBeforeModesTestExecutionListener,\
        org.springframework.test.context.event.ApplicationEventsTestExecutionListener,\
        org.springframework.test.context.support.DependencyInjectionTestExecutionListener,\
        org.springframework.test.context.observation.MicrometerObservationRegistryTestExecutionListener,\
        org.springframework.test.context.support.DirtiesContextTestExecutionListener,\
        org.springframework.test.context.transaction.TransactionalTestExecutionListener,\
        org.springframework.test.context.jdbc.SqlScriptsTestExecutionListener,\
        org.springframework.test.context.event.EventPublishingTestExecutionListener, \
        org.littlewings.spring.dbrider.TableOrderAssertTestExecutionListener

末尾に自作のTestExecutionListenerを追加しました。

        org.springframework.test.context.event.EventPublishingTestExecutionListener, \
        org.littlewings.spring.dbrider.TableOrderAssertTestExecutionListener

これで、自作のTestExecutionListenerがテストで動作するようになります。

たとえば、includeTablesを以下のように変更してテストを実行すると

    @ExportDataSet(
            format = DataSetFormat.YML,
            outputName = "src/test/resources/org/littlewings/spring/dbrider/DataGenerateTest/initial_dataset.yml",
            /*
            includeTables = {
                    "category", "book"
            }
             */
            includeTables = {}
    )
    @Transactional
    @Test
    void generateInitialData() {

こういう結果になり、テストが失敗します。

========================================

@ExportDataSet(includeTables = { ... }) には、以下を指定してください

includeTables = {
        "category", "book"
}

また @DataSet(tableOrdering = { ... }) には、以下を指定してください

tableOrdering = {
        "category", "book"
}

========================================
2024-01-14T00:31:15.116+09:00  WARN 39771 --- [           main] o.s.test.context.TestContextManager      : Caught exception while invoking 'afterTestMethod' callback on TestExecutionListener [org.littlewings.spring.dbrider.TableOrderAssertTestExecutionListener] for test method [void org.littlewings.spring.dbrider.TestDataGenerateTest.generateInitialData()] and test instance [org.littlewings.spring.dbrider.TestDataGenerateTest@390e814c]

org.opentest4j.AssertionFailedError:
expected: []
 but was: ["category", "book"]
        at java.base/jdk.internal.reflect.DirectConstructorHandleAccessor.newInstance(DirectConstructorHandleAccessor.java:62) ~[na:na]
        at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:502) ~[na:na]
        at org.littlewings.spring.dbrider.TableOrderAssertTestExecutionListener.afterTestMethod(TableOrderAssertTestExecutionListener.java:64) ~[test-classes/:na]
        at org.springframework.test.context.TestContextManager.afterTestMethod(TestContextManager.java:487) ~[spring-test-6.1.2.jar:6.1.2]
        at org.springframework.test.context.junit.jupiter.SpringExtension.afterEach(SpringExtension.java:278) ~[spring-test-6.1.2.jar:6.1.2]
        at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeAfterEachCallbacks$12(TestMethodTestDescriptor.java:261) ~[junit-jupiter-engine-5.10.1.jar:5.10.1]
        at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeAllAfterMethodsOrCallbacks$13(TestMethodTestDescriptor.java:277) ~[junit-jupiter-engine-5.10.1.jar:5.10.1]
        at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeAllAfterMethodsOrCallbacks$14(TestMethodTestDescriptor.java:277) ~[junit-jupiter-engine-5.10.1.jar:5.10.1]
        at org.junit.platform.commons.util.CollectionUtils.forEachInReverseOrder(CollectionUtils.java:217) ~[junit-platform-commons-1.10.1.jar:1.10.1]
        at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeAllAfterMethodsOrCallbacks(TestMethodTestDescriptor.java:276) ~[junit-jupiter-engine-5.10.1.jar:5.10.1]
        at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeAfterEachCallbacks(TestMethodTestDescriptor.java:260) ~[junit-jupiter-engine-5.10.1.jar:5.10.1]
        at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:145) ~[junit-jupiter-engine-5.10.1.jar:5.10.1]
        at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:69) ~[junit-jupiter-engine-5.10.1.jar:5.10.1]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:151) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) ~[na:na]
        at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:41) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:155) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at java.base/java.util.ArrayList.forEach(ArrayList.java:1596) ~[na:na]
        at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:41) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:155) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:35) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:57) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:54) ~[junit-platform-engine-1.10.1.jar:1.10.1]
        at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:198) ~[junit-platform-launcher-1.10.1.jar:1.10.1]
        at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:169) ~[junit-platform-launcher-1.10.1.jar:1.10.1]
        at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:93) ~[junit-platform-launcher-1.10.1.jar:1.10.1]
        at org.junit.platform.launcher.core.EngineExecutionOrchestrator.lambda$execute$0(EngineExecutionOrchestrator.java:58) ~[junit-platform-launcher-1.10.1.jar:1.10.1]
        at org.junit.platform.launcher.core.EngineExecutionOrchestrator.withInterceptedStreams(EngineExecutionOrchestrator.java:141) ~[junit-platform-launcher-1.10.1.jar:1.10.1]
        at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:57) ~[junit-platform-launcher-1.10.1.jar:1.10.1]
        at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:103) ~[junit-platform-launcher-1.10.1.jar:1.10.1]
        at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:85) ~[junit-platform-launcher-1.10.1.jar:1.10.1]
        at org.junit.platform.launcher.core.DelegatingLauncher.execute(DelegatingLauncher.java:47) ~[junit-platform-launcher-1.10.1.jar:1.10.1]
        at org.apache.maven.surefire.junitplatform.LazyLauncher.execute(LazyLauncher.java:56) ~[surefire-junit-platform-3.1.2.jar:3.1.2]
        at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.execute(JUnitPlatformProvider.java:184) ~[surefire-junit-platform-3.1.2.jar:3.1.2]
        at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invokeAllTests(JUnitPlatformProvider.java:148) ~[surefire-junit-platform-3.1.2.jar:3.1.2]
        at org.apache.maven.surefire.junitplatform.JUnitPlatformProvider.invoke(JUnitPlatformProvider.java:122) ~[surefire-junit-platform-3.1.2.jar:3.1.2]
        at org.apache.maven.surefire.booter.ForkedBooter.runSuitesInProcess(ForkedBooter.java:385) ~[surefire-booter-3.1.2.jar:3.1.2]
        at org.apache.maven.surefire.booter.ForkedBooter.execute(ForkedBooter.java:162) ~[surefire-booter-3.1.2.jar:3.1.2]
        at org.apache.maven.surefire.booter.ForkedBooter.run(ForkedBooter.java:507) ~[surefire-booter-3.1.2.jar:3.1.2]
        at org.apache.maven.surefire.booter.ForkedBooter.main(ForkedBooter.java:495) ~[surefire-booter-3.1.2.jar:3.1.2]

[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0, Time elapsed: 4.560 s <<< FAILURE! -- in org.littlewings.spring.dbrider.TestDataGenerateTest
[ERROR] org.littlewings.spring.dbrider.TestDataGenerateTest.generateInitialData -- Time elapsed: 0.111 s <<< FAILURE!
org.opentest4j.AssertionFailedError:

includeTablesは指定しなくてもいいのでは?という話もあるのですが、includeTablesを指定しない場合はデータベース内のすべての
テーブルをエクスポートするようになってしまうので、テストメソッド内で記録したテーブル以外は出力しない方がよいかなと。
見知らぬデータが入り込んでも困りますからね。

また、こうすることでテストデータ作成用のテストを書いた後に、必要なテーブルが増減した場合でも検出することができます。
検出したら、@DataSetアノテーションを付与しているテストの方にも反映が必要ですし。

テストを書く

作成したデータを使って、テストを書いていきます。こちらは通常どおりDatabase Riderを使ってテストを書けばOKです。

src/test/java/org/littlewings/spring/dbrider/DataTest.java

package org.littlewings.spring.dbrider;

import com.github.database.rider.core.api.dataset.DataSet;
import com.github.database.rider.core.api.dataset.ExpectedDataSet;
import com.github.database.rider.spring.api.DBRider;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.littlewings.spring.dbrider.entity.Book;
import org.littlewings.spring.dbrider.entity.Category;
import org.littlewings.spring.dbrider.repository.BookRepository;
import org.littlewings.spring.dbrider.repository.CategoryRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

@DBRider
@SpringBootTest
class DataTest {
    @Autowired
    private CategoryRepository categoryRepository;

    @Autowired
    private BookRepository bookRepository;

    @Autowired
    private EntityManager entityManager;

    @DataSet(
            value = "org/littlewings/spring/dbrider/DataGenerateTest/initial_dataset.yml",
            useSequenceFiltering = false,
            tableOrdering = {
                    "category", "book"
            }
    )
    @ExpectedDataSet(
            value = "org/littlewings/spring/dbrider/DataGenerateTest/expected_dataset.yml",
            orderBy = {"id", "isbn"}
    )
    @Transactional
    @Test
    void registerCategoryAndBook() {
        Category pythonCategory = new Category();
        pythonCategory.setId("004");
        pythonCategory.setName("Python");

        categoryRepository.save(pythonCategory);

        Book springBook = new Book();
        springBook.setIsbn("978-4798142470");
        springBook.setTitle("Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発");
        springBook.setPrice(4400);
        springBook.setCategoryId("002");

        Book pythonBook = new Book();
        pythonBook.setIsbn("978-4873119328");
        pythonBook.setTitle("入門 Python 3 第2版");
        pythonBook.setPrice(4180);
        pythonBook.setCategoryId("004");

        bookRepository.save(springBook);
        bookRepository.save(pythonBook);

        entityManager.flush();
    }
}

@DataSetアノテーションおよび@ExpectedDataSetアノテーションvalueには、テストで生成したデータセットのパスを記載して
います。

    @DataSet(
            value = "org/littlewings/spring/dbrider/DataGenerateTest/initial_dataset.yml",
            useSequenceFiltering = false,
            tableOrdering = {
                    "category", "book"
            }
    )
    @ExpectedDataSet(
            value = "org/littlewings/spring/dbrider/DataGenerateTest/expected_dataset.yml",
            orderBy = {"id", "isbn"}
    )
    @Transactional
    @Test
    void registerCategoryAndBook() {

tableOrderingは、TestExecutionListenerによって出力されたものですね。

    @DataSet(
            value = "org/littlewings/spring/dbrider/DataGenerateTest/initial_dataset.yml",
            useSequenceFiltering = false,
            tableOrdering = {
                    "category", "book"
            }
    )

これで、やりたいテストの仕組みはできました。

テストの実行順を考える

これで終わりと言いたいところなのですが、この2種類のテストは依存関係があり、順不同で実行されると困ります。

以下の順番で動いて欲しいですね。

  • テストデータを作成するテスト
  • 作成されたテストデータや期待値のデータを使って行うテスト

これが理由で、テストデータを作成するクラスにはJUnit 5の@Tagアノテーションを付与しました。

@Tag("generateData")
@DBRider
@Import(TestDataRegistrationService.class)
@SpringBootTest
class TestDataGenerateTest {

Maven Surefire Pluginの設定を追加し、実行するテストグループ(group)と実行しないグループ(excludedGroups)を指定できるように
しました。

         <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <configuration>
                    <groups>${test.groups}</groups>
                    <excludedGroups>${test.excludedGroups}</excludedGroups>
                </configuration>
            </plugin>

これらの値は、プロパティで定義しておきます。

 <properties>
        <java.version>21</java.version>
        <test.excludedGroups>generateData</test.excludedGroups>
        <test.groups/>
    </properties>

デフォルトでは、テストデータ作成のテストは動作しないようにしてあります(IDE上からはふつうに実行できますが)。

テストデータを作成するテストのみを動かす場合は、以下の指定で実行します。

$ mvn test -Dtest.excludedGroups= -Dtest.groups=generateData

結果。

[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 5.908 s -- in org.littlewings.spring.dbrider.TestDataGenerateTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0

テスト自体を実行する場合は、ふつうにmvn testです。

$ mvn test

結果。

[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 5.419 s -- in org.littlewings.spring.dbrider.DataTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0

順番に実行する場合は、こうしたらよいかなと。

$ mvn test -Dtest.excludedGroups= -Dtest.groups=generateData && mvn test

JUnit 5内で、タグをつけたテストの実行順をコントロールすることはできなさそうだったので、こういう感じになりました。

おわりに

Spring TestとDatabase Riderで、データを作成する時にテーブル間の依存関係を記録するような仕掛けを考えてみました。

あくまでDatabase Riderの仕組みに乗る場合は、テストデータの作成をテストで作成するように閉じてしまえば管理面ではいろいろと
やりやすくなるのではないかなと思います。

とはいえ、テストデータで1番大変なのはテストデータのメンテナンスな気がするんですけどね…。