これは、なにをしたくて書いたもの?
こちらのエントリーで、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プロジェクトを作成。依存関係には、jpa
とmysql
を加えています。
$ 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 JPAの
Repository#save
を実行させ、その時のテーブル名を記録するクラス - テストデータを作成するクラス
@ExportDataSet
アノテーションの内容と記録されたテーブル名を比較してアサーションするTestExecutionListener
まずは、Spring Data JPAのJpaRepository#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
に加えることにしました。
こちらを参考にして
こんなファイルを作成。
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番大変なのはテストデータのメンテナンスな気がするんですけどね…。