倭マン's BLOG

くだらない日々の日記書いてます。 たまにプログラミング関連の記事書いてます。 書いてます。

ScalaTest も『JUnit 実践入門』もまとめて相手してやんよ (8) : カスタム Matcher の作成1 ~型クラス実装編~

前回までで ScalaTest に定義済みの Matcher を見てきましたが、今回と次回で独自の検証を行う方法を見ていきます(目次)。 検証方法の拡張には大まかに分けて2つあり、1つは JUnit のように(ScalaTest の)Matcher トレイトもしくはそれに類するトレイトの実装を作成すること、もう1つは ScalaTest が提供する型クラスの実装を提供することです。 今回は型クラスの実装を行う方法を見ていきます。

型クラスの実装を行う方法では、should を使う検証文は前回まででみた定義済み Matcher と全く同じように書けます。 型クラス実装を implicit にしてスコープ内に入れておけば、SUT (検証対象オブジェクト)として渡されたオブジェクトの型によって自動的に適切な検証方法を探して実行してくれます。 Scala の「暗黙のパラメータ」の機能を使っているという方がわかりやすいですかね。

加えて、この記事では Equality トレイト、Uniformity トレイトのカスタム実装を提供して等価性の検証をカスタマイズする方法も見ていきます。 これは例えば文字列の等価性の評価で大文字小文字を区別しないようにするといった方法の拡張です。 文字列での等価性のカスタマイズ方法には ScalaTest 側で定義済みのものがいくつかありますが、それについては「ScalaTest も『JUnit 実践入門』もまとめて相手してやんよ (5) : Matcher いろいろ~各種クラス編~ # 文字列 String」を参照(以前の記事に追記しました)。

本記事の内容

参考

  • 『JUnit 実践入門』 4.3 カスタム Matcher の作成
  • ScalaTest User Guide 「Using matchers

型クラスのカスタム実装を作る

型クラスのカスタム実装を作る方法では、Scala の暗黙のパラメータ機能を使って、should を含む検証文はそのままに SUT の型によって自動的に適切な検証コードを実行してもらいます。 検証文をそのまま使えるということは逆に検証文を変更できないということだし、動作を拡張できる部分も決められているので使える場合が限られますが、まぁ、そんなに難しくないので軽く見ていきましょう。

ここでは例として、java.io.File (以下 File)オブジェクトに対して行える検証(「ScalaTest も『JUnit 実践入門』もまとめて相手してやんよ (5) : Matcher いろいろ~各種クラス編~ # ファイル File」参照)を java.nio.file.Path (以下 Path)にも使えるようにしてみます。 File に関する検証には

  • SUT should exist
  • SUT should be (readable)
  • SUT should be (writable)

というのがありました。 ついでに Path#getNameCount() メソッド*1を使って

  • SUT should have length (...)

という検証もできるようにしましょう。

以下のように PathMatchers トレイトに暗黙のオブジェクト(implicit objectPathEnabler を定義し、使用したい述語 (exist, readable, writable, length) に対応する型クラスのトレイト (Existence, Readability, Writability, Length)*2 をミックスイン、必要なメソッドを実装します:

package scalatest.tutorial.matcher.custom

import java.nio.file.{Files, Path}
import org.scalatest.enablers.{Existence, Readability, Writability, Length}

trait PathMatchers {

  implicit object PathEnabler
      extends Existence[Path] with Readability[Path] with Writability[Path] with Length[Path] {

    override def exists(path: Path): Boolean = Files.exists(path)

    override def isReadable(path: Path): Boolean = Files.isReadable(path)

    override def isWritable(path: Path): Boolean = Files.isWritable(path)

    override def lengthOf(path: Path): Long = path.getNameCount
  }
}

// import で使えるようにする
object PathMatchers extends PathMatchers

まぁ、別に PathEnabler オブジェクトに全てミックスインして実装するのではなくてそれぞれ別個に暗黙のオブジェクトを作っても構いません。 この辺はお好みで。 最後の行の PathMatcher オブジェクトは、これらの暗黙のオブジェクトをインポート文で使えるようにしたい人のために作っておきます。 Path オブジェクトに対して行いたい検証の実装が java.nio.file.Files クラスでほとんど提供されているのでラクちん。

さて、これで Path オブジェクトに対して検証を行う準備が完了。 では実際に検証してみましょう。 テストクラスに行う準備は、Matchers トレイトに加えて先ほどの PathMatchers トレイトをミックスインして(もしくはコメントアウトしてある import 文を書いて)おくだけです。

import java.nio.file.{Files, Paths}

// import scalatest.tutorial.matcher.custom.PathMatchers._
// とインポートしておくと PathMatchers トレイトをミックスインする必要はない

class PathEnablerSpec extends FlatSpec with Matchers with PathMatchers{

  "Existence" should "existで検証を行う" in {
    val path = Files.createTempFile(null, null)  // 一時ファイルを作成
    path should exist

    Files.delete(path)  // ファイルを削除
    path should not (exist)
  }

  "Readability" should "readableで検証を行う" in {
    val path = Files.createTempFile(null, null)
    path should be (readable)

    Files.delete(path)
    path should not be readable
  }

  "Writability" should "writableで検証を行う" in {
    val path = Files.createTempFile(null, null)
    path should be (writable)

    Files.delete(path)
    path should not be writable
  }

  "Length" should "have lengthで検証を行う" in {
    val path = Paths.get("/path/to/some/file")
    path should have length 4
  }
}

should を含む検証文は、SUT が Path オブジェクトであること以外は、File オブジェクトに対するものと全く同じように書けます。 嗚呼簡単!

ScalaTest で定義されている型クラス
上記 Existence のような型クラスは org.scalatest.enablers パッケージに定義されています。 それぞれどのような検証文で使用できるかと併せて列挙しておきましょう。 SUT は検証対象のオブジェクトです:

  • xxx(SUT) should ...
    • Collectingall(SUT) should ... など
  • should
    • ExistenceSUT should exist
  • should be
    • EmptinessSUT should be (empty)
    • Definition * : SUT should be (defined)
    • Readability * : SUT should be (readable)
    • Writability * : SUT should be (writable)
    • SortableSUT should be (sorted)
  • should contain
    • ContainingSUT should contain ...
    • AggregatingSUT should contain ...
    • SequencingSUT should contain
    • KeyMappingSUT should contain key ...
    • ValueMappingSUT should contain value ...
  • should have
    • Size * : SUT should have size ...
    • Length * : SUT should have length ...
    • Messaging * : SUT should have message ...

型クラスの後に * を付けてあるものは、暗黙のオブジェクトを別途実装しなくても、SUT が適当なプロパティを持てばリフレクションで呼び出してくれるものです。 例えば(唐突ですが) java.time.Year クラスには日数を返す length() メソッドが定義されているので、暗黙のオブジェクトを実装することなしに以下のような検証が行えます:

  "have length" should "lengthメソッドの返り値を検証する" in {
    Year.of(2015) should have length 365
    Year.of(2016) should have length 366
  }

そう言えば、have で JavaBeans 等のプロパティを検証する場合は丸括弧 () がいろいろ必要でしたが、have 関連の型クラスにあるプロパティ (size, length, message) の場合は付けなくても大丈夫なようです。

それぞれの型クラスがどのようなプロパティを要求しているかを表にしておきましょう:

型クラス プロパティ
Definition isDefined
Readability isReadable
Writability isWritable
Size size, getSize
Length length, getLength
Messaging message, getMessage

全部は試してませんが。 詳しくは Scaladoc 参照。

カスタム Equality、カスタム Uniformity を実装する

ScalaTest も『JUnit 実践入門』もまとめて相手してやんよ (5) : Matcher いろいろ~各種クラス編~ # 文字列 String」では、文字列の等価性の評価をカスタマイズして

"ScalaTest" should equal ("scalatest") (after being lowerCased)

によって大文字小文字の区別なく文字列が一致することを検証できるのを見ました。 以下ではこのような等価性の評価方法をカスタマイズする方法を見ていきます。

実装するトレイト Equality, Uniformity
等価性を評価するアルゴリズムを実装するのは、org.scalactic パッケージにある Equality, Uniformity トレイトです。 関連するトレイトの継承関係は次のようになっています:

  • AnyRef
    • Equivalence
      • Equality
        • NormalizingEquality
      • NormalizingEquivalence
    • Normalization
      • Uniformity

書いといてなんですが、Equivalence トレイトなどの薄色になっているものはあまり気にする必要はありません*3

Equality は2つのオブジェクトの等価性の評価アルゴリズムをそのまま実装するトレイトで、そのインスタンスを myEquality としたとき

  SUT should equal (expected) (decided by myEquality)

という風に「decided by」によって使います。

一方、Uniformity は等価性の評価の前に SUT と期待値の2つのオブジェクトそれぞれに対して行う(同じ)変換のアルゴリズムを書きます。 上記の大文字小文字の区別をなくして文字列の等価性を評価する場合、SUT と期待値の文字列をどちらも小文字に変換しているのですが、この「文字列内の文字を全て小文字にする」という変換にあたるものを書くのが Uniformity トレイトです。 このインスタンスを myUniformity とすると

  SUT should equal (expected) (after being myUniformity)

のように「after being」で使います。

Equality と Uniformity を同時に使いたい場合には

  SUT should equal (expected) (decided by myEquality afterBeing myUniformity)

のように「afterBeing」を使います。 Uniformity はさらに「and」でつないで続けられます(Uniformity だけの場合も同じ)。

サンプルコード
さて、以下では Equality と Uniformity の実装のサンプルを見ていきますが、Java8 で導入された日時 API を使ってあれこれしているので、これに不慣れだと少々サンプルコードとしては分かりにくいかも知れません。 なんてったって書いてる人間がこの API に不慣れだし(笑)。 Equality, Uniformity のシンプルなサンプルはそれぞれの Scaladoc に載ってるコードを見るのが1番かと思います。

カスタム Equality
まずは Equality のカスタム実装。 Equality のサブクラスは areEqual メソッド1つを実装するだけなので簡単。 ここでは月日が同じときに同じと評価される Equality を作ってみましょう:

  val monthDay = new Equality[TemporalAccessor] {
    override def areEqual(a: TemporalAccessor, b: Any) = b match {
      case t: TemporalAccessor => MonthDay.from(a) == MonthDay.from(t)
      case _ => false
    }
  }
  • 無名クラスで作っています。 もちろんクラス定義とインスタンス化を別に行っても構いません。
  • TemporalAccessor は年月日、時分秒などを(持っていれば)取得できるインターフェースです。 今回扱うサンプルでは ZonedDateTime と思って OK。
  • areEqual メソッドは引数の2つのオブジェクトの等価性の評価アルゴリズムを実装しますが、一方のオブジェクトの型は Any であることに注意。 型チェックなりパターンマッチングなりが必要になります。
  • MonthDay クラスは月日を表すクラスです。 このクラスの(Java オブジェクトとしての) equals メソッドは月と日が一致していれば true を返すので、まず TemporalAccessor を MonthDay#from メソッドで MonthDay オブジェクトにしてから等価性の評価をしています。

2つの引数を MonthDay オブジェクトに変換しているので Uniformity としても書けますが、そこは Equality の実装練習ということで。 さて、これを使った検証文は以下のようになります:

class EqualityMatcherSpec extends FlatSpec with Matchers{

  // 年月日はどちらも 2015/9/1
  val dtGreenwich0 = ZonedDateTime.parse("2015-09-01T00:00Z[Greenwich]")  // グリニッジ0時
  val dtTokyo0     = ZonedDateTime.parse("2015-09-01T00:00+09:00[Asia/Tokyo]")  // 東京0時

  // カスタム Equality (本当は別トレイトに定義してミックスインした方がよい)
  val monthDay = new Equality[TemporalAccessro]{ ... }

  "decided by monthDay" should "月日が等しいことを検証する" in {
    // 普通の等価性評価
    dtGreenwich0 should not equal dtTokyo0  // 等しくない

    // monthDay によって月日が等しいことを検証
    dtGreenwich0 should equal (dtTokyo0) (decided by monthDay)  // 等しい
  }
}

普通に等価性を評価すると ZonedDateTime の equals メソッドが使われますが、これは年月日、時分秒(ミリ秒・ナノ秒)、タイムゾーン全てが等しくないと等しいと評価されません。 decided by monthDay によって月日のみで等価性評価をすると、どちらも9月1日なので等しいと評価されます。

カスタム Uniformity
次は Uniformity のカスタム実装。 ここでは ZonedDateTime オブジェクトを、同じ時点 (Instant) での東京の時刻にそろえてから等価性を評価するようにしてみます。 この変換自体は ZonedDateTime#withZoneSameInstant(ZoneId) メソッドが面倒を見てくれます。 Equality と同じように Uniformity トレイトの実装クラスを作ればいいのですが、こちらは3つの抽象メソッドがあります。

  • Uniformity[A]
    • normalized(a: A): A
    • normalizedCanHandle(a: Any): Boolean
    • normalizedOrSame(a: Any): Any

変換アルゴリズムを書くのは normalized メソッドです。 外部から呼ばれるのはおそらく normalizedOrSame メソッドで、このメソッドの中から normalized (と必要なら normalizedCanHandle) メソッドを呼びます*4。 実装はこんな感じ:

  val inTokyo = new Uniformity[TemporalAccessor] {

    val tokyo = ZoneId.of("Asia/Tokyo")
    val tokyoOffset = ZoneOffset.ofHours(9)

    override def normalized(temporal: TemporalAccessor): TemporalAccessor =
      temporal match {
        case zdt: ZonedDateTime => zdt.withZoneSameInstant(tokyo)
        case odt: OffsetDateTime => odt.atZoneSameInstant(tokyo)
        case ot: OffsetTime => ot.withOffsetSameInstant(tokyoOffset)
        case _ => temporal
      }

    override def normalizedCanHandle(a: Any): Boolean = a.isInstanceOf[TemporalAccessor]

    override def normalizedOrSame(a: Any): Any = a match {
      case temporal: TemporalAccessor => normalized(temporal)
      case _ => a
    }
  }
  • 型パラメータを ZonedDateTime ではなく TemporalAccessor に風呂敷広げたせいでちょっと実装に手間がかかってます。 TemporalAccessor のサブタイプでタイムゾーンが変わると時刻が変わるクラスは ZonedDateTime 以外に OffsetDateTime, OffsetTime というのもあるので、こちらの場合の変換も行います。 変換処理は OffsetDateTime#atZoneSameInstant, OffsetTime#withOffsetSameInstant メソッドに丸投げしてますが(日本はサマータイムがないので楽)。 その他の場合は引数をそのまま返します。
  • normalizedCanHandle メソッドでは、引数のオブジェクトが TemporalAccessor オブジェクトであることを検証しています。 ただし、このメソッドは外部からは呼ばれていないようなので、必要なら normalizedOrSame メソッド内から呼ばないといけないようです。 今の場合は型チェックだけなので(normalizeOrSame 内で既にしてるので)呼んでません。

この Uniformity を使って、テストは以下のように書けます:

class EqualityMatcherSpec extends FlatSpec with Matchers{

  // 年月日はどれも 2015/9/1
  val dtGreenwich0 = ZonedDateTime.parse("2015-09-01T00:00Z[Greenwich]")  // グリニッジ0時
  val dtTokyo0     = ZonedDateTime.parse("2015-09-01T00:00+09:00[Asia/Tokyo]")  // 東京0時
  val dtTokyo9     = ZonedDateTime.parse("2015-09-01T09:00+09:00[Asia/Tokyo]")  // 東京9時

  // カスタム Uniformity
  val inTokyo = new Uniformity[TemporalAccessor] { ... }

  "after being inTokyo" should "東京での時刻で比べる" in {
    // 異なるタイムゾーンで同じローカル時刻
    dtTokyo0 shouldNot equal (dtGreenwich0) (after being inTokyo)  // 等しくない

    // 同じ時点 (Instant)
    dtTokyo9 shouldNot equal (dtGreenwich0)  // 別のタイムゾーンでは等しくない
    dtTokyo9 should equal (dtGreenwich0) (after being inTokyo)  // 東京にそろえれば等しい

    // おまけ:OffsetDateTime の場合
    val dtOffset9_0 = OffsetDateTime.parse("2015-09-01T00:00+09:00")
          // ゾーンオフセット+9:00で0時
    dtOffset9_0 shouldNot equal (dtTokyo0)  // 等しくない
    dtOffset9_0 should equal (dtTokyo0) (after being inTokyo)  // 東京にそろえれば等しい
  }
}

よしよし、きちんと動いてますね。

Equality と Uniformity のコラボレーション
せっかく Equality と Uniformity の実装を作ったので、両方を同時に使うサンプルも書いておきましょう。 東京の時刻に合わせた後で日付(月日)が等しいことを検証します。

class EqualityMatcherSpec extends FlatSpec with Matchers{

  // 日付はすべて 2015/9/1
  val dtGreenwich0 = ZonedDateTime.parse("2015-09-01T00:00Z[Greenwich]")  // グリニッジ0時
  val dtTokyo0     = ZonedDateTime.parse("2015-09-01T00:00+09:00[Asia/Tokyo]")  // 東京0時
  val dtUS12       = ZonedDateTime.parse("2015-09-01T12:00-04:00[America/New_York]")  // US12時

  // カスタム Equality, Uniformity
  val monthDay = new Equality[TemporalAccessor] { ... }
  val inTokyo = new Uniformity[TemporalAccessor] { ... }

  "decided by monthDay afterBeing inTokyo" should "同時刻の東京での月日が等しいかことを検証する" in {
    dtGreenwich0 should equal (dtTokyo0) (decided by monthDay afterBeing inTokyo)  // 等しい

    dtUS12 shouldNot equal (dtTokyo0) (decided by monthDay afterBeing inTokyo)
      // USと東京の時差が 9-(-4) = 13 時間なので、東京0時、US12時なら25時間のズレ
      //  -> 日付が異なる
  }
}

うん、それなりに動いてますな。 このあたりになってくると自己満足感が半端ないけど。 さて、次回は Matcher トレイトの実装クラスを作って、独自の述語を使えるようにしますよ。

JUnit実践入門 ~体系的に学ぶユニットテストの技法 (WEB+DB PRESS plus)

JUnit実践入門 ~体系的に学ぶユニットテストの技法 (WEB+DB PRESS plus)

Scalaスケーラブルプログラミング第2版

Scalaスケーラブルプログラミング第2版

  • 作者: Martin Odersky,Lex Spoon,Bill Venners,羽生田栄一,水島宏太,長尾高弘
  • 出版社/メーカー: インプレスジャパン
  • 発売日: 2011/09/27
  • メディア: 単行本(ソフトカバー)
  • 購入: 12人 クリック: 235回
  • この商品を含むブログ (46件) を見る

*1:あまり正確ではないけど、パスの文字列をスラッシュ / もしくはバックスラッシュ \ で区切ったトークンの個数と言えばいいのかな。

*2:これらのトレイトは org.scalatest.enablers パッケージに定義されています。

*3:Scaladoc には「determined by」によって Equivalence を使って等価性のカスタマイズができるっぽく書いてますが、コンパイラに怒られました。

*4:なんか抽象メソッドの定義の仕方がぎこちない気がするんだけど・・・