これは、なにをしたくて書いたもの?
JUnitテスト実行時に、Flywayを組み合わせて使いたいなと思いまして。
よくDatabase Riderを使うので、今回はJUnit 5 ExtensionとしてDatabase Riderの実行前にFlywayマイグレーションを動かすように組み込んで
みたいと思います。
FlywayとJUnitテスト
Spring BootのようにFlywayをサポートしていて、コンテナの起動時に実行してしまうようなものを使ったらいい、では話が終わってしまうので
今回はそのような構成は対象外にします。
Flywayサポートがない状態でもテスト時に実行したい場合は?という話をテーマにしています。
まずはFlyway自体を見ると、Flyway Test Extensionsというものがあるようです。
GitHub - flyway/flyway-test-extensions
なのですが、これはSpring Frameworkと組み合わせて使うことが前提になっているようです。となると、Spring Bootを使ったらいいのではと
やっぱり話が終わってしまうので、これは除外ということで。
となると、Flywayのマイグレーションをテストの実行前に動くようにしたくなるのですが、これにはJUnit 5 Extensionを使おうかなと
思います。@BeforeEachや@BeforeAllで都度書いてもいいのですが、各テストで同じようなことをするならJUnit 5 Extensionとして
書いてもいいかなと。
JUnit 5のExtension Modelを試す - CLOVER🍀
一方で、テストデータの登録やアサーションにはDatabase Riderを使いたいので、Database Riderの実行前にFlywayマイグレーションを
行うようなサンプルがあるかどうか見てみます。
Database Riderのサンプルには、@BeforeAllのタイミングでFlywayマイグレーションを実行するものがありました。
というわけで、FlywayマイグレーションをJUnit 5 Extensionとして組み込み、@BeforeAllのタイミングで実行するようにしてみましょう。
FlywayをJavaコード内に組み込む
そういえば、自分でFlywayマイグレーションをJavaコードから実行したことがありません。Flywayのドキュメントを見てみましょう。
こちらですね。flyway-coreを使います。
API (Java) - Flyway - Product Documentation
また、データベースの種類ごとに追加のライブラリーが必要なようです。今回はMySQLを対象にしましょう。
MySQL - Flyway - Product Documentation
その他のデータベースについては、こちらからたどってください。
Supported Databases - Flyway - Product Documentation
なお、flyway-coreにはデフォルトでH2 Database、SQLite 3、Testcontainersのサポートが入っているようです。それ以外のデータベースに
ついては追加のライブラリーが必要です。
では、試していってみましょう。
環境
今回の環境はこちら。
$ java --version openjdk 21.0.3 2024-04-16 OpenJDK Runtime Environment (build 21.0.3+9-Ubuntu-1ubuntu122.04.1) OpenJDK 64-Bit Server VM (build 21.0.3+9-Ubuntu-1ubuntu122.04.1, mixed mode, sharing) $ mvn --version Apache Maven 3.9.8 (36645f6c9b5079805ea5009217e36f2cffd34256) Maven home: $HOME/.sdkman/candidates/maven/current Java version: 21.0.3, vendor: Ubuntu, runtime: /usr/lib/jvm/java-21-openjdk-amd64 Default locale: ja_JP, platform encoding: UTF-8 OS name: "linux", version: "5.15.0-117-generic", arch: "amd64", family: "unix"
MySQLは172.17.0.2で動作しているものとします。
MySQL localhost:3306 ssl practice SQL > select version(); +-----------+ | version() | +-----------+ | 8.0.39 | +-----------+ 1 row in set (0.0007 sec)
準備
Maven依存関係など。
<properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> </properties> <dependencies> <dependency> <groupId>org.seasar.doma</groupId> <artifactId>doma-core</artifactId> <version>2.61.0</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> <scope>runtime</scope> </dependency> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>3.26.3</version> <scope>test</scope> </dependency> <dependency> <groupId>com.github.database-rider</groupId> <artifactId>rider-junit5</artifactId> <version>1.44.0</version> <scope>test</scope> </dependency> <!-- <dependency> <groupId>org.flywaydb</groupId> <artifactId>flyway-core</artifactId> <version>10.16.0</version> <scope>test</scope> </dependency> --> <dependency> <groupId>org.flywaydb</groupId> <artifactId>flyway-mysql</artifactId> <version>10.16.0</version> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.13.0</version> <configuration> <source>21</source> <target>21</target> <annotationProcessorPaths> <path> <groupId>org.seasar.doma</groupId> <artifactId>doma-processor</artifactId> <version>2.61.0</version> </path> </annotationProcessorPaths> </configuration> </plugin> </plugins> </build>
データベースアクセスにはDoma 2を使うことにします。Maven Compiler PluginはDoma 2の設定ですね。
Flywayはflyway-mysqlを依存関係に追加します。flyway-coreはflyway-mysqlから推移的に追加されます。
<!-- <dependency> <groupId>org.flywaydb</groupId> <artifactId>flyway-core</artifactId> <version>10.16.0</version> <scope>test</scope> </dependency> --> <dependency> <groupId>org.flywaydb</groupId> <artifactId>flyway-mysql</artifactId> <version>10.16.0</version> <scope>test</scope> </dependency>
Database Rider。JUnit 5のサポート機能を使います。
<dependency> <groupId>com.github.database-rider</groupId> <artifactId>rider-junit5</artifactId> <version>1.44.0</version> <scope>test</scope> </dependency>
ちなみに、rider-junit5が依存しているJUnit 5が現時点で5.7.2と古かったのですが、新しくすると例外になったのでDatabase Riderが依存している
バージョンのJUnit 5を使うことにします…。
https://github.com/database-rider/database-rider/blob/1.44.0/rider-junit5/pom.xml#L13
テスト対象のコードを書く
それでは、まずはテスト対象のコードを書きましょう。
複数のテストコードがあった方がよいかなと思ったので、対象も複数にします。
お題を書籍に。エンティティ。
src/main/java/org/littlewings/databaserider/Book.java
package org.littlewings.databaserider; import org.seasar.doma.Entity; import org.seasar.doma.Id; @Entity public record Book( @Id String isbn, String title, Integer price ) { }
Doma 2ではエンティティにRecordsを使えるようです。
Dao。
src/main/java/org/littlewings/databaserider/BookDao.java
package org.littlewings.databaserider; import org.seasar.doma.*; import org.seasar.doma.jdbc.Result; import java.util.List; @Dao public interface BookDao { @Insert Result<Book> insert(Book book); @Sql("select /*%expand*/* from book where isbn = /* isbn */'dummy'") @Select Book selectByIsbn(String isbn); @Sql("select /*%expand*/* from book order by price desc") @Select List<Book> selectAllOrderByPriceDesc(); @Delete Result<Book> delete(Book book); }
人物をお題に。エンティティ。
src/main/java/org/littlewings/databaserider/Person.java
package org.littlewings.databaserider; import org.seasar.doma.Entity; import org.seasar.doma.Id; @Entity public record Person( @Id Integer id, String firstName, String lastName, Integer age ) { }
Dao。
src/main/java/org/littlewings/databaserider/PersonDao.java
package org.littlewings.databaserider; import org.seasar.doma.*; import org.seasar.doma.jdbc.Result; import java.util.List; @Dao public interface PersonDao { @Insert Result<Person> insert(Person person); @Sql("select /*%expand*/* from person where id = /* id */0") @Select Person selectById(Integer id); @Sql("select /*%expand*/* from person order by id asc") @Select List<Person> selectAllOrderByIdAsc(); @Delete Result<Person> delete(Person person); }
Doma 2のConfigクラス。データベースへの接続情報は、外部から受け取ることにしました。
src/main/java/org/littlewings/databaserider/DomaConfig.java
package org.littlewings.databaserider; import org.seasar.doma.jdbc.Config; import org.seasar.doma.jdbc.Naming; import org.seasar.doma.jdbc.dialect.Dialect; import org.seasar.doma.jdbc.dialect.MysqlDialect; import org.seasar.doma.jdbc.tx.LocalTransactionDataSource; import org.seasar.doma.jdbc.tx.LocalTransactionManager; import org.seasar.doma.jdbc.tx.TransactionManager; import javax.sql.DataSource; public class DomaConfig implements Config { private Dialect dialect; private LocalTransactionDataSource dataSource; private TransactionManager transactionManager; public static DomaConfig from(String url, String username, String password) { return new DomaConfig(url, username, password); } private DomaConfig(String url, String username, String password) { dialect = new MysqlDialect(); dataSource = new LocalTransactionDataSource(url, username, password); transactionManager = new LocalTransactionManager(dataSource.getLocalTransaction(getJdbcLogger())); } @Override public DataSource getDataSource() { return dataSource; } @Override public Dialect getDialect() { return dialect; } @Override public TransactionManager getTransactionManager() { return transactionManager; } @Override public Naming getNaming() { return Naming.SNAKE_LOWER_CASE; } }
Flywayのマイグレーションはsrc/main/resources側に置くことにします。
src/main/resources/db/migration/V1__create_book_table.sql
create table book ( isbn varchar(14), title varchar(255), price int, primary key(isbn) );
src/main/resources/db/migration/V2__create_person_table.sql
create table person ( id int, first_name varchar(255), last_name varchar(255), age int, primary key(id) );
Flywayのマイグレーションの配置先は、デフォルトでクラスパス上のdb/migrationのようなのでそれに合わせたいと思います。
Configuration - flyway-core 10.16.0 javadoc
テストコードを書く
では、テストコードを書いていきます。
まずはDatabase Riderの設定ファイル。
src/test/resources/dbunit.yml
cacheConnection: false properties: caseSensitiveTableNames: true connectionConfig: url: "jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&connectionCollation=utf8mb4_0900_bin" user: "kazuhira" password: "password"
src/test/resources/dataset/bookSetup.yml
book: - isbn: "978-4798161488" title: "MySQL徹底入門 第4版 MySQL 8.0対応" price: 4180 - isbn: "978-4297141844" title: "MySQL運用・管理[実践]入門 〜安全かつ高速にデータを扱う内部構造・動作原理を学ぶ" price: 3080 - isbn: "978-4621303252" title: "Effective Java 第3版" price: 4400
src/test/resources/dataset/bookExpected.yml
book: - isbn: "978-4798161488" title: "MySQL徹底入門 第4版 MySQL 8.0対応" price: 4180 - isbn: "978-4297141844" title: "MySQL運用・管理[実践]入門 〜安全かつ高速にデータを扱う内部構造・動作原理を学ぶ" price: 3080 - isbn: "978-4621303252" title: "Effective Java 第3版" price: 4400 - isbn: "978-4839981723" title: "単体テストの考え方/使い方" price: 4488
こちらを使ったテストコード。
src/test/java/org/littlewings/databaserider/BookDaoTest.java
package org.littlewings.databaserider; import com.github.database.rider.core.api.dataset.DataSet; import com.github.database.rider.core.api.dataset.ExpectedDataSet; import com.github.database.rider.junit5.api.DBRider; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @DBRider class BookDaoTest { private DomaConfig config = DomaConfig.from( "jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&connectionCollation=utf8mb4_0900_bin", "kazuhira", "password" ); private BookDao bookDao = new BookDaoImpl(config); @Test @DataSet("dataset/bookSetup.yml") void select() { config .getTransactionManager() .required(() -> { Book book = bookDao.selectByIsbn("978-4798161488"); assertThat(book.title()).isEqualTo("MySQL徹底入門 第4版 MySQL 8.0対応"); assertThat(bookDao.selectAllOrderByPriceDesc()).hasSize(3); }); } @Test @DataSet("dataset/bookSetup.yml") @ExpectedDataSet(value = "dataset/bookExpected.yml", orderBy = "isbn") void insert() { config .getTransactionManager() .required(() -> { Book book = new Book( "978-4839981723", "単体テストの考え方/使い方", 4488 ); bookDao.insert(book); }); } }
複数のテストメソッドがあった方がいいかなと思ったくらいで、実装自体はとても単純です。
データベースの接続情報をハードコードしているのは、今回は置いておきます…。
private DomaConfig config = DomaConfig.from( "jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&connectionCollation=utf8mb4_0900_bin", "kazuhira", "password" );
で、このテスト実行前にFlywayマイグレーションを実行したいところです。またDatabase Riderと同じ接続情報を使いたいところです。
というわけで、今回はこんなJUnit 5 Extensionを作成。@BeforeAllのタイミングでFlywayマイグレーションを実行します。
src/test/java/org/littlewings/databaserider/FlywayExtension.java
package org.littlewings.databaserider; import com.github.database.rider.core.configuration.ConnectionConfig; import com.github.database.rider.core.configuration.DBUnitConfig; import org.flywaydb.core.Flyway; import org.junit.jupiter.api.extension.BeforeAllCallback; import org.junit.jupiter.api.extension.ExtensionContext; public class FlywayExtension implements BeforeAllCallback { @Override public void beforeAll(ExtensionContext context) throws Exception { DBUnitConfig dbUnitConfig = DBUnitConfig.fromGlobalConfig(); ConnectionConfig connectionConfig = dbUnitConfig.getConnectionConfig(); Flyway flyway = Flyway .configure() .dataSource(connectionConfig.getUrl(), connectionConfig.getUser(), connectionConfig.getPassword()) .load(); flyway.migrate(); } }
Database Riderの設定情報は、DBUnitConfigから取得しています。
せっかくなのでメタアノテーションも作っておきましょう。
src/test/java/org/littlewings/databaserider/FlywayMigration.java
package org.littlewings.databaserider; import org.junit.jupiter.api.extension.ExtendWith; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target({ ElementType.TYPE }) @Retention(RetentionPolicy.RUNTIME) @ExtendWith(FlywayExtension.class) public @interface FlywayMigration { }
これを先ほどのクラスに付与します。
@FlywayMigration @DBRider class BookDaoTest {
人物の方もテストを用意しましょう。
src/test/resources/dataset/personSetup.yml
person: - id: 1 first_name: "サザエ" last_name: "フグ田" age: 24 - id: 2 first_name: "マスオ" last_name: "フグ田" age: 28
src/test/resources/dataset/personExpected.yml
person: - id: 1 first_name: "サザエ" last_name: "フグ田" age: 24 - id: 2 first_name: "マスオ" last_name: "フグ田" age: 28 - id: 3 first_name: "カツオ" last_name: "磯野" age: 11
テストコード。
src/test/java/org/littlewings/databaserider/PersonDaoTest.java
package org.littlewings.databaserider; import com.github.database.rider.core.api.dataset.DataSet; import com.github.database.rider.core.api.dataset.ExpectedDataSet; import com.github.database.rider.junit5.api.DBRider; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @FlywayMigration @DBRider class PersonDaoTest { private DomaConfig config = DomaConfig.from( "jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&connectionCollation=utf8mb4_0900_bin", "kazuhira", "password" ); private PersonDao personDao = new PersonDaoImpl(config); @Test @DataSet("dataset/personSetup.yml") void select() { config .getTransactionManager() .required(() -> { Person person = personDao.selectById(1); assertThat(person.firstName()).isEqualTo("サザエ"); assertThat(person.lastName()).isEqualTo("フグ田"); assertThat(personDao.selectAllOrderByIdAsc()).hasSize(2); }); } @Test @DataSet("dataset/personSetup.yml") @ExpectedDataSet(value = "dataset/personExpected.yml", orderBy = "id") void insert() { config .getTransactionManager() .required(() -> { Person person = new Person(3, "カツオ", "磯野", 11); personDao.insert(person); }); } }
準備ができたので、テストを実行しましょう。
$ mvn test
最初に実行されたテストクラスの時にFlywayマイグレーションが実行されました。
[INFO] Running org.littlewings.databaserider.BookDaoTest 7月 28, 2024 10:23:11 午後 org.flywaydb.core.FlywayExecutor execute 情報: Database: jdbc:mysql://172.17.0.2:3306/practice (MySQL 8.0) 7月 28, 2024 10:23:11 午後 org.flywaydb.core.internal.schemahistory.JdbcTableSchemaHistory allAppliedMigrations 情報: Schema history table `practice`.`flyway_schema_history` does not exist yet 7月 28, 2024 10:23:11 午後 org.flywaydb.core.internal.command.DbValidate validate 情報: Successfully validated 2 migrations (execution time 00:00.028s) 7月 28, 2024 10:23:11 午後 org.flywaydb.core.internal.schemahistory.JdbcTableSchemaHistory create 情報: Creating Schema History table `practice`.`flyway_schema_history` ... 7月 28, 2024 10:23:12 午後 org.flywaydb.core.internal.command.DbMigrate migrateGroup 情報: Current version of schema `practice`: << Empty Schema >> 7月 28, 2024 10:23:12 午後 org.flywaydb.core.internal.command.DbMigrate doMigrateGroup 情報: Migrating schema `practice` to version "1 - create book table" 7月 28, 2024 10:23:12 午後 org.flywaydb.core.internal.command.DbMigrate doMigrateGroup 情報: Migrating schema `practice` to version "2 - create person table" 7月 28, 2024 10:23:12 午後 org.flywaydb.core.internal.command.DbMigrate logSummary 情報: Successfully applied 2 migrations to schema `practice`, now at version v2 (execution time 00:00.264s)
次のテストクラスの実行時には、すでにFlywayマイグレーションは実行済みなことが表示されます。
[INFO] Running org.littlewings.databaserider.PersonDaoTest 7月 28, 2024 10:23:13 午後 org.flywaydb.core.FlywayExecutor execute 情報: Database: jdbc:mysql://172.17.0.2:3306/practice (MySQL 8.0) 7月 28, 2024 10:23:13 午後 org.flywaydb.core.internal.command.DbValidate validate 情報: Successfully validated 2 migrations (execution time 00:00.010s) 7月 28, 2024 10:23:13 午後 org.flywaydb.core.internal.command.DbMigrate migrateGroup 情報: Current version of schema `practice`: 2 7月 28, 2024 10:23:13 午後 org.flywaydb.core.internal.command.DbMigrate logSummary 情報: Schema `practice` is up to date. No migration necessary.
というわけで、Database Riderの実行前にFlywayマイグレーションを実行するようなJUnit 5 Extensionを書けました。OKですね。
オマケ: Database Riderのデータベースアクセス先をテストコードから設定する
今回はFlywayのデータベースアクセス先をDatabase Riderの設定に寄せましたが、テストコードで使う接続情報をDatabase Riderに
指定したい時もあるような気がします。
こういう時はConnectionHolderというものを使えばよさそうです。
テストクラスのフィールドに定義しておくと、親クラスまで含めて探してくれるみたいですね。
Database Riderの設定ファイルからは、データベース接続先の情報をコメントアウトします。
src/test/resources/dbunit.yml
cacheConnection: false properties: caseSensitiveTableNames: true # connectionConfig: # url: "jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&connectionCollation=utf8mb4_0900_bin" # user: "kazuhira" # password: "password"
テストクラスにConnectionHolder型のフィールドを定義し、これはDoma 2のConfigからJDBCのConnectionを取得するようにします。
src/test/java/org/littlewings/databaserider/BookDaoTest.java
package org.littlewings.databaserider; import com.github.database.rider.core.api.connection.ConnectionHolder; import com.github.database.rider.core.api.dataset.DataSet; import com.github.database.rider.core.api.dataset.ExpectedDataSet; import com.github.database.rider.junit5.api.DBRider; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @FlywayMigration @DBRider class BookDaoTest { private DomaConfig config = DomaConfig.from( "jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&connectionCollation=utf8mb4_0900_bin", "kazuhira", "password" ); private ConnectionHolder connectionHolder = () -> config.getDataSource().getConnection(); private BookDao bookDao = new BookDaoImpl(config); 〜テストメソッドは省略〜 }
もうひとつのテストクラスも同様に。
src/test/java/org/littlewings/databaserider/PersonDaoTest.java
package org.littlewings.databaserider; import com.github.database.rider.core.api.connection.ConnectionHolder; import com.github.database.rider.core.api.dataset.DataSet; import com.github.database.rider.core.api.dataset.ExpectedDataSet; import com.github.database.rider.junit5.api.DBRider; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; @FlywayMigration @DBRider class PersonDaoTest { private DomaConfig config = DomaConfig.from( "jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&connectionCollation=utf8mb4_0900_bin", "kazuhira", "password" ); private ConnectionHolder connectionHolder = () -> config.getDataSource().getConnection(); private PersonDao personDao = new PersonDaoImpl(config); 〜テストメソッドは省略〜 }
Flyway用のJUnit 5 Extensionの方は…仕方がないので今回はハードコードにしました…。
src/test/java/org/littlewings/databaserider/FlywayExtension.java
package org.littlewings.databaserider; import com.github.database.rider.core.configuration.ConnectionConfig; import com.github.database.rider.core.configuration.DBUnitConfig; import org.flywaydb.core.Flyway; import org.junit.jupiter.api.extension.BeforeAllCallback; import org.junit.jupiter.api.extension.ExtensionContext; public class FlywayExtension implements BeforeAllCallback { @Override public void beforeAll(ExtensionContext context) throws Exception { /* DBUnitConfig dbUnitConfig = DBUnitConfig.fromGlobalConfig(); ConnectionConfig connectionConfig = dbUnitConfig.getConnectionConfig(); */ Flyway flyway = Flyway .configure() //.dataSource(connectionConfig.getUrl(), connectionConfig.getUser(), connectionConfig.getPassword()) .dataSource( "jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&connectionCollation=utf8mb4_0900_bin", "kazuhira", "password" ) .load(); flyway.migrate(); } }
アプリケーションやテストコードの設定から取るようにしたいですね、このあたりは。
テスト実行時にFlywayマイグレーションが実行される様子は先ほどと同じなので、省略します。
おわりに
Database Riderの実行前にFlywayマイグレーションを行うJUnit 5 Extensionを書いてみました。
Flyway自体をサポートしているフレームワークなどを使ってそちらに任せるようなことが多いような気はしますが、ちょっとした小ネタ的に
押さえておいてもいいのかな、と思います。
そういった環境以外でFlywayを使う時のアイデアのひとつとして。