ScalaTestで独自のテストレポートを実装する

この記事は [Scala Advent Calendar 2017](https://qiita.com/advent-calendar/2017/scala) の12日目です。

---

テスト結果を何かしらの形でレポート出力したい、カスタマイズしたいということはよくある(?)とおもいます。  
今回はそれを[ScalaTest](http://www.scalatest.org/)で実装する方法のご紹介です。

今回紹介するソースコードは以下のリポジトリに含まれています。

https://github.com/NomadBlacky/scalatest-reporter-sample


## 手順1: `org.scalatest.Reporter` トレイトをmix-inしたクラスを定義する

- ScalaTestが `Reporter` トレイトとしてAPIを提供してくれているのでそれを利用します。
- `Reporter` トレイトは抽象メンバとして `apply` メソッドを持っています。
- コンストラクタ引数は空にしましょう。

```scala
package org.nomadblacky.scalatest_repoter_sample

import org.scalatest.Reporter
import org.scalatest.events.Event

class SampleReporter extends Reporter {
  override def apply(event: Event): Unit = ???
}
```


## 手順2: `apply` メソッドを実装する

- `apply(event: Event)` メソッドにScalaTestでの発生したイベントが流れてくる仕組みです。
  - テストの開始・成功・失敗、テストスイートの開始・終了…など
- `org.scalatest.Event` は `sealed trait` となっており、各イベントが型として定義されています。

```scala
class SampleReporter extends Reporter {
  override def apply(event: Event): Unit = event match {
    case _: RecordableEvent =>
    case _: ExceptionalEvent =>
    case _: NotificationEvent =>
    case TestStarting(ordinal, suiteName, suiteId, suiteClassName, testName, testText, formatter, location, rerunner, payload, threadName, timeStamp) =>
    case TestSucceeded(ordinal, suiteName, suiteId, suiteClassName, testName, testText, recordedEvents, duration, formatter, location, rerunner, payload, threadName, timeStamp) =>
    case TestFailed(ordinal, message, suiteName, suiteId, suiteClassName, testName, testText, recordedEvents, throwable, duration, formatter, location, rerunner, payload, threadName, timeStamp) =>
    case TestIgnored(ordinal, suiteName, suiteId, suiteClassName, testName, testText, formatter, location, payload, threadName, timeStamp) =>
    case TestPending(ordinal, suiteName, suiteId, suiteClassName, testName, testText, recordedEvents, duration, formatter, location, payload, threadName, timeStamp) =>
    case TestCanceled(ordinal, message, suiteName, suiteId, suiteClassName, testName, testText, recordedEvents, throwable, duration, formatter, location, rerunner, payload, threadName, timeStamp) =>
    case SuiteStarting(ordinal, suiteName, suiteId, suiteClassName, formatter, location, rerunner, payload, threadName, timeStamp) =>
    case SuiteCompleted(ordinal, suiteName, suiteId, suiteClassName, duration, formatter, location, rerunner, payload, threadName, timeStamp) =>
    case SuiteAborted(ordinal, message, suiteName, suiteId, suiteClassName, throwable, duration, formatter, location, rerunner, payload, threadName, timeStamp) =>
    case RunStarting(ordinal, testCount, configMap, formatter, location, payload, threadName, timeStamp) =>
    case RunCompleted(ordinal, duration, summary, formatter, location, payload, threadName, timeStamp) =>
    case RunStopped(ordinal, duration, summary, formatter, location, payload, threadName, timeStamp) =>
    case RunAborted(ordinal, message, throwable, duration, summary, formatter, location, payload, threadName, timeStamp) =>
    case InfoProvided(ordinal, message, nameInfo, throwable, formatter, location, payload, threadName, timeStamp) =>
    case AlertProvided(ordinal, message, nameInfo, throwable, formatter, location, payload, threadName, timeStamp) =>
    case NoteProvided(ordinal, message, nameInfo, throwable, formatter, location, payload, threadName, timeStamp) =>
    case MarkupProvided(ordinal, text, nameInfo, formatter, location, payload, threadName, timeStamp) =>
    case ScopeOpened(ordinal, message, nameInfo, formatter, location, payload, threadName, timeStamp) =>
    case ScopeClosed(ordinal, message, nameInfo, formatter, location, payload, threadName, timeStamp) =>
    case ScopePending(ordinal, message, nameInfo, formatter, location, payload, threadName, timeStamp) =>
    case DiscoveryStarting(ordinal, configMap, threadName, timeStamp) =>
    case DiscoveryCompleted(ordinal, duration, threadName, timeStamp) =>
  }
}
```

- ここでは `TestSucceeded` と `TestFailed` のふたつを実装しましょう

```scala
class SampleReporter extends Reporter {
  override def apply(event: Event): Unit = event match {
    case e: TestSucceeded =>
      println(s"OK: ${e.suiteName} ${e.testName} ${getLocation(e.location)}")

    case e: TestFailed =>
      println(s"NG: ${e.suiteName} ${e.testName} ${getLocation(e.location)} [${e.message}]")

    case _ => // Do Nothing
  }

  private def getLocation(location: Option[Location]): String = location
    .collect {
      case TopOfClass(clazz)                   => s"$clazz"
      case TopOfMethod(clazz, method)          => s"$clazz#$method"
      case LineInFile(lineNumber, fileName, _) => s"$fileName$$L$lineNumber"
    }
    .getOrElse("(Unknown)")
}
```

- `Event` のフィールドからテストの情報を取得できます。


## 手順3: テスト時に実装したReporterを使うよう `build.sbt` を書き換える

- ScalaTestに実装したクラスを使うよう教えてあげます。
- `build.sbt` の適当な箇所に以下を追記します。
  - ScalaTestのテストランナーに `-C` オプションでReporterを渡しています。
  - その他のオプションに関しては[こちら](http://www.scalatest.org/user_guide/using_the_runner)を参照してください。

```
testOptions in Test ++= Seq(
  Tests.Argument(TestFrameworks.ScalaTest, "-C", "org.nomadblacky.scalatest_repoter_sample.SampleReporter")
)
```


## 手順4: テストを実行する

- 準備が整ったのでテストを実行してみましょう。
- てきとうなテストを用意します。

```scala
package org.nomadblacky.scalatest_repoter_sample

import org.scalatest._

class SampleTest extends FunSuite with Matchers {

  test("Success01") {
    1 + 1 shouldBe 2
  }

  test("Success02") {
    1 - 1 shouldBe 0
  }

  test("Fail01") {
    1 / 0 shouldBe Double.NaN
  }

  test("Success03") {
    1 * 2 shouldBe 2
  }

  test("Fail02") {
    val maybe: Option[Int] = None
    maybe.get shouldBe 0
  }
}
```

- 成功するテスト、失敗するテストをそれぞれ用意しました。
- `sbt test` を叩きましょう。

```
$ sbt test
(前略)
[info] Done compiling.
OK: SampleTest Success01 SampleTest.scala$L7
OK: SampleTest Success02 SampleTest.scala$L11
NG: SampleTest Fail01 (Unknown) [/ by zero]
OK: SampleTest Success03 SampleTest.scala$L19
NG: SampleTest Fail02 (Unknown) [None.get]
[info] Run completed in 704 milliseconds.
[info] Total number of tests run: 5
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 3, failed 2, canceled 0, ignored 0, pending 0
[info] *** 2 TESTS FAILED ***
[error] Failed tests:
[error]         org.nomadblacky.scalatest_repoter_sample.SampleTest
[error] (test:test) sbt.TestsFailedException: Tests unsuccessful
[error] Total time: 9 s, completed 2017/12/10 19:13:08
```

- 期待する結果が得られました!
  - (失敗したテストのLocationが上手く取れてないみたいですが…)
- あとは自分好みに実装してあげましょう!


## 余談

https://github.com/NomadBlacky/scala_samples

- こちらのREADME.mdを同様の方法で出力しています。
  - [実装はこんな感じ](https://github.com/NomadBlacky/scala_samples/blob/master/src/test/scala/org/nomadblacky/scala/reporter/TableOfContentsReporter.scala)
- sbtの[testListeners](http://www.scala-sbt.org/1.0/docs/Testing.html#Using+Extensions)でも似たようなことができるかも?(よく調べてない)