Flywayを利用したScala開発でのテストデータ作成簡略化

どうも大曲です。

今回はScalaのプロジェクトでFlywayを活用してテストデータ作成を簡略化したお話について書きます。

背景

テストコードの中でテストケースを作成していましたが、
ただそのためのコードでSQLが乱雑してメンテが困難な部分がチラホラ増えてきました。
どこが差分があるのか?あり得るテストデータなのか(システム的にありえない可能性もある)の担保が不安でした。

実際のコード

// SQL作成
val query = Seq(
  sqlu"""
    INSERT INTO
      `media` (
        `media_id`, `site_id`, `name`, `published_type`, `version`, `identifier`, `mode`, `margin` ...)
    VALUES (
        '1', '1', 'testttt', '0', '5.1', '...
      )
  """,
  sqlu"""
    INSERT INTO
      `site` (
        `site_id`, `partner_id`, `name`, `identifier`, `url`, `price`,...)
    VALUES (
        '1', '1', 'Media用', '2', 'http://localhost', '500', '1.0', '30'...
      )
  """
)
// SQL実行
Await.result(Future.sequence(query.map(db.run)), Inf)

要件

  • テストデータ作成の負担軽減
  • 管理の負荷を減らす(目grepは辛い)
    意図的に変更している部分は明確に分かるようにしたい
  • テストコードのためのテストデータにはならないこと
    システム上ありえないデータが作られないように担保する

Flywayとは

https://flywaydb.org/

DBのマイグレーションツールです。
コマンドラインやコードからマイグレーションを実行できます。
テストコードで利用するテストデータの構築で使っています。

今回は構築だけでなく、テストデータの作成でFlywayを少し弄った使い方をしています。

Flyway利用した理由

  • ファイル読み込みから実行をFlywayが管理してもらえる
  • テストデータ用のライブラリを管理しなくて良い
    • テストデータ作成のためのライブラリを作ってメンテナンスが大変だった経験があるため
  • テストデータはたくさん必要ない
    • たくさん作れる手軽さよりもビジネスロジックを網羅したテストデータがきちんとあることが大事であると判断
    • 全てのパターンは不要でベースとなるテストデータがあるだけで十分活用されること

テストデータの設計

データの種類

  • メタデータ
    カテゴリなどのデータ
    元々、システムの運用で必須なもの(データ追加の場合はDBに直接SQLを実行することが多い)
  • サービスデータ
    サービスの機能で使われるデータ
    メディア、プロモなどこの2軸でデータが作られることが多い
  • ロジックデータ
    配信テーブル(メディアとプロモの組み合わせをするもの)
    システムのロジックで生成されるもの。
    組み合わせデータのための設定は別である
    手動で管理画面から設定する組み合わせをやるものあり
  • 社内データ
    社内のアカウント情報
  • システムデータ
    サーバの情報(DBのマスター情報とか)
    システムに必要なデータ

種類ごとでの特徴

特徴の項目

  • データ変動する可能性
    • サービスの仕様的に変動しやすいもの
    • 管理画面からたくさん作られたり書き換えが頻繁なもの
  • テストデータでの活用頻度
    • リポジトリやE2Eのテストとして活用されること
  • テストコードで変動する可能性
    • テストコードの中でテストケースの網羅のために書き換えや作成が頻繁なもの

特徴のまとめ

テスト種類 データ変動する可能性 テストデータでの活用頻度 テストコードで変動する可能性
メタデータ
サービスデータ
ロジックデータ
社内データ
システムデータ

データの生成方式

データ生成は3種類

  • DB構築のFlyway
    テーブル作成のためのFlywayと同じタイミングで作成する
  • データ作成専用のFlyway
    テーブル作成とは別でデータ作成のみのFlywayで作成する
  • テストコード内でSQL直書き
    直接テストコードにSQLを書いてデータを作成する
テスト種類 DB構築のFlyway データ作成専用のFlyway テストコード内でSQL直書き
メタデータ × ×
サービスデータ × ▲ ※1
ロジックデータ × ×
社内データ × ×
システムデータ × ×

※1
ページャーのためのデータ作成などはテストコードに直接書いて良いとしています。
そんなデータがFlywayにあってもノイズでしかないためです。

実装

ディレクトリ構成に関して

migration直下のフォルダごとでテストコードで呼び出しています。
ロジックデータが簡単に作られないようにサービスデータはメディアとキャンペーンで分けています。 (広告システムでしたので分けています。)

test
|- resources
  |- db
     |- migration
       |- 00_base(全テーブル作成とメタデータ、社内データ、システムデータ作成)
       |  |- V1__create_db.sql
       |  |- V2__meta_data.sql
       |- 10_media(サービスデータのメディアデータ作成)
       |  |- V1__media_data.sql
       |- 20_campaign(サービスデータのキャンペーンデータ作成)
          |- V1__campaign_data.sql

IDは重複しないように工夫します。
利用するIDは以下の通りです。IDだけでどこで作られたか把握できるようにします。
Flywayの「10_media」フォルダ = 100~
Flywayの「20_campaign」フォルダ = 200~
テストコードに直接 = 1000~

コード

テストコード用のFlywayのコード

import com.typesafe.config.{Config, ConfigFactory}
import slick.driver.MySQLDriver.api._
import org.flywaydb.core.Flyway

import scala.concurrent.Await
import scala.concurrent.duration.Duration

import com.typesafe.config.ConfigFactory
import slick.jdbc.JdbcBackend._

// DBのコネクション管理
trait Databases {
  implicit val db = MySQLDatabase.db
}

object MySQLDatabase {
  val config = ConfigFactory.load
  val db = Database.forConfig("db")
}

/**
  * テスト用にDBを生成する
  */
object TestMigrate extends Databases {

  private val config: Config = ConfigFactory.load()
  private val flywayBase: Flyway = Flyway.configure().dataSource(
    config.getString("db.url"),
    config.getString("db.user"),
    config.getString("db.password")
  ).locations("classpath:db/migration/00_base").load()

  private val flywayMedia: Flyway = Flyway.configure().dataSource(
    config.getString("db.url"),
    config.getString("db.user"),
    config.getString("db.password")
  ).locations("classpath:db/migration/10_media").load()

  private val flywayCampaign: Flyway = Flyway.configure().dataSource(
    config.getString("db.url"),
    config.getString("db.user"),
    config.getString("db.password")
  ).locations("classpath:db/migration/20_campaign").load()

  def base(): Unit = {
    flywayBase.clean()
    flywayBase.migrate()
  }

  def media(): Unit = {
    // 手動でmigrationのDBを削除(flyway_schema_history)することで、既存のDBを削除することなく、テスト用のデータを追加できる。
    Await.result(db.run(sqlu""" delete from flyway_schema_history"""), Duration.Inf)
    flywayMedia.migrate()
  }

  def campaign(): Unit = {
    // 手動でmigrationのDBを削除(flyway_schema_history)することで、既存のDBを削除することなく、テスト用のデータを追加できる。
    Await.result(db.run(sqlu""" delete from flyway_schema_history"""), Duration.Inf)
    flywayCampaign.migrate()
  }

}

テストコードでの呼び出し

class XXXSpecs {

  override def beforeAll(): Unit = {
    TestMigrate.base()
    TestMigrate.media()
    // メディアのみの場合はキャンペーンのメソッドは呼ばなくておk
    // TestMigrate.campaign()

    // 追加でテストデータを追加する場合は、直にSQLを書く
    val query = Seq(
      sqlu"""
             INSERT INTO
               click(`click_id`,`date`,`media_id`,...)
             VALUES
               ('1', '2019-12-01', '1', '200', '1', NULL, 'test1'...)
          """
    )
    Await.result(Future.sequence(query.map(db.run)), Inf)

  }
}

結果

冒頭で書いた要件に対しての結果をまとめます。

テストデータ作成の負担軽減

テストで活用される可能性が高いものはFlywayで実装することで楽になりました。

管理の負荷を減らす(目grepは辛い)

重要なデータ(ロジックが絡むようなフラグとか)はFlywayで管理し、
変更する場合はFlyway実行後に特定の項目のみを上書きする形になるのでどれを意図的に変更するか分かるようになりました。

ただSQLがなくなっていないので、負荷は減ったが無くなった訳ではないです。

テストコードのためのテストデータにはならないこと

全てのテストコードがテストデータがこの要件を満たすことは出来ていません。
ただFlywayで管理されているデータのみ、仕様を考慮されたデータであることを担保できると考えております。

一時的なデータ(ページャーのためのデータ、ロジックデータ)はテストコードに書く事にしてありますが
こちらは逆にガチガチにせず自由にした方がテストコードとしては書きやすいと思ったからです。

まとめ

全てのSQLがなくなるわけではないですが、これはこれで良い落とし所かなと思っています。
テストデータライブラリは自前で作成したり、factory_botを利用したことがあります。
作る側も使う側も結局はどんなSQLが作成されているのかを頭の中で解釈することが多かったですし、
データ作成が簡略化されるよりテストデータに使えるものが揃っていることが有効だなと現時点では考えているからです。

ただ、最近はチーム内で「skinny-factory-girl」を使う流れもあり
この実装は無くなるのかなぁと思ったりしてます。


 

[お知らせ]
アドウェイズエンジニアブログ公式Twitterアカウントの運用を開始しました。
ぜひフォローお願いします! @ADWAYS_ENGINEER