こんにちは。今年もクリぼっち予定の張沢です。
本記事はScala Advent Calendar 2018 13日目の記事です。
先日はshowmantさんの「circeをつかったバリデーションの実装」でした。
明日はnacikaさんの「AkkaのMailboxを自作する」です。
今回はPlay FrameworkのDatabaseマイグレーションツールであるEvolutionsでDownsとUpsをテストする方法について書きます。テストライブラリはScalaTestを使用します*1。
この記事では分からないこと(説明しないこと)
- Databaseマイグレーションツールについて
- Evolutionsの設定方法やマイグレーションSQL(Ups/Downs Script)の書き方
- Play FrameworkやScalaTestに関する基本的なこと
テスト方針について
現時点ですべてのDownsが正常に動作する環境で開発中の皆さま、おめでとうございます、テストではEvolutionsの適用とクリーンアップが成功することを確認するだけで大丈夫です。以降はこのテストが壊れないようにマイグレーションSQLを保守するだけですね。
class EvolutionsSpecs extends PlaySpec with GuiceOneAppPerTest { import EvolutionsSpecs._ "Evolutions" should { "適用とクリーンアップが成功する" in { val dbApi = app.injector.instanceOf[DBApi] val db = dbApi.database(DatabaseName) try { // すべてのUpsが実行される Evolutions.applyEvolutions(db) // すべてのDownsが実行される Evolutions.cleanupEvolutions(db) succeed } catch { case e: InconsistentDatabase => // Evolutionsの実行中にエラーが発生した fail(s"${e.subTitle()} ${e.content()}", e) } } } } object EvolutionsSpecs { val DatabaseName: String = "default" }
ただ、なにかしらの悲しい事情により特定RevisionまでしかDownsが成功しない環境の場合*2、せめて成功するRevisionまでDownsが実行でき、再度Upsで最新の状態にできることだけはテストしたい、ということがあるかもしれません。
Evolutions自体に特定Revisionまで巻き戻す機能は存在しませんが、EvolutionsApiには指定したRevisionのUps/DownsのScriptを実行するメソッドが存在しますので、これを利用します。
その前に少しだけEvolutionsの内部仕様を見てみましょう。
Evolutionsの内部仕様について
Evolutionsは対象のDBにplay_evolutions
というテーブルを自動的に作成し、マイグレーションSQLやマイグレーション適用状態の管理に使用しています。
mysql> DESCRIBE play_evolutions; +---------------+--------------+------+-----+---------+-------+ | Field | Type | Null | Key | Default | Extra | +---------------+--------------+------+-----+---------+-------+ | id | int(11) | NO | PRI | NULL | | | hash | varchar(255) | NO | | NULL | | | applied_at | timestamp | NO | | NULL | | | apply_script | mediumtext | YES | | NULL | | | revert_script | mediumtext | YES | | NULL | | | state | varchar(255) | YES | | NULL | | | last_problem | mediumtext | YES | | NULL | | +---------------+--------------+------+-----+---------+-------+ 7 rows in set (0.00 sec)
id
にはマイグレーションSQLのRevision番号が入ります。具体的には1.sql
や2.sql
のファイル名部分の数値が入ります*3。
apply_script
とrevert_script
にはそれぞれSQLファイルに書かれたUpsとDownsのSQLが格納されています。hash
にはこれらのscriptから計算したhash値が格納されていて、UpsやDownsのSQLの変更を検知するために使用されます。
state
にはRevisionごとの適用状態が格納されます。格納される値は以下の3種類です。
- applied
適用が正常に終了した - applying_up
Upsの適用中、または適用中にエラーが発生した - applying_down
Downsの適用中、または適用中にエラーが発生した
エラーが発生した場合、last_problem
に詳細な理由や情報が格納されていることがあります。
実際のテストコード
Evolutionsの内部仕様を踏まえて、テストを書きます。
- テスト前にEvolutionsを実行してDBを最新の状態にする*4
play_evolutions
テーブルからUps/DownsのScriptを取得する- 特定RevisionまでのDownsを最新から順に適用する
- 特定RevisionからのUpsを最新まで順に適用する
play_evolutions
テーブルからSQL(Script)を取得するメソッドはEvolutionsApi
にも定義されていますが、private
になっていて呼び出せないため、同じような処理を自分で定義する必要があります。今回はテストで余計なライブラリ依存が発生しないようにJDBCをそのまま使用しています。
今回の成果物はすべてGitHubに上げていますので、詳細が気になる方は以下のリンクからご参照ください。
テストコードのみ以下に抜粋します。
class EvolutionsSpecs extends PlaySpec with GuiceOneAppPerSuite with BeforeAndAfter { import EvolutionsSpecs._ before { val dbApi = app.injector.instanceOf[DBApi] val db = dbApi.database(DatabaseName) Evolutions.applyEvolutions(db) } "Evolutions" should { s"$BaseRevision.sql以降であればDownsとUpsが成功する" in { val dbApi = app.injector.instanceOf[DBApi] val evolutionsApi = app.injector.instanceOf[EvolutionsApi] val db = dbApi.database(DatabaseName) try { // テストするRevisionまでのEvolutions(Ups/Downs)をplay_evolutionsテーブルから取得 val evolutions = db.withConnection(autocommit = false)(loadEvolutions(BaseRevision)) assert(evolutions.nonEmpty) // 最新のRevisionから順にDownsを実行する val downs = evolutions.reverse.map(e => DownScript(e)) evolutionsApi.evolve(DatabaseName, downs, autocommit = false, schema = "") // DownsしたRevisionから最新RevisionまでのUpsを実行する val ups = evolutions.map(e => UpScript(e)) evolutionsApi.evolve(DatabaseName, ups, autocommit = false, schema = "") succeed } catch { case e: InconsistentDatabase => fail(s"${e.subTitle()} ${e.content()}", e) } } } } object EvolutionsSpecs { val DatabaseName: String = "default" val BaseRevision: Int = 2 def loadEvolutions(fromRevision: Int): Connection => Seq[Evolution] = { c => val statement = c.prepareStatement( """SELECT id, apply_script, revert_script |FROM play_evolutions |WHERE id >= ? |ORDER BY id""".stripMargin ) statement.setInt(1, fromRevision) val resultSet = statement.executeQuery() Iterator .iterate(resultSet)(identity) .takeWhile(_.next()) .map { rs => Evolution( rs.getInt("id"), rs.getString("apply_script"), rs.getString("revert_script") ) } .toList } }
README.mdに書かれた手順で実行するとテストが成功することを確認できるかと思います。
ただし、他にもDBを参照するテストがあった場合、Evolutionsのテストと同じDBで実行してしまうと、Evolutionsのテスト失敗時に他のテストも失敗する恐れがありますので、それぞれ別のDBで実行するなどの工夫が必要かと思います。
例えばScalaTestであれば、fakeApplication()
でテスト用のApplication生成時にDB接続設定を上書きするなどの方法が考えられます。
class EvolutionsSpecs extends PlaySpec with GuiceOneAppPerSuite with BeforeAndAfter { import EvolutionsSpecs._ override def fakeApplication(): Application = new GuiceApplicationBuilder() .configure( "db.default.url" -> "jdbc:mysql://localhost:3306/evolutions_test?characterEncoding=UTF8&useSSL=false", "db.default.username" -> "root", "db.default.password" -> "password" ) .build() // ... }
今回はEvolutionsのテスト方法について紹介させていただきました。それではよいクリスマスとお年を!