sbtのカスタムタスクに入門して、テスト失敗時に爆発音を鳴らしてみる
sbtではアプリケーションの起動やコンパイルといった予め定義済みのタスクの他に、利用者が独自のタスク「カスタムタスク」を定義することができます。
SassやJSなどのトランスパイルやAPIドキュメントの生成など、開発に付随する定型的作業はsbtのカスタムタスクにしてあげると嬉しいかもしれません。
この記事ではsbtの初学者向けに、最小構成のカスタムタスクを作ってみるところから、別のタスクの結果に応じた処理を行うカスタムタスクの作り方までをさらっとメモしていきます。
初めてのカスタムタスク
カスタムタスクを使い始めるまでに必要なことは2つです。
- カスタムタスクのキーを宣言し、タスクとして使えるキーを定義
- カスタムタスクを実装し、キーと実装を紐付ける
$ sbt new sbt/scala-seed.g8
で生成したプロジェクトをベースに、build.sbt
に手を入れてカスタムタスクを実装してみましょう。
1.カスタムタスクのキーを宣言する
まずは「現在の時刻をsbtコンソールに出力する」だけの簡単なタスクを作ってみます。
sbtのキーには、
- プロジェクトのロード時に一度だけ値が計算され、保存された結果が使用されるキーである
SettingKey
- 実行のたびに再計算される「タスク」を呼び出すキーである
TaskKey
- コマンドラインの引数を入力として受け取るタスクキーである
InputKey
の3種類があります。今回はタスクを扱いたいので、下記のようにタスクキーの宣言を行います。
import Dependencies._ // キーの宣言 lazy val sampleDateTimeTask = taskKey[Unit]("A sample task shows current date time.") lazy val root = (project in file(".")). settings( ...
.sbt
ファイル内にはScalaのコードを記載することが可能なので、val
やdef
を用いてプロジェクトの設定に必要な変数などを宣言することができます。初期化順序にまつわる問題を避けるためにlazy
にしておくのが望ましいでしょう。
なお今回は触れませんが、object
やclass
はbuild.sbtのトップレベルには定義できないので、必要な場合はproject/
の下に.scalaのソースを配置します。
タスクキーを宣言するためにはtaskKey[T]
を用います。taskKey[T]の型パラメータTはタスクの結果の型を表します。今回は文字列をコンソールに出力したいだけなので、返り値の方はUnitです。リソースの生成などを行うタスクを実装する場合はFileオブジェクトなどを返すことになるかもしれません。
引数には、そのタスクの解説文を文字列として渡します。ここで渡した文字列はタスク一覧を表示するタスクであるtasks -v
タスクなどの結果に含められます。
sbt:sbt-sandbox> tasks -v This is a list of tasks defined for the current project. It does not list the scopes the tasks are defined in; use the 'inspect' command for that. Tasks produce values. Use the 'show' command to run the task and print the resulting value. bgRun Start an application's default main class as a background job bgRunMain Start a provided main class as a background job ... sampleDateTimeTask A sample task shows current date time. ... update Resolves and optionally retrieves dependencies, producing a report.
キーの定義はbuild.sbt内のどこで書かれてもプロジェクトの設定より前に評価されますが、混乱を避けるために冒頭の方で宣言しておくと良いかもしれません。
2. カスタムタスクを実装する
キーを宣言したら次は実装を与えます。
import Dependencies._ import java.time.LocalDateTime // importを追加 lazy val sampleDateTimeTask = taskKey[Unit]("A sample task shows current date time.") lazy val root = (project in file(".")). settings( ... sampleDateTimeTask := println(LocalDateTime.now) // sampleDateTimeTaskの実装 )
.settings()
内に渡しているのはsbtのDSLで表現された「セッティング式」で、key := body
の左辺がキー、右辺がセッティング本文です。
今回は先に宣言したタスクキーに対して、右辺で実装を与えています。
ここまでできたらsbtシェルからカスタムタスクを呼び出すことができます。既にsbtが立ち上がっているならreload
でbuild.sbt
を再度ローディングし、下記を実行してみましょう。
sbt:sbt-sandbox> reload sbt:sbt-sandbox> sampleDateTimeTask 2018-12-10T11:13:19.659 sbt:sbt-sandbox> sampleDateTimeTask 2018-12-10T11:15:21.048
sbtコンソールに時刻が出力されるはずです。また実行のたびに時刻が変わることから、呼び出しのたびに再計算を行う「タスク」になっていることが分かります。 これでひとまず最小構成の簡単なタスクを定義することができました。
テストの失敗時に音を鳴らすカスタムタスクを作ってみる
次はもう少し意味のあるタスクを作ってみましょう。より失敗感が出るよう、テスト失敗時に爆発音を鳴らすboomTest
というタスクを作ってみたいと思います。
.value
でタスク間の依存を表現する
先に作った簡単なタスクとboomTest
が異なるのは、testの実行結果を何らかの手段で受け取り、その結果を元に挙動を分岐させる必要がある点です。つまりboomTest
のタスクはtest
のタスクの結果に依存する訳です。
このように「別のタスクに依存するタスク」表現する場合には.value
という特殊なメソッドを利用します。具体的な例で見てみましょう。
lazy val taskA = taskKey[Int]("Execute A and return magic number") lazy val taskB = taskKey[Unit]("Execute B") ... settings( ... taskA := { println("taskA is Done") 42 }, taskB := { taskA.value println("taskB is Done") } ) }
上記の例ではtaskA
とtaskB
という2つのカスタムタスクを定義しています。taskBはtaskAに依存するため、セッティング本文の中でtaskA.value
のようにtaskBへの依存性を表現しています。そのためそれぞれの実行結果は下記のようになります。
sbt:sbt-sandbox> taskA taskA is Done sbt:sbt-sandbox> taskB taskA is Done taskB is Done
注意が必要なのは、.value
は普通のメソッド呼び出しではなく、マクロを使ってタスク本体から「持ち上げ」るための構文である点です。上記の例だと、taskA.value
はtaskBのセッティング本文のどこに書かれていようとも、taskBの本文より先に実行されます。例えば上記のtaskBを少し改修し、
taskB := {
println("taskB is Done")
taskA.value
}
と呼び出しの順序を入れ替えたとしても、taskAの「taskA is Done」が必ず先に表示されます。
タスクの実行結果を取得する
「別のタスクに依存するタスク」が表現できるようになったので、次は別のタスクの実行結果にアクセスするようにしてみましょう。
あるタスクの実行結果には、taskX.result.value
とアクセスが可能です。この場合、.value
はsbt.Result
型のインスタンスを返します。
Resultはタスクの正常終了を表すValue
と異常終了を表すInc
からなる代数的データ型なので、パターンマッチで簡単に正常/異常時の処理を分岐させることができます。
lazy val taskA = taskKey[Int]("Execute A and return magic number") lazy val taskB = taskKey[Unit]("Execute B") ... settings( ... taskA := { println("taskA is Done") 42 }, taskB := { taskA.result.value match { case Inc(cause) => // タスクが異常終了した場合 println("oops, taskA is incomplete") throw cause case Value(value) => // タスクが正常終了した場合 println("taskA's answer is " + value) } println("taskB is Done") } )
taskAは異常終了しないので、これを実行すると下記のようになるでしょう。
sbt:sbt-sandbox> taskB taskA is Done taskA's answer is 42 taskB is Done
test
タスクの実行時には、テストに失敗すると.result.value
がInc
を返すので、これを使うとテストの失敗をフックすることができそうです。
組み合わせてテスト失敗時に音を鳴らす
実装に必要な知識が揃ったので、いよいよboomTest
を作り始めてみましょう。こんな感じのものです。
まずは失敗時に鳴らしたい音をsrc/test/resources/
以下に用意します。javax.sound
パッケージで扱いやすいよう、フォーマットはwaveかaiffが良いかと思います。私はfreesoundのこの音源を利用しました。
次にboomTest
タスクの雛形を作ります。例によってタスクキーの宣言とタスクの本体を用意します。
val boomTest = taskKey[Unit]("Execute test & play boom sound on failure") ... settings( ... boomTest := { (test in Test).result.value match { case Inc(cause) => println("boom! ☆☆☆☆☆☆☆☆☆☆☆☆☆☆☆☆☆☆☆") // 音を鳴らす処理は後ほど実装 throw cause case Value(value) => value } } )
失敗するテストを用意してboomTest
を実行すると、ちゃんと失敗がフックできていることが確認できるはずです。
sbt:sbt-sandbox> boomTest [info] HelloSpec: [info] The Hello object [info] - should say hello *** FAILED *** [info] "hello[!!!]" did not equal "hello[]" (HelloSpec.scala:7) [info] Run completed in 160 milliseconds. [info] Total number of tests run: 1 [info] Suites: completed 1, aborted 0 [info] Tests: succeeded 0, failed 1, canceled 0, ignored 0, pending 0 [info] *** 1 TEST FAILED *** boom! ☆☆☆☆☆☆☆☆☆☆☆☆☆☆☆☆☆☆☆ [error] Failed tests: [error] example.HelloSpec [error] (Test / test) sbt.TestsFailedException: Tests unsuccessful [error] Total time: 0 s, completed Dec 10, 2018 4:32:29 PM
続いて音を鳴らす処理を追加していきます。sbtには各リソースにアクセスするための便利な関数などが用意されているので、なるべくそれらを使って実装したいと思います。
... boomTest := { (test in Test).result.value match { case Inc(cause) => val audioFile = (resourceDirectory in Test).value / "boom.wav" Using.fileInputStream(audioFile) { stream => val ais = AudioSystem.getAudioInputStream(stream) val format = ais.getFormat val dataLine = new DataLine.Info(classOf[Clip], format) val clip = AudioSystem.getLine(dataLine).asInstanceOf[Clip] clip.open(ais) clip.loop(0) clip.start() while (clip.isRunning) { Thread.sleep(100) } } throw cause case Value(value) => value } }, ...
val audioFile = (resourceDirectory in Test).value / "boom.wav"
ではsrc/test/resources/
以下のwaveファイルをjava.io.Fileオブジェクトとして取得しています。resourceDirectory
以外にもsourceDirectory
やclassDirectory
など各種ディレクトリにアクセスするためのセッティングキーが用意されています。
Using.fileInputStream
はsbt.ioパッケージに用意されている関数で、Loanパターンを実現するためのものです。
ちなみにsbt.ioはライブラリとして切り出されているので、この他の各種便利な機能をアプリケーションで利用することもできます。
stream =>
移行ではAudioInputStreamからClipを生成し、ループなしで一度だけ再生をしています。
これで、テストに失敗すると爆発音が流れるはずです。
testタスク実行時に爆発音を鳴らす
ここまで来るとboomTest
の失敗時だけでなく、test
の失敗時にも爆発音を鳴らしたいですよね?なので、(test in Test)
に:=
で実装を定義し直し、testタスクをoverrideします。
...
(test in Test) := {
(test in Test).result.value match {
case Inc(cause) =>
...
一見testタスクが無限ループしてしまうように見えますが、.value
はタスク依存性を表記しているだけなので、sbtのタスクエンジンはtestタスクを一度だけ実行してくれます。
(このようなoverrideの方法はドキュメントに見つけられなかったのですが、用例はここ やここなどにあります。ドキュメントに記載があれば教えていただけるとありがたいです...)
これでtest
タスクでもテスト失敗時に音を鳴らすことができました。