背景
今開発しているプロダクトでは、開発とQAで協力しながら、テストの自動化を積極的に推進しています。自動テストの一貫として、akka-httpで実装したREST APIに対してAPIテストを書いているのですが、そのテストにはボイラープレートなコードが大量に出てきたので、それをScalaの力を使って、どうやって克服していったのか。その結果学びになった経験などをソースの履歴を振り返りながらつらつらと書いていこうと思います。
そもそもAPIテストはボイラープレートコードが多くなる
APIテストで記述するべきことは、大体以下のような感じになると思います。
// データのセットアップ
// リクエストの生成
// 送信
// レスポンスの検証
テストのパターンが増えるほど大量のボイラープレートコードが発生することが予想されますね。
局所的なボイラープレートコードの削減
APIテストの実装にあたってまず考えたことは、
- QAの人も書きやすいようにテストコードをScalaでタイプセーフな形で書けるようにしたい。
- ある程度抽象化したAPIを用意することで、なるべく記述する量を少なくしたい。
ということでした。
データセットアップをタイプセーフに行えるようにする
DBアクセスには、ScalikeJDBCを利用しており、当初はこんなコードでした。
Seq(
sql"INSERT IGNORE INTO AccountPeriod(accountPeriodId,startDate,endDate) VALUES (2,'2017-04-01','2018-03-31')",
sql"INSERT IGNORE INTO AccountingMonth(accountingMonthId,accountPeriodId,month,monthType,startDate,endDate) VALUES (1,2,4,0,'2017-04-01','2017-04-30')",
sql"INSERT IGNORE INTO AccountPeriod(accountPeriodId,startDate,endDate) VALUES (3,'2017-04-01','2018-03-31')",
sql"INSERT IGNORE INTO AccountingMonth(accountingMonthId,accountPeriodId,month,monthType,startDate,endDate) VALUES (2,3,3,4,'2018-03-01','2018-03-31')"
)
つらいですね。。特にinsert文はカラムが大量にあると死ねます。これをどうにかタイプセーフにできないものかと悩んだ末に、shapelessに行き着きました。依存型を利用したジェネリックプログラミングという形で、対象型の型クラスを用意することで、型によらない汎用的な処理を記述できます。当時一緒に働いていたScalaおじさん曰く「ケースクラスをペロペロすることができるよ」ということです。その結果上記のコードは以下のように、型安全になりました。
save(accountPeriod(accountperiodid = 1, status = 0))
save(accountingMonth(accountingmonthid = 1, accountperiodid = 1, month = 4))
save(accountPeriod(accountperiodid = 2, status = 1))
save(accountingMonth(accountingmonthid = 2, accountperiodid = 2, month = 4))
これでリファクタリングも楽にできるし、IDEのショートカットも利用できます。
特に値を指定しない場合はデフォルト値でinsertもできるようになりました。
ちなみに、saveの裏側ではshapelessの黒魔術が効いているので、慣れていないと理解し難いですが。。
def save[R, L <: HList, L1 <: HList, L2 <: HList](r: R)(
implicit
tr: TestRecord[R],
gen: LabelledGeneric.Aux[R, L],
mapper: hlist.Mapper.Aux[toNamedParam.type, L, L1],
toList: ToTraversable.Aux[L1, List, (String, ParameterBinder)],
mapper2: hlist.Mapper.Aux[toEqCondition.type, L, L2],
toList2: ToTraversable.Aux[L2, List, (String, SQLSyntax)]
): Unit = {
...
}
APIとの通信をタイプセーフに行えるようにする
APIとの通信は、極論curlを叩けばできますが、リクエストボディの生成や結果のアサートをやったりするには、やはり型があると便利です。今回はRESTクライアントとして、サーバサイドの実装に合わせてakka-httpのクライアントAPIを利用して実装しました。akka-httpのクライアントを利用するには、型クラスのインスタンスとして、
- リクエストボディの型Iに対して、Marshaller[I, RequestEntity]を用意する
- レスポンスボディの型Oに対して、Unmarchaller[RequestEntity, O]を用意する
必要があります。これをAPIのすべての入出力の型についてやると日が暮れてしまいそうなので、自動で用意して欲しいところです。これもScalaのすばらしいJSONライブラリCirceの力を借りることで実現できます。Circeのgenericモジュールでは、shapelessを使ってケースクラスのエンコード、デコードの型クラスを自動で解決してくれます。さらにakka-httpの連携用のライブラリakka-http-jsonを利用するとcirceとakka-httpを結合してくれます。結果以下のtraitを継承することで、Marshaller, Unmarshallerのインスタンスを自動で導出できるようになりました。
import de.heikoseeberger.akkahttpcirce.FailFastCirceSupport
import io.circe.generic.AutoDerivation
trait IntegrationTestSupport
...
with FailFastCirceSupport
with AutoDerivation
...
テストコードの記述は、以下のようにAPIのインターフェースとなる型を使いまわすことでタイプセーフに実装できるようになりました。
post(
path = baseUrl("1"),
input = input(
accountingPeriodId = 1,
issuedMonth = 4,
issuedDay = 1,
drAccountId = 1,
crAccountId = 1,
)
) { (res, out: JournalPostOutput) =>
res.status shouldEqual StatusCodes.Created
out shouldEqual JournalPostOutput()
}
APIテストが実装の型に依存するのはどうなの?っていう議論はあると思いますが、APIのインターフェースの型なのでまぁいいかなと割り切っています。
ここでぶち当たった壁
上記の結果、1つのテストケースはタイプセーフにコンパクトな記述ができるようになりました。しかしながら、APIのテストケースが増えていくに従って(だいたい、120ケースくらい)、コンパイル時間もうなぎのぼりに上昇していきました。。手元のハイスペックなMacbookProでも6,7分ほどかかり、QAの方のそれなりのスペックなPCでは、10分以上かかるようになっていったのです/(^o^)\。しばらくは様子を見ながら、不要なテストケースをコメントアウトしたりなど、不毛な努力をしていたのですが、流石にどうにかしないとなぁという感じになってきました。
APIテスト全体としてのボイラープレートコードに気づく
Scalaでは、implicit探索の多用やマクロの多用は、コンパイルを遅くする要因ですよね。今回はshapeless+Circeの部分でのimplicit探索のコストがかなり大きかったようです。黒魔術の代償を身をもって経験したのでした。。
さて、抜本的にAPIテストの作り方を見直す必要があるのでしょうか。。「ここはもはやリフレクショ...」という悪魔のささやきが頭をよぎったのですが、どうにか思いとどまりました。
そこで、改めてAPIテストを見返してみると、実はAPIテスト全体としてみると、ボイラープレートなコードの山だったのです。
save(accountPeriod(accountperiodid = 1, startdate = d("2018-01-01"), enddate = d("2018-12-31")))
save(accountingMonth(accountperiodid = 1, month = 2, monthtype = 0))
save(account(accountid = 1))
post(
path = baseUrl,
input = input(
accountingPeriodId = 1,
issuedMonth = 2,
issuedDay = 28,
drAccountId = 1,
crAccountId = 1,
)
) { (res, out: JournalPostOutput) =>
res.status shouldEqual StatusCodes.Created
out shouldEqual JournalPostOutput()
}
...
save(accountPeriod(accountperiodid = 1, startdate = d("2018-01-01"), enddate = d("2018-12-31")))
save(accountingMonth(accountperiodid = 1, month = 2, monthtype = 0))
save(account(accountid = 1))
post(
path = baseUrl,
input = input(
accountingPeriodId = 1,
issuedMonth = 2,
issuedDay = 29,
drAccountId = 1,
crAccountId = 1
)
) { (res, out: ErrorOutput) =>
res.status shouldEqual StatusCodes.BadRequest
out should invalidJournalIssuedDate
}
...
この辺は、QAの方が起こしたテストケースがデシジョンテーブルでまとめられていたりなど、暗黙的には現れていましたし、テストコードを書いている開発者もなんとなくは感じていました。なんか同じようなこと書いているなと。これは、テストプログラム的にはテストのフィクスチャ(データのセットアップ)が同じで、テストデータの入出力パターンが違うということになりますね。
結局やったこと
そこで、基本的にはQAの方にデシジョンテーブルベースでテストケースを起してもらい、それに忠実に従って、テストを記述するようにしました。デシジョンテーブルでテストを記述するということは、そこにはテストのコンテキストがあり、テストのフィクスチャも同一にできる(ことが多い)ので、適切な感じがします。ScalaTestのTableDrivenPropertyChecksを利用して、データ記述の柔軟性を利用してAPIテストを以下のようなコードでまとめて記述しました。
override def beforeEach(): Unit = {
save(accountPeriod(accountperiodid = 1, startdate = d("2017-04-01"), enddate = d("2018-03-31")))
}
val errorTable = Table(
("ケース", "会計期ID", "エラー"),
("No2. 会計期ID=存在しないID", "99", ("AccountingPeriodNotFound", "指定された会計期が見つかりません", "accountingPeriodId").some),
("No3. 会計期ID=マイナスのID", "-1", none),
("No4. 会計期ID=型違い", "a", none),
("No5. 会計期ID=空", "", none),
)
"Variations" - {
"Success Cases" in {
get(path = s"$baseUrl/1") { (res, out: AccountingPeriodAppQueryResult) =>
res.status shouldEqual StatusCodes.OK
out.startDate shouldEqual d("2017-04-01")
out.endDate shouldEqual d("2018-03-31")
}
}
"Error Cases" in {
forAll(errorTable) { case (caseTitle, apId, err) =>
println(caseTitle)
err match {
case Some((key, msg, field)) =>
get(path = s"$baseUrl/$apId") { (res, out: ErrorOutput) =>
res.status shouldEqual StatusCodes.NotFound
out should containsError(key = key, msg = msg.some, fields = Seq(field).some)
}
case None =>
getNoBody(path = s"$baseUrl/$apId") { res =>
res.status shouldEqual StatusCodes.NotFound
}
}
}
}
}
この例だと4つのエラーケースが1つの処理で記述できています。こうして、もともと120箇所以上あったAPIへのアクセス処理が20箇所くらいで記述できるようになったので、コンパイル時間も劇的に(1分ちょいに)減らすことができました。
まとめ
- Scala黒魔術の柔軟性はすごいけど、コンパイル遅くなるのでご利用は計画的に
- なんか同じこと書いてるななど、イケてないという直感は大事
- 全体的な最適化も重要