これは、なにをしたくて書いたもの?
以前、こんなエントリーを書きました。
Database RiderをSpring Framework(Spring Boot)と合わせて使う - CLOVER🍀
Spring Framework 5以降であれば、rider-junit5を使うのがよいのだろうと思ったのですが、ちょっと気になる差異があったので確認して
みました。
Database RiderとSpring Framework
Database RiderとSpring Frameworkを組み合わせる場合、選択肢として次の2つのモジュールがあります。
- Database Rider / JUnit 5
- Rider JUnit 5モジュール(rider-junit5)
- Database Rider / Spring
- Rider Springモジュール(rider-spring)
Rider Springモジュールの方に注意書きが書かれているのですが、Rider SpringモジュールはJUnit 4とSpringRunner
向けに設計されたもの
なので、JUnit 5を組み合わせる場合はRider JUnit 5モジュールを使うことが勧められています。
This module is designed to work with JUnit4 and SpringRunner, for JUnit5 please use @DBRider annotation from JUnit5 module, see an example here.
一方で、実装方法なのですが。
Rider JUnit 5モジュールは、JUnit 5のテストライフサイクルコールバックの仕組みで実現されています。
JUnit 5 User Guide / Extension Model / Test Lifecycle Callbacks
Rider Springモジュールは、Spring TestのTestExecutionListener
の仕組みで実現されています。
Testing / Spring TestContext Framework / TestExecutionListener Configuration
サンプルに書かれているように、こういった使い方をRider JUnit 5モジュールで実現できます。
@Test @ExpectedDataSet("expectedUsers.yml") public void shouldDeleteUser() { assertThat(userRepository).isNotNull(); assertThat(userRepository.count()).isEqualTo(3); userRepository.findById(2L).ifPresent(userRepository::delete); //assertThat(userRepository.count()).isEqualTo(2); //assertion is made by @ExpectedDataset } @Test @DataSet(cleanBefore = true)//as we didn't declared a dataset DBUnit wont clear the table @ExpectedDataSet("user.yml") public void shouldInsertUser() { assertThat(userRepository).isNotNull(); assertThat(userRepository.count()).isEqualTo(0); userRepository.save(new User("newUser@gmail.com", "new user")); //assertThat(userRepository.count()).isEqualTo(1); //assertion is made by @ExpectedDataset }
これなら問題ないのですが、Spring Frameworkの@Transactional
を使うと話が変わってきます。テストメソッドに@Transactional
を
付与すると、テストメソッドの実行終了時にロールバックしてくれます。
Testing / Spring TestContext Framework / Transaction Management
今回は、この2つのモジュールで@Transactional
と組み合わせるとどうなるかを見ていこうと思います。
環境
今回の環境は、こちら。
java --version openjdk 17.0.6 2023-01-17 OpenJDK Runtime Environment (build 17.0.6+10-Ubuntu-0ubuntu122.04) OpenJDK 64-Bit Server VM (build 17.0.6+10-Ubuntu-0ubuntu122.04, mixed mode, sharing) $ mvn --version Apache Maven 3.9.1 (2e178502fcdbffc201671fb2537d0cb4b4cc58f8) Maven home: $HOME/.sdkman/candidates/maven/current Java version: 17.0.6, vendor: Private Build, runtime: /usr/lib/jvm/java-17-openjdk-amd64 Default locale: ja_JP, platform encoding: UTF-8 OS name: "linux", version: "5.15.0-67-generic", arch: "amd64", family: "unix"
データベースには、MySQLを使用します。
MySQL localhost:3306 ssl practice SQL > select version(); +-----------+ | version() | +-----------+ | 8.0.32 | +-----------+ 1 row in set (0.0285 sec)
MySQLは172.17.0.2で動作しているものとし、データベースpractice
、アカウントはkazuhira
/password
で接続できるものと
します。
Spring Bootプロジェクトを作成する
Spring Bootプロジェクトを作成します。依存関係にはjdbc
とmysql
を含めておきます。
$ curl -s https://start.spring.io/starter.tgz \ -d bootVersion=3.0.1 \ -d javaVersion=17 \ -d type=maven-project \ -d name=database-rider-transaction \ -d groupId=org.littlewings \ -d artifactId=database-rider-transaction \ -d version=0.0.1-SNAPSHOT \ -d packageName=org.littlewings.spring.dbrider \ -d dependencies=jdbc,mysql \ -d baseDir=database-rider-transaction | tar zxvf -
ディレクトリ内に移動。
$ cd test-execution-listener-example
生成されたMaven依存関係など。
<properties> <java.version>17</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</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/DatabaseRiderTransactionApplication.java src/test/java/org/littlewings/spring/dbrider/DatabaseRiderTransactionApplicationTests.java
今回は、動作の違いがわかるようにRider JUnit 5モジュールとRider Springモジュールの両方を追加します。
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</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-junit5</artifactId> <version>1.36.0</version> <scope>test</scope> </dependency> <dependency> <groupId>com.github.database-rider</groupId> <artifactId>rider-spring</artifactId> <version>1.36.0</version> <scope>test</scope> </dependency> </dependencies>
ここから動作確認用のソースコードとテストコードを書いていきましょう。
お題とソースコード
今回使うテーブルは以下の2つとします。
src/main/resources/schema.sql
drop table if exists person; drop table if exists family; create table family( id int, name varchar(20), primary key(id) ); create table person( id int, last_name varchar(20), first_name varchar(20), age int, family_id int, primary key(id), foreign key(family_id) references family(id) );
ここに、初期データとして以下を加えるようにします。
src/main/resources/data.sql
-- family insert into family(id, name) values(1, '磯野家'); -- people insert into person(id, last_name, first_name, age, family_id) values(1, '磯野', '波平', 55, 1); insert into person(id, last_name, first_name, age, family_id) values(2, '磯野', 'フネ', 48, 1);
この2つのSQLは、いずれもSpring Bootの起動時に自動で実行されるように設定します。
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としてもアサーションを
行うようにします。ここで、@Transactional
をテストメソッドに付与するとRider JUnit 5モジュールとRider Springモジュールで
動きがどう変わるかを確認するのが今回のお題です。
ソースコードの作成。
エンティティ的なクラス。
src/main/java/org/littlewings/spring/dbrider/Family.java
package org.littlewings.spring.dbrider; public class Family { private Integer id; private String name; public static Family create(Integer id, String name) { Family family = new Family(); family.setId(id); family.setName(name); return family; } // getter/setterは省略 }
src/main/java/org/littlewings/spring/dbrider/Person.java
package org.littlewings.spring.dbrider; public class Person { private Integer id; private String lastName; private String firstName; private Integer age; private Integer familyId; public static Person create(Integer id, String lastName, String firstName, Integer age, Integer familyId) { Person person = new Person(); person.setId(id); person.setLastName(lastName); person.setFirstName(firstName); person.setAge(age); person.setFamilyId(familyId); return person; } // getter/setterは省略 }
Serviceクラス。
src/main/java/org/littlewings/spring/dbrider/PersonService.java
package org.littlewings.spring.dbrider; import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Map; @Service @Transactional public class PersonService { NamedParameterJdbcTemplate jdbcTemplate; public PersonService(NamedParameterJdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } public void addFamily(Family family, List<Person> people) { addFamily(family); addPeople(people); } public void addPeople(List<Person> people) { people.forEach(this::addPerson); } public void addFamily(Family family) { jdbcTemplate.update(""" insert into family(id, name) values(:id, :name)""", new BeanPropertySqlParameterSource(family)); } public void addPerson(Person person) { jdbcTemplate.update(""" insert into person(id, last_name, first_name, age, family_id) values(:id, :lastName, :firstName, :age, :familyId)""", new BeanPropertySqlParameterSource(person)); } public int countPeopleByFamily(Integer familyId) { return jdbcTemplate.queryForObject(""" select count(*) from person where family_id = :familyId""", Map.of("familyId", familyId), Integer.class); } public int countFamily() { return jdbcTemplate.queryForObject(""" select count(*) from family""", Map.of(), Integer.class); } }
main
メソッドを持ったクラス。
src/main/java/org/littlewings/spring/dbrider/App.java
package org.littlewings.spring.dbrider; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class App { public static void main(String... args) { SpringApplication.run(App.class, args); } }
以上で準備は完了です。
テストコードを書く
では、テストコードを作成します。
src/test/java/org/littlewings/spring/dbrider/DatabaseRiderTest.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.core.api.dataset.SeedStrategy; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.transaction.annotation.Transactional; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @SpringBootTest //@com.github.database.rider.spring.api.DBRider //@com.github.database.rider.junit5.api.DBRider public class DatabaseRiderTest { @Autowired PersonService personService; @Test //@Transactional @DataSet( value = "org/littlewings/spring/dbrider/DatabaseRiderTest/load_dataSet.yml", strategy = SeedStrategy.INSERT ) @ExpectedDataSet( value = "org/littlewings/spring/dbrider/DatabaseRiderTest/expected_dataSet.yml", orderBy = "id" ) void addFamily() { List<Person> additionalIsonoPeople = List.of( Person.create(5, "磯野", "カツオ", 11, 1), Person.create(6, "磯野", "ワカメ", 9, 1), Person.create(7, "フグ田", "タラオ", 3, 1) ); personService.addPeople(additionalIsonoPeople); Family naminoFamily = Family.create(2, "波野家"); List<Person> naminoPeople = List.of( Person.create(8, "波野", "ノリスケ", 24, 2), Person.create(9, "波野", "タイコ", 22, 2), Person.create(10, "波野", "イクラ", 1, 2) ); personService.addFamily(naminoFamily, naminoPeople); assertThat(personService.countFamily()) .isEqualTo(2); assertThat(personService.countPeopleByFamily(1)) .isEqualTo(7); assertThat(personService.countPeopleByFamily(2)) .isEqualTo(3); } }
テストコード内で、Database Riderによってロードされたデータを前提としたアサーションは、テストコード内でのデータ追加を
行っています。
コメントアウトしているところは、使用するRiderモジュールによって切り替えていきます。
テスト開始時には、以下のデータセットをロードします。
src/test/resources/org/littlewings/spring/dbrider/DatabaseRiderTest/load_dataSet.yml
person: - id: 3 last_name: "フグ田" first_name: "サザエ" age: 23 family_id: 1 - id: 4 last_name: "フグ田" first_name: "マスオ" age: 32 family_id: 1
src/test/resources/org/littlewings/spring/dbrider/DatabaseRiderTest/expected_dataSet.yml
family: - id: 1 name: "磯野家" - id: 2 name: "波野家" person: - id: 1 last_name: "磯野" first_name: "波平" age: 55 family_id: 1 - id: 2 last_name: "磯野" first_name: "フネ" age: 48 family_id: 1 - id: 3 last_name: "フグ田" first_name: "サザエ" age: 23 family_id: 1 - id: 4 last_name: "フグ田" first_name: "マスオ" age: 32 family_id: 1 - id: 5 last_name: "磯野" first_name: "カツオ" age: 11 family_id: 1 - id: 6 last_name: "磯野" first_name: "ワカメ" age: 9 family_id: 1 - id: 7 last_name: "フグ田" first_name: "タラオ" age: 3 family_id: 1 - id: 8 last_name: "波野" first_name: "ノリスケ" age: 24 family_id: 2 - id: 9 last_name: "波野" first_name: "タイコ" age: 22 family_id: 2 - id: 10 last_name: "波野" first_name: "イクラ" age: 1 family_id: 2
DbUnitの設定。
src/test/resources/dbunit.yml
properties: caseSensitiveTableNames: true
データベース接続の設定は、Spring Bootの設定をものが使われます。
Rider JUnit 5モジュールを使う
最初は、Rider JUnit 5モジュールを使いましょう。
こちらを使います。
@SpringBootTest @com.github.database.rider.junit5.api.DBRider public class DatabaseRiderTest {
@Transactional
はなしでいきます。
@Test // @Transactional @DataSet( value = "org/littlewings/spring/dbrider/DatabaseRiderTest/load_dataSet.yml", strategy = SeedStrategy.INSERT ) @ExpectedDataSet( value = "org/littlewings/spring/dbrider/DatabaseRiderTest/expected_dataSet.yml", orderBy = "id" ) void addFamily() {
このテストは、問題なく成功します。
テスト終了時に、データは残ったままになります。
MySQL localhost:3306 ssl practice SQL > select * from family; +----+-----------+ | id | name | +----+-----------+ | 1 | 磯野家 | | 2 | 波野家 | +----+-----------+ 2 rows in set (0.0004 sec) MySQL localhost:3306 ssl practice SQL > select * from person; +----+-----------+--------------+-----+-----------+ | id | last_name | first_name | age | family_id | +----+-----------+--------------+-----+-----------+ | 1 | 磯野 | 波平 | 55 | 1 | | 2 | 磯野 | フネ | 48 | 1 | | 3 | フグ田 | サザエ | 23 | 1 | | 4 | フグ田 | マスオ | 32 | 1 | | 5 | 磯野 | カツオ | 11 | 1 | | 6 | 磯野 | ワカメ | 9 | 1 | | 7 | フグ田 | タラオ | 3 | 1 | | 8 | 波野 | ノリスケ | 24 | 2 | | 9 | 波野 | タイコ | 22 | 2 | | 10 | 波野 | イクラ | 1 | 2 | +----+-----------+--------------+-----+-----------+ 10 rows in set (0.0006 sec)
では、テストメソッドに@Transactional
アノテーションを付与してみます。
@Test @Transactional @DataSet( value = "org/littlewings/spring/dbrider/DatabaseRiderTest/load_dataSet.yml", strategy = SeedStrategy.INSERT ) @ExpectedDataSet( value = "org/littlewings/spring/dbrider/DatabaseRiderTest/expected_dataSet.yml", orderBy = "id" ) void addFamily() {
すると、このテストは失敗します。
2023-03-25T20:05:39.974+09:00 ERROR 32950 --- [ main] org.dbunit.assertion.DbUnitAssertBase : org.dbunit.assertion.DbComparisonFailure[row count (table=family)expected:<2>but was:<1>] row count (table=family) expected:<2> but was:<1> Comparison Failure: Expected :2 Actual :1 <Click to see difference> org.dbunit.assertion.DbComparisonFailure[row count (table=family)expected:<2>but was:<1>] at org.dbunit.assertion.DefaultFailureHandler$DefaultFailureFactory.createFailure(DefaultFailureHandler.java:323) at org.dbunit.assertion.DefaultFailureHandler.createFailure(DefaultFailureHandler.java:105) at org.dbunit.assertion.DbUnitAssertBase.compareRowCounts(DbUnitAssertBase.java:171) at org.dbunit.assertion.DbUnitAssertBase.assertWithValueComparer(DbUnitAssertBase.java:417) at org.dbunit.assertion.DbUnitAssert.assertEquals(DbUnitAssert.java:300) at org.dbunit.assertion.DbUnitAssert.assertEquals(DbUnitAssert.java:268) at org.dbunit.assertion.DbUnitAssert.assertEquals(DbUnitAssert.java:227) at org.dbunit.assertion.DbUnitAssert.assertEqualsIgnoreCols(DbUnitAssert.java:103) at com.github.database.rider.core.assertion.DataSetAssertion.assertEqualsIgnoreCols(DataSetAssertion.java:17) at com.github.database.rider.core.dataset.DataSetExecutorImpl.compareClassic(DataSetExecutorImpl.java:877) at com.github.database.rider.core.dataset.DataSetExecutorImpl.compareCurrentDataSetWith(DataSetExecutorImpl.java:835) at com.github.database.rider.core.RiderRunner.performDataSetComparison(RiderRunner.java:144) at com.github.database.rider.core.RiderRunner.runAfterTest(RiderRunner.java:63) at com.github.database.rider.junit5.DBUnitExtension.afterTestExecution(DBUnitExtension.java:79) at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeAfterTestExecutionCallbacks$9(TestMethodTestDescriptor.java:236) 〜省略〜
つまり、Rider JUnit 5モジュールを使うと、@Transactional
アノテーションとは組み合わせられないことになります。
Rider Springモジュール
次に、Rider Springモジュールと組み合わせるとどうなるかを見ていきます。
こちらを使います。
@SpringBootTest @com.github.database.rider.spring.api.DBRider public class DatabaseRiderTest {
@Transactional
はなしでいきます。
@Test // @Transactional @DataSet( value = "org/littlewings/spring/dbrider/DatabaseRiderTest/load_dataSet.yml", strategy = SeedStrategy.INSERT ) @ExpectedDataSet( value = "org/littlewings/spring/dbrider/DatabaseRiderTest/expected_dataSet.yml", orderBy = "id" ) void addFamily() {
このテストは、問題なく成功します。
テスト終了時に、データは残ったままになります。
MySQL localhost:3306 ssl practice SQL > select * from family; +----+-----------+ | id | name | +----+-----------+ | 1 | 磯野家 | | 2 | 波野家 | +----+-----------+ 2 rows in set (0.0004 sec) MySQL localhost:3306 ssl practice SQL > select * from person; +----+-----------+--------------+-----+-----------+ | id | last_name | first_name | age | family_id | +----+-----------+--------------+-----+-----------+ | 1 | 磯野 | 波平 | 55 | 1 | | 2 | 磯野 | フネ | 48 | 1 | | 3 | フグ田 | サザエ | 23 | 1 | | 4 | フグ田 | マスオ | 32 | 1 | | 5 | 磯野 | カツオ | 11 | 1 | | 6 | 磯野 | ワカメ | 9 | 1 | | 7 | フグ田 | タラオ | 3 | 1 | | 8 | 波野 | ノリスケ | 24 | 2 | | 9 | 波野 | タイコ | 22 | 2 | | 10 | 波野 | イクラ | 1 | 2 | +----+-----------+--------------+-----+-----------+ 10 rows in set (0.0006 sec)
では、テストメソッドに@Transactional
アノテーションを付与してみます。
@Test @Transactional @DataSet( value = "org/littlewings/spring/dbrider/DatabaseRiderTest/load_dataSet.yml", strategy = SeedStrategy.INSERT ) @ExpectedDataSet( value = "org/littlewings/spring/dbrider/DatabaseRiderTest/expected_dataSet.yml", orderBy = "id" ) void addFamily() {
Rider JUnit 5モジュールと異なり、このテストは成功します。
また、データはロールバックされています。
MySQL localhost:3306 ssl practice SQL > select * from family; +----+-----------+ | id | name | +----+-----------+ | 1 | 磯野家 | +----+-----------+ 1 row in set (0.0010 sec) MySQL localhost:3306 ssl practice SQL > select * from person; +----+-----------+------------+-----+-----------+ | id | last_name | first_name | age | family_id | +----+-----------+------------+-----+-----------+ | 1 | 磯野 | 波平 | 55 | 1 | | 2 | 磯野 | フネ | 48 | 1 | +----+-----------+------------+-----+-----------+ 2 rows in set (0.0012 sec)
正確には、data.sql
でロードしたものだけが残り、Database Riderおよびテストコード内で追加したデータについてはロールバック
されています。
どうしてこうなるのか?
どうしてこういう差が出るのかは、2つのモジュールの基礎になっている仕組みの差が出ています。
Rider JUnit 5モジュールは、JUnit 5のテストライフサイクルコールバックの仕組みで実現されているのでした。
JUnit 5 User Guide / Extension Model / Test Lifecycle Callbacks
実行タイミングはSpring Testの@Transactional
の実行の仕組みであるTestExecutionListener
よりも後(テストメソッド実行後は前)に
なるのですが、Spring Testの実行時に使うデータベース接続とは別の接続を使うことになります。
このため、Database Riderによるアサーション時にはテストメソッドで登録したデータは見えないのです。
これは、このエントリーを書いた時にも調べていました。
Database RiderをSpring Framework(Spring Boot)と合わせて使う - CLOVER🍀
Rider Springモジュールの場合は、@Transactional
の実行の仕組みであるTestExecutionListener
を使って実現されており、
かつ適用される順番が最も遅い(AbstractTestExecutionListener
のデフォルト値)ため@Transactional
で開始されたトランザクション内で
実行されることになります。
このため、データベース接続も同じものが使われます。
たとえば、今回のテストコードを実行する前提で以下の設定をapplication.properties
に追加すると、Rider JUnit 5モジュールを
使っている場合はDatabase Riderがデータベース接続を取得できず実行に失敗しますが、Rider Springモジュールを使っている場合は
問題なく実行できます。
spring.datasource.hikari.maximum-pool-size=1
どうしましょう?
Rider Springモジュールの中身を見ると、README.md
にはSpringRunner
向けの設計だと書かれていたものの、具体的に依存している
仕組みはTestExecutionListener
なのでこの点については問題ないのかなと思います。
ただ、Rider SpringモジュールはJUnit 5向けのモジュールではないことは間違いありません。
よって、Rider Springモジュールを使用した状態で、サンプルにあるようなJUnit 5のテストライフサイクルメソッドに対して
@DataSet
アノテーション等を利用しても効果がありません。
@BeforeAll @DataSet("emptyUsers.yml") static void beforeAll() {} @BeforeEach @DataSet("users.yml") public void setUpUsers() { }
JUnit 5向けの機能か、@Transactional
との組み合わせを重視するかが選択のポイントになりそうですね。
まとめ
Database RiderをSpring Framework(Spring Boot)と合わせて使う時に、組み合わせるのがRider JUnit 5モジュールなのか
Rider Springモジュールなのかで@Transactional
をテストメソッドに付与した時に挙動が変わることを調べてみました。
データセットを用意するやり方だと@Transactinal
でのロールバックは効いて欲しいような気もするのですが、どうなのでしょうね。