CLOVER🍀

That was when it all began.

Database RiderをSpring Framework(Spring Boot)と合わせて使う時に、Rider JUnit 5とRider Springのどちらを使うか?

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

以前、こんなエントリーを書きました。

Database RiderをSpring Framework(Spring Boot)と合わせて使う - CLOVER🍀

Spring Framework 5以降であれば、rider-junit5を使うのがよいのだろうと思ったのですが、ちょっと気になる差異があったので確認して
みました。

Database RiderとSpring Framework

Database RiderとSpring Frameworkを組み合わせる場合、選択肢として次の2つのモジュールがあります。

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

https://github.com/database-rider/database-rider/blob/1.36.0/rider-junit5/src/main/java/com/github/database/rider/junit5/api/DBRider.java

https://github.com/database-rider/database-rider/blob/1.36.0/rider-junit5/src/main/java/com/github/database/rider/junit5/DBUnitExtension.java

Rider Springモジュールは、Spring TestのTestExecutionListenerの仕組みで実現されています。

Testing / Spring TestContext Framework / TestExecutionListener Configuration

https://github.com/database-rider/database-rider/blob/master/rider-spring/src/main/java/com/github/database/rider/spring/api/DBRider.java

https://github.com/database-rider/database-rider/blob/master/rider-spring/src/main/java/com/github/database/rider/spring/DBRiderTestExecutionListener.java

サンプルに書かれているように、こういった使い方を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
    }

https://github.com/database-rider/database-rider/blob/1.36.0/rider-examples/spring-boot-dbunit-sample/src/test/java/com/github/database/rider/springboot/SpringBootDBUnitTest.java

これなら問題ないのですが、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、アカウントはkazuhirapasswordで接続できるものと します。

Spring Bootプロジェクトを作成する

Spring Bootプロジェクトを作成します。依存関係にはjdbcmysqlを含めておきます。

$ 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

https://github.com/database-rider/database-rider/blob/1.36.0/rider-junit5/src/main/java/com/github/database/rider/junit5/api/DBRider.java

https://github.com/database-rider/database-rider/blob/1.36.0/rider-junit5/src/main/java/com/github/database/rider/junit5/DBUnitExtension.java

実行タイミングはSpring Testの@Transactionalの実行の仕組みであるTestExecutionListenerよりも後(テストメソッド実行後は前)に
なるのですが、Spring Testの実行時に使うデータベース接続とは別の接続を使うことになります。

このため、Database Riderによるアサーション時にはテストメソッドで登録したデータは見えないのです。

これは、このエントリーを書いた時にも調べていました。

Database RiderをSpring Framework(Spring Boot)と合わせて使う - CLOVER🍀

https://github.com/database-rider/database-rider/blob/1.36.0/rider-junit5/src/main/java/com/github/database/rider/junit5/jdbc/ConnectionManager.java#L51

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() {
    }

https://github.com/database-rider/database-rider/blob/1.36.0/rider-examples/spring-boot-dbunit-sample/src/test/java/com/github/database/rider/springboot/SpringBootDBUnitTest.java

JUnit 5向けの機能か、@Transactionalとの組み合わせを重視するかが選択のポイントになりそうですね。

まとめ

Database RiderをSpring Framework(Spring Boot)と合わせて使う時に、組み合わせるのがRider JUnit 5モジュールなのか
Rider Springモジュールなのかで@Transactionalをテストメソッドに付与した時に挙動が変わることを調べてみました。

データセットを用意するやり方だと@Transactinalでのロールバックは効いて欲しいような気もするのですが、どうなのでしょうね。

Spring TestのTestExecutionListenerを試す

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

タイトルどおり、Spring TestのTestExecutionListenerというものを試してみようかなと思いまして。

Spring TestのTestExecutionListener

TestExecutionListenerはSpring TestContext Frameworkの一部で、テスト実行時の規定のタイミングで動作するリスナーを設定することが
できます。

Testing / Spring TestContext Framework / TestExecutionListener Configuration

TestExecutionListener自体はインターフェースです。

TestExecutionListener (Spring Framework 6.0.7 API)

TestExecutionListenerインターフェースを実装したクラスを作成し、@TestExecutionListenersアノテーションを使って
テストクラスに対してリスナーを構成します。

Testing / Spring TestContext Framework / TestExecutionListener Configuration / Registering TestExecutionListener Implementations

TestExecutionListeners (Spring Framework 6.0.7 API)

TestExecutionListenerには適用順があり、getOrderメソッドをオーバーライドすることで制御します。

Testing / Spring TestContext Framework / TestExecutionListener Configuration / Ordering TestExecutionListener Implementations

順序の値が大きい方がより後で適用される、ということになります。

TestExecutionListenerインターフェースの実装は、デフォルトのものが用意されており、以下が適用済みになっています。

リスナークラス名 説明 order
ServletTestExecutionListener WebApplicationContext向けにServlet APIのモックを構成する 1000
DirtiesContextBeforeModesTestExecutionListener @DirtiesContextアノテーションの"before"モードを扱う 1500
ApplicationEventsTestExecutionListener ApplicationEventsのサポートを提供する 1800
DependencyInjectionTestExecutionListener テストインスタンスのDIサポートを提供する 2000
DirtiesContextTestExecutionListener @DirtiesContextアノテーションの"after"モードを扱う 3000
TransactionalTestExecutionListener @Transactionalアノテーションを付与したテストを、デフォルトでロールバックさせる 4000
SqlScriptsTestExecutionListener @Sqlアノテーションで設定されたSQLスクリプトを実行する 5000
EventPublishingTestExecutionListener テスト実行イベントをテスト用のApplicationContextに発行する 10000

Spring Testで提供される機能の中には、これらのTestExecutionListenerの実装により提供されるものもあるため、ドキュメント内に
上記のリスナー名が時々出てきます。

TestExecutionListenerの実装を作成したら、以下のように@TestExecutionListenersアノテーションで指定するのですが。

@SpringBootTest
@TestExecutionListeners(
        listeners = XxxTestExecutionListener.class
)
public class XxxTest {

これだとデフォルトで提供されているリスナーがすべて外れてしまうので、デフォルトのものも含めて全部列挙するか、デフォルトの
リスナー群とマージするかを選ぶことになります。

大抵の場合は、デフォルトのリスナー群をマージすることになるのではと思います。その方法については、以下に書いてあります。

Testing / Spring TestContext Framework / TestExecutionListener Configuration / Merging TestExecutionListener Implementations

以下のように、MergeMode#MERGE_WITH_DEFAULTSを指定します。

@SpringBootTest
@TestExecutionListeners(
    listeners = MyCustomTestExecutionListener.class,
    mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS
)
class MyTest {
    // class body...
}

なお、デフォルトはREPLACE_DEFAULTSなので、デフォルト実装とマージする場合は明示的に指定する必要があります。

ここまでTestExecutionListenerの外観について見てきましたが、どんなタイミングに処理を挟めるかを見ていませんでしたね。
これについてはJavadocを読むしかなさそうです。

TestExecutionListener (Spring Framework 6.0.7 API)

以下のメソッドがあります。引数はいずれもTestContextです。

  • beforeTestClass … テストを実行する前(テストクラスのインスタンス化の前、@BeforeAllがある場合はその前)に処理を行う
  • prepareTestInstance … テストクラスのインスタンス化後に処理を行う
  • beforeTestMethod … テストメソッドの実行前のライフサイクルのコールバックの実行前(@BeforeEachがある場合はその前)に処理を行う
  • beforeTestExecution … テストメソッドの実行前に処理を行う
  • afterTestExecution … テストメソッドの実行後に処理を行う
  • afterTestMethod … テストメソッドの実行後のライフサイクルのコールバックの実行後(@AfterEachがある場合はその後)に処理を行う
  • afterTestClass … クラス内のすべてのテストの実行後(@AfterAllがある場合はその後)に処理を行う

ここまでドキュメントを読んでみましたが、実際に動かした方が早い気がするので自分でTestExecutionListenerを作ってみましょう。

まずは各動作タイミングを確認するだけのTestExecutionListenerの実装と、テストメソッドに@Transactionalを付与してロールバック
される前に処理を行うTestExecutionListenerの実装を作ってみることにします。

環境

今回の環境は、こちら。

$ 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、アカウントはkazuhirapasswordで接続できるものと
します。

Spring Bootプロジェクトを作成する

まずはSpring Bootプロジェクトを作成します。依存関係にはjdbcmysqlを加えます。

$ curl -s https://start.spring.io/starter.tgz \
  -d bootVersion=3.0.5 \
  -d javaVersion=17 \
  -d type=maven-project \
  -d name=test-execution-listener-example \
  -d groupId=org.littlewings \
  -d artifactId=test-execution-listener-example \
  -d version=0.0.1-SNAPSHOT \
  -d packageName=org.littlewings.spring.test \
  -d dependencies=jdbc,mysql \
  -d baseDir=test-execution-listener-example | 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/test/TestExecutionListenerExampleApplication.java src/test/java/org/littlewings/spring/test/TestExecutionListenerExampleApplicationTests.java

mainメソッドを持ったクラスは再定義しておきました。

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

package org.littlewings.spring.test;

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);
    }
}

はじめてのTestExecutionListener

最初は簡単なTestExecutionListenerの実装を作成してみましょう。

依存関係にSpring JDBCがあるままだとデータベース接続設定が必要になるので、ここではいったん外してspring-boot-starter
追加しておきます。

     <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>
        <!--
       <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>

TestExecutionListenerの実装を作成するには、AbstractTestExecutionListenerクラスを継承して作るのがよいみたいです。

AbstractTestExecutionListenerクラスは、すべてのメソッドが空実装で、getOrderは最も低い値(Integer#MIN_VALUE)で実装された
クラスです。

AbstractTestExecutionListener (Spring Framework 6.0.7 API)

今回はすべてのメソッドを実装し、呼び出されたログ出力するTestExecutionListenerの実装を作成しました。

src/test/java/org/littlewings/spring/test/listener/LoggingTestExecutionListener.java

package org.littlewings.spring.test.listener;

import org.springframework.test.context.TestContext;
import org.springframework.test.context.support.AbstractTestExecutionListener;

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

    @Override
    public void beforeTestClass(TestContext testContext) throws Exception {
        log(
                testContext.getTestClass().getSimpleName(),
                "none",
                "none",
                "call, beforeTestClasses"
        );
    }

    @Override
    public void prepareTestInstance(TestContext testContext) throws Exception {
        log(
                testContext.getTestClass().getSimpleName(),
                testContext.getTestInstance().toString(),
                "none",
                "call, prepareTestInstance"
        );
    }

    @Override
    public void beforeTestMethod(TestContext testContext) throws Exception {
        log(
                testContext.getTestClass().getSimpleName(),
                testContext.getTestInstance().toString(),
                testContext.getTestMethod().getName(),
                "call, beforeTestMethod"
        );
    }

    @Override
    public void beforeTestExecution(TestContext testContext) throws Exception {
        log(
                testContext.getTestClass().getSimpleName(),
                testContext.getTestInstance().toString(),
                testContext.getTestMethod().getName(),
                "call, beforeTestExecution"
        );
    }

    @Override
    public void afterTestExecution(TestContext testContext) throws Exception {
        log(
                testContext.getTestClass().getSimpleName(),
                testContext.getTestInstance().toString(),
                testContext.getTestMethod().getName(),
                "call, afterTestExecution"
        );
    }

    @Override
    public void afterTestMethod(TestContext testContext) throws Exception {
        log(
                testContext.getTestClass().getSimpleName(),
                testContext.getTestInstance().toString(),
                testContext.getTestMethod().getName(),
                "call, afterTestMethod"
        );
    }

    @Override
    public void afterTestClass(TestContext testContext) throws Exception {
        log(
                testContext.getTestClass().getSimpleName(),
                "none",
                "none",
                "call, afterTestClass"
        );
    }

    void log(String testClassSimpleName, String testInstanceToString, String testMethodName, String message) {
        System.out.printf(
                "[%s/%s:%s] %s%n",
                testClassSimpleName,
                testInstanceToString,
                testMethodName,
                message
        );
    }
}

どのメソッドの引数もTestContextで、テスト対象のクラスやメソッド、テスト用のApplicationContextの取得ができます。

TestContext (Spring Framework 6.0.7 API)

今回はこちらを使って、テストクラスやメソッドの情報をログ出力しています。ただ、タイミングによっては取得できないものも
あるので(たとえば、beforeTestClassのタイミングだとテストクラスのインスタンスがなく、テストメソッドも決まらない)、
そういったケースでは固定値noneを出力するようにしています。

順番は、デフォルトの実装よりも後ろにくるようにしておきました。

    @Override
    public int getOrder() {
        return 20000;
    }

こちらを適用したテストクラスを作成。

src/test/java/org/littlewings/spring/test/SampleTest.java

package org.littlewings.spring.test;

import org.junit.jupiter.api.*;
import org.littlewings.spring.test.listener.LoggingTestExecutionListener;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestExecutionListeners;

@SpringBootTest
@TestExecutionListeners(
        listeners = {LoggingTestExecutionListener.class},
        mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS
)
public class SampleTest {
    public SampleTest() {
        System.out.println("new test instance");
    }

    @BeforeAll
    static void setUpAll() {
        System.out.println("before all");
    }

    @AfterAll
    static void tearDownAll() {
        System.out.println("after all");
    }

    @BeforeEach
    void setUp() {
        System.out.println("before each");
    }

    @AfterEach
    void tearDown() {
        System.out.println("after each");
    }

    @Test
    void test1() {
        System.out.println("test1");
    }

    @Test
    void test2() {
        System.out.println("test2");
    }
}

作成したTestExecutionListener@TestExecutionListenersアノテーションに指定し、デフォルトのTestExecutionListenerリスナー
実装とマージするようにしています。

@SpringBootTest
@TestExecutionListeners(
        listeners = {LoggingTestExecutionListener.class},
        mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS
)
public class SampleTest {

あとは、各種コールバックメソッドを実装し、テストメソッドを2つ実装しています。

この状態でテストを実行すると、以下のような出力になります。
※関係のある部分のみの抜粋

最初のテストメソッドの実行。

[MyTest/none:none] call, beforeTestClasses
before all
new test instance
22:45:54.406 [main] DEBUG org.springframework.test.context.support.DependencyInjectionTestExecutionListener -- Performing dependency injection for test class org.littlewings.spring.test.MyTest

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v3.0.5)


〜省略〜

[MyTest/org.littlewings.spring.test.MyTest@5d235104:none] call, prepareTestInstance
[MyTest/org.littlewings.spring.test.MyTest@5d235104:test1] call, beforeTestMethod
before each
[MyTest/org.littlewings.spring.test.MyTest@5d235104:test1] call, beforeTestExecution
test1
[MyTest/org.littlewings.spring.test.MyTest@5d235104:test1] call, afterTestExecution
after each
[MyTest/org.littlewings.spring.test.MyTest@5d235104:test1] call, afterTestMethod
new test instance
[MyTest/org.littlewings.spring.test.MyTest@c755b2:none] call, prepareTestInstance
[MyTest/org.littlewings.spring.test.MyTest@c755b2:test2] call, beforeTestMethod
before each
[MyTest/org.littlewings.spring.test.MyTest@c755b2:test2] call, beforeTestExecution
test2
[MyTest/org.littlewings.spring.test.MyTest@c755b2:test2] call, afterTestExecution
after each
[MyTest/org.littlewings.spring.test.MyTest@c755b2:test2] call, afterTestMethod
after all
[MyTest/none:none] call, afterTestClass

2つ目のテストメソッドの実行。

[SampleTest/none:none] call, beforeTestClasses
before all
new test instance
[SampleTest/org.littlewings.spring.test.SampleTest@142213d5:none] call, prepareTestInstance
[SampleTest/org.littlewings.spring.test.SampleTest@142213d5:test1] call, beforeTestMethod
before each
[SampleTest/org.littlewings.spring.test.SampleTest@142213d5:test1] call, beforeTestExecution
test1
[SampleTest/org.littlewings.spring.test.SampleTest@142213d5:test1] call, afterTestExecution
after each
[SampleTest/org.littlewings.spring.test.SampleTest@142213d5:test1] call, afterTestMethod
new test instance
[SampleTest/org.littlewings.spring.test.SampleTest@934b52f:none] call, prepareTestInstance
[SampleTest/org.littlewings.spring.test.SampleTest@934b52f:test2] call, beforeTestMethod
before each
[SampleTest/org.littlewings.spring.test.SampleTest@934b52f:test2] call, beforeTestExecution
test2
[SampleTest/org.littlewings.spring.test.SampleTest@934b52f:test2] call, afterTestExecution
after each
[SampleTest/org.littlewings.spring.test.SampleTest@934b52f:test2] call, afterTestMethod
after all
[SampleTest/none:none] call, afterTestClass

これで、実行順が確認できましたね。

@Transactionalが付与されていることを前提にしたTestExecutionListenerを作成する

次は、テストメソッドに@Transactionalアノテーションが付与されていることを前提に、トランザクションロールバックされる前に
処理を行うTestExecutionListenerの実装を作成してみようと思います。

spring-boot-starter-jdbcmysqlの依存関係を再び追加します。

 <dependencies>
        <!--
       <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter</artifactId>
       </dependency>
       -->
        <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>

テーブルは、schema.sqlで作成することにします。お題は書籍で。

src/main/resources/schema.sql

create table if not exists book(
  isbn varchar(14),
  title varchar(100),
  price int,
  primary key(isbn)
);

データベースの接続、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

logging.level.org.springframework.jdbc=DEBUG

エンティティ的なクラス。

src/main/java/org/littlewings/spring/test/Book.java

package org.littlewings.spring.test;

public class Book {
    private String isbn;
    private String title;
    private Integer price;

    public static Book create(String isbn, String title, Integer price) {
        Book book = new Book();
        book.setIsbn(isbn);
        book.setTitle(title);
        book.setPrice(price);

        return book;
    }

    // getter/setterは省略
}

Serviceクラスも作成。

src/main/java/org/littlewings/spring/test/BookService.java

package org.littlewings.spring.test;

import org.springframework.jdbc.core.BeanPropertyRowMapper;
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.Map;

@Service
@Transactional
public class BookService {
    NamedParameterJdbcTemplate jdbcTemplate;

    public BookService(NamedParameterJdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public void add(Book book) {
        jdbcTemplate.update(
                """
                        insert into book(isbn, title, price)
                        values(:isbn, :title, :price)""",
                new BeanPropertySqlParameterSource(book)
        );
    }

    public Book findByIsbn(String isbn) {
        return jdbcTemplate.queryForObject(
                """
                        select isbn, title, price
                        from book
                        where isbn = :isbn""",
                Map.of("isbn", isbn),
                new BeanPropertyRowMapper<>(Book.class)
        );
    }
}

1件登録、1件取得。今回最低限使うものだけです。

TestExecutionListenerを作成します。

src/test/java/org/littlewings/spring/test/listener/WithTestTransactionExecutionListener.java

package org.littlewings.spring.test.listener;

import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.test.context.TestContext;
import org.springframework.test.context.support.AbstractTestExecutionListener;

import java.util.Map;

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

public class WithTestTransactionExecutionListener extends AbstractTestExecutionListener {
    @Override
    public int getOrder() {
        return 30000;
    }

    @Override
    public void afterTestMethod(TestContext testContext) throws Exception {
        System.out.println("WithTestTransactionExecutionListener:afterTestMethod");

        NamedParameterJdbcTemplate jdbcTemplate =
                testContext.getApplicationContext().getBean(NamedParameterJdbcTemplate.class);

        assertThat(jdbcTemplate.queryForObject(
                """
                        select title
                        from book
                        where isbn = :isbn""",
                Map.of("isbn", "978-4798142470"),
                String.class))
                .isEqualTo("Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発");
        assertThat(jdbcTemplate.queryForObject(
                """
                        select title
                        from book
                        where isbn = :isbn""",
                Map.of("isbn", "978-4774182179"),
                String.class))
                .isEqualTo("[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ");
    }
}

順番は、少なくともTransactionalTestExecutionListenerの4000より大きい必要があります。だいぶ大きくしましたが。

    @Override
    public int getOrder() {
        return 30000;
    }

オーバーライドするメソッドは、afterTestMethodにしました。これは、TransactionalTestExecutionListenerロールバックを行うのが
afterTestMethodなのでこれに合わせています。

    @Override
    public void afterTestMethod(TestContext testContext) throws Exception {

実装としては、呼び出されていることの確認と、アサーション

    @Override
    public void afterTestMethod(TestContext testContext) throws Exception {
        System.out.println("WithTestTransactionExecutionListener:afterTestMethod");

        NamedParameterJdbcTemplate jdbcTemplate =
                testContext.getApplicationContext().getBean(NamedParameterJdbcTemplate.class);

        assertThat(jdbcTemplate.queryForObject(
                """
                        select title
                        from book
                        where isbn = :isbn""",
                Map.of("isbn", "978-4798142470"),
                String.class))
                .isEqualTo("Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発");
        assertThat(jdbcTemplate.queryForObject(
                """
                        select title
                        from book
                        where isbn = :isbn""",
                Map.of("isbn", "978-4774182179"),
                String.class))
                .isEqualTo("[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ");
    }

テストメソッド終了時にロールバックされる前であれば、コミット前のデータが見えるはずです。

テストコード。

src/test/java/org/littlewings/spring/test/TransactionalListenerTest.java

package org.littlewings.spring.test;

import org.junit.jupiter.api.Test;
import org.littlewings.spring.test.listener.WithTestTransactionExecutionListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.TestExecutionListeners;
import org.springframework.transaction.annotation.Transactional;

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

@SpringBootTest
@TestExecutionListeners(
        listeners = {WithTestTransactionExecutionListener.class},
        mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS
)
public class TransactionalListenerTest {
    @Autowired
    BookService bookService;

    @Test
    @Transactional
    void withTransaction() {
        bookService.add(Book.create("978-4798142470", "Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発", 4400));
        bookService.add(Book.create("978-4774182179", "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ", 4180));

        assertThat(bookService.findByIsbn("978-4798142470").getTitle())
                .isEqualTo("Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発");
        assertThat(bookService.findByIsbn("978-4774182179").getTitle())
                .isEqualTo("[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ");
    }
}

@Transactionalアノテーションを付与しているので、テストメソッド終了時にロールバックします。

    @Test
    @Transactional
    void withTransaction() {

この状態で、テストを実行してみましょう。

作成したTestExecutionListenerが適用されているログが出力されます。

00:49:04.546 [main] DEBUG org.springframework.test.context.support.AbstractDirtiesContextTestExecutionListener -- Before test class: class [TransactionalListenerTest], class annotated with @DirtiesContext [false] with mode [null]

Spring JDBCのログレベルをDEBUGにしているので、今回作成したTestExecutionListenerの後にロールバックされていることが
確認できます。

WithTestTransactionExecutionListener:afterTestMethod
2023-03-25T00:49:07.497+09:00 DEBUG 95328 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL query
2023-03-25T00:49:07.497+09:00 DEBUG 95328 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL statement [select title
from book
where isbn = ?]
2023-03-25T00:49:07.499+09:00 DEBUG 95328 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL query
2023-03-25T00:49:07.499+09:00 DEBUG 95328 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL statement [select title
from book
where isbn = ?]
2023-03-25T00:49:07.501+09:00 DEBUG 95328 --- [           main] o.s.jdbc.support.JdbcTransactionManager  : Initiating transaction rollback
2023-03-25T00:49:07.501+09:00 DEBUG 95328 --- [           main] o.s.jdbc.support.JdbcTransactionManager  : Rolling back JDBC transaction on Connection [HikariProxyConnection@773301025 wrapping com.mysql.cj.jdbc.ConnectionImpl@66b59b7d]
2023-03-25T00:49:07.521+09:00 DEBUG 95328 --- [           main] o.s.jdbc.support.JdbcTransactionManager  : Releasing JDBC Connection [HikariProxyConnection@773301025 wrapping com.mysql.cj.jdbc.ConnectionImpl@66b59b7d] after transaction

データも残っていませんね。

 MySQL  localhost:3306 ssl  practice  SQL > select * from book;
Empty set (0.0760 sec)

ここで試しに、TransactionalTestExecutionListenerよりも先に適用されるようにしてみましょう。

public class WithTestTransactionExecutionListener extends AbstractTestExecutionListener {
    @Override
    public int getOrder() {
        //return 30000;
        return 300;
    }

この状態でテストを実行すると、ロールバックが先に行われるためTestExecutionListener内でのアサーションに失敗します。

2023-03-25T00:52:42.730+09:00 DEBUG 95575 --- [           main] o.s.jdbc.support.JdbcTransactionManager  : Initiating transaction rollback
2023-03-25T00:52:42.730+09:00 DEBUG 95575 --- [           main] o.s.jdbc.support.JdbcTransactionManager  : Rolling back JDBC transaction on Connection [HikariProxyConnection@1972628089 wrapping com.mysql.cj.jdbc.ConnectionImpl@31c628e7]
2023-03-25T00:52:42.743+09:00 DEBUG 95575 --- [           main] o.s.jdbc.support.JdbcTransactionManager  : Releasing JDBC Connection [HikariProxyConnection@1972628089 wrapping com.mysql.cj.jdbc.ConnectionImpl@31c628e7] after transaction
WithTestTransactionExecutionListener:afterTestMethod
2023-03-25T00:52:42.745+09:00 DEBUG 95575 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL query
2023-03-25T00:52:42.745+09:00 DEBUG 95575 --- [           main] o.s.jdbc.core.JdbcTemplate               : Executing prepared SQL statement [select title
from book
where isbn = ?]
2023-03-25T00:52:42.745+09:00 DEBUG 95575 --- [           main] o.s.jdbc.datasource.DataSourceUtils      : Fetching JDBC Connection from DataSource
2023-03-25T00:52:42.747+09:00  WARN 95575 --- [           main] o.s.test.context.TestContextManager      : Caught exception while invoking 'afterTestMethod' callback on TestExecutionListener [org.littlewings.spring.test.listener.WithTestTransactionExecutionListener] for test method [void org.littlewings.spring.test.TransactionalListenerTest.withTransaction()] and test instance [org.littlewings.spring.test.TransactionalListenerTest@58687fb7]

org.springframework.dao.EmptyResultDataAccessException: Incorrect result size: expected 1, actual 0
        at org.springframework.dao.support.DataAccessUtils.nullableSingleResult(DataAccessUtils.java:97) ~[spring-tx-6.0.7.jar:6.0.7]
        at org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate.queryForObject(NamedParameterJdbcTemplate.java:244) ~[spring-jdbc-6.0.7.jar:6.0.7]
        at org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate.queryForObject(NamedParameterJdbcTemplate.java:252) ~[spring-jdbc-6.0.7.jar:6.0.7]
        at org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate.queryForObject(NamedParameterJdbcTemplate.java:268) ~[spring-jdbc-6.0.7.jar:6.0.7]
        at org.littlewings.spring.test.listener.WithTestTransactionExecutionListener.afterTestMethod(WithTestTransactionExecutionListener.java:25) ~[test-classes/:na]
        at org.springframework.test.context.TestContextManager.afterTestMethod(TestContextManager.java:440) ~[spring-test-6.0.7.jar:6.0.7]
        at org.springframework.test.context.junit.jupiter.SpringExtension.afterEach(SpringExtension.java:206) ~[spring-test-6.0.7.jar:6.0.7]

〜省略〜

これで、確認したいことはできたかなと思います。

ちなみに、実は今回の例だとオーバーライドするメソッドをafterTestExecutionにすると、TransactionalTestExecutionListenerより前に
適用されるようにしてもテストに失敗しなくなったりします。
afterTestExecutionは、afterTestMethodよりも前に動作するからですね。

今回は、適用順の確認ということで。

まとめ

Spring TestのTestExecutionListenerを試してみました。

存在を知らなかったので、Spring Frameworkでのテストの拡張方法のひとつを知る良い機会になりました。Spring Framework用には
なりますが、JUnit 5よりもより細かくライフサイクルに処理を挟み込めるので、やりたいことに応じて使い分ける感じですね。