Candy, Vitamin or Painkiller

He's half man and half machine rides the metal monster

sbtのカスタムタスクに入門して、テスト失敗時に爆発音を鳴らしてみる

sbtではアプリケーションの起動やコンパイルといった予め定義済みのタスクの他に、利用者が独自のタスク「カスタムタスク」を定義することができます。
SassやJSなどのトランスパイルやAPIドキュメントの生成など、開発に付随する定型的作業はsbtのカスタムタスクにしてあげると嬉しいかもしれません。
この記事ではsbtの初学者向けに、最小構成のカスタムタスクを作ってみるところから、別のタスクの結果に応じた処理を行うカスタムタスクの作り方までをさらっとメモしていきます。

初めてのカスタムタスク

カスタムタスクを使い始めるまでに必要なことは2つです。

  1. カスタムタスクのキーを宣言し、タスクとして使えるキーを定義
  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のコードを記載することが可能なので、valdefを用いてプロジェクトの設定に必要な変数などを宣言することができます。初期化順序にまつわる問題を避けるためにlazyにしておくのが望ましいでしょう。 なお今回は触れませんが、objectclassは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が立ち上がっているならreloadbuild.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")
    }
  )
}

上記の例ではtaskAtaskBという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とアクセスが可能です。この場合、.valuesbt.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.valueIncを返すので、これを使うとテストの失敗をフックすることができそうです。

組み合わせてテスト失敗時に音を鳴らす

実装に必要な知識が揃ったので、いよいよboomTestを作り始めてみましょう。こんな感じのものです。


boom on test failed

まずは失敗時に鳴らしたい音を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以外にもsourceDirectoryclassDirectoryなど各種ディレクトリにアクセスするためのセッティングキーが用意されています。

Using.fileInputStreamはsbt.ioパッケージに用意されている関数で、Loanパターンを実現するためのものです。
ちなみにsbt.ioはライブラリとして切り出されているので、この他の各種便利な機能をアプリケーションで利用することもできます。

takezoe.hatenablog.com

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タスクでもテスト失敗時に音を鳴らすことができました。