やりたいこと
外部ミドルウェア(ex: MySQL)に依存するような、いわゆるIntegration Testを並行実行したい。
問題
sbtからすべてのテストを実行すると失敗する件 - Yamashiro0217の日記などで典型的な通り、外部ミドルウェアは基本的に同時に複数のテスト実行で共有できない。
そこで、並列に実行されるスレッドの数だけ外部ミドルウェアを立ち上げて、それぞれ同時に同じ外部ミドルウェアを2スレッド以上で使わないよう注意する必要がある。
ここで問題となるのが、テスト内部へ自分の実行が並列実行のX個中I個目である、ということを取得する方法がないことで、sbtのコードを見る限りテスト実行の並列化はjava.util.concurrent.Executor
へ完全に移譲されているためそれを取得する方法はおそらくない。
並列でクラウドで実行するインフラのおかげで、以前のように遅いわけではないとしても、並列に実行した外部ミドルウェアを使って適切にテストを実行できないとしたら結局Integration Testは並列実行できない。
解決方法(のアイデア)
java.util.concurrent.Executorを使う実装が変わらなければ、テストを実行するスレッドはsbt test
実行開始時から終了時まで常に一定のはず。
テストは並列に行われるがobjectはすべてのスレッドで共有されることを利用して以下のようなコードを使う。
コード
import org.scalatest._
import scala.collection.mutable.ArrayBuffer
class ThreadIndex {
/**
* Undeletable, mutable, does not contains duplicate thread numbers.
*/
private[this] val indexedThreadNumbers: ArrayBuffer[Long] = ArrayBuffer.empty[Long]
private[this] def invariantRule: Boolean = synchronized {
indexedThreadNumbers.size == indexedThreadNumbers.toSet.size
}
def getThreadIndex: Long = {
require(invariantRule)
synchronized {
val threadId = Thread.currentThread().getId
val index = indexedThreadNumbers.indexOf(threadId)
if (index == -1) {
indexedThreadNumbers.append(threadId)
indexedThreadNumbers.size - 1
} else
index
}
}.ensuring(invariantRule)
}
object GlobalThreadIndex extends ThreadIndex
class FirstSpec extends FunSpec with Matchers {
it("最初のクラスのテスト1") {
println(s"MySQLサーバ${GlobalThreadIndex.getThreadIndex}を使用開始")
Thread.sleep(5 * 1000L)
println(s"MySQLサーバ${GlobalThreadIndex.getThreadIndex}を使用終了")
}
it("最初のクラスのテスト2") {
println(s"MySQLサーバ${GlobalThreadIndex.getThreadIndex}を使用開始")
Thread.sleep(5 * 1000L)
println(s"MySQLサーバ${GlobalThreadIndex.getThreadIndex}を使用終了")
}
}
class SecondSpec extends FunSpec with Matchers {
it("2つ目のクラスのテスト") {
println(s"MySQLサーバ${GlobalThreadIndex.getThreadIndex}を使用開始")
Thread.sleep(10 * 1000L)
println(s"MySQLサーバ${GlobalThreadIndex.getThreadIndex}を使用終了")
}
}
[info] SecondSpec:
MySQLサーバ0を使用開始
MySQLサーバ1を使用開始
MySQLサーバ1を使用終了
[info] FirstSpec:
[info] - 最初のクラスのテスト1
MySQLサーバ1を使用開始
MySQLサーバ0を使用終了
[info] - 2つ目のクラスのテスト
MySQLサーバ1を使用終了
[info] - 最初のクラスのテスト2
注意
- 以下のコードは理論上これでうまく行くはずというレベルで最低限のテストしかしていません(プロダクトコードで同様のことをしてうまく行ったらこの注意書きは削除する予定です)。
- sbt設定でforkを有効にした場合、各テストケースレベルで並列実行を有効にした場合などのテストはしていません。またテスト内でthreadをspawnした場合も同様です。ただ、非常にシンプルな機構なので自分がどのようにテストを並列実行をしているか理解しつつ使えば大丈夫なはずです。
ロックのリストでは駄目なの?
ロックを使うと毎回開放しないと行けないのが手間です。このThreadIndex形式なら一意に自分の利用できる外部ミドルウェアのIndexがわかるのが利点です。
とはいえこの手法はテストの並行実行中に使われるスレッドが変わらないことが前提であり、ExecutorContextの実装に強く依存します。その手法のほうがより確実でよいかも。