Septeni Engineer's Blog

セプテーニ・オリジナルのエンジニアが綴る技術ブログ

Play Framework Evolutionsで特定リビジョンまでのDownsとUpsをテストする

こんにちは。今年もクリぼっち予定の張沢です。

本記事はScala Advent Calendar 2018 13日目の記事です。
先日はshowmantさんの「circeをつかったバリデーションの実装」でした。
明日はnacikaさんの「AkkaのMailboxを自作する」です。

今回はPlay FrameworkのDatabaseマイグレーションツールであるEvolutionsでDownsとUpsをテストする方法について書きます。テストライブラリはScalaTestを使用します*1

この記事では分からないこと(説明しないこと)

テスト方針について

現時点ですべての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.sql2.sqlのファイル名部分の数値が入ります*3

apply_scriptrevert_scriptにはそれぞれSQLファイルに書かれたUpsとDownsのSQLが格納されています。hashにはこれらのscriptから計算したhash値が格納されていて、UpsやDownsのSQLの変更を検知するために使用されます。

stateにはRevisionごとの適用状態が格納されます。格納される値は以下の3種類です。

  • applied
    適用が正常に終了した
  • applying_up
    Upsの適用中、または適用中にエラーが発生した
  • applying_down
    Downsの適用中、または適用中にエラーが発生した

エラーが発生した場合、last_problemに詳細な理由や情報が格納されていることがあります。

実際のテストコード

Evolutionsの内部仕様を踏まえて、テストを書きます。

  1. テスト前にEvolutionsを実行してDBを最新の状態にする*4
  2. play_evolutionsテーブルからUps/DownsのScriptを取得する
  3. 特定RevisionまでのDownsを最新から順に適用する
  4. 特定RevisionからのUpsを最新まで順に適用する

play_evolutionsテーブルからSQL(Script)を取得するメソッドはEvolutionsApiにも定義されていますが、privateになっていて呼び出せないため、同じような処理を自分で定義する必要があります。今回はテストで余計なライブラリ依存が発生しないようにJDBCをそのまま使用しています。

今回の成果物はすべてGitHubに上げていますので、詳細が気になる方は以下のリンクからご参照ください。

github.com

テストコードのみ以下に抜粋します。

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のテスト方法について紹介させていただきました。それではよいクリスマスとお年を!

参考リンク

Evolutions - 2.6.x

ScalaTestingWithDatabases - 2.6.x

*1:Specs2でも内容は大きく変わらないと思いますが

*2:あるRevisionのUpsで不要になったテーブルを削除してしまった結果、別のRevisionのDownsでテーブル削除に失敗するなど

*3:この仕様のためにSQLのファイル名に数値しか使用できないのでしょうね…

*4:最新のRevisionまでは正常に適用される前提とします