倭マン's BLOG

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

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

今回は Matcher 型もしくはそれに類する型の実装クラスを作成して、検証文(should を含む文)の独自の述語を作る方法を見ていきます(目次)。 『JUnit 実践入門』に沿うなら前回の型クラスの話よりこちらを先にやるべきだった気もしますが、書いてしまったものは仕方ないので粛々といきましょう。

ScalaTest では should の後に続ける(should メソッドの引数として渡す) Matcher オブジェクト以外にも似たような(しかし継承関係にない)いくつかの型があります。 ただし使い方は似ている、もしくはほとんど同じなので恐るるに足りません。

この記事でのカスタム Matcher は『JUnit 実践入門』に倣って日付関連の検証を行うものをいくつか作りますが、昔の Date, Calender ではなく Java8 で導入された新しい日時 API (Date and Time API) を使っていきます。

この記事の内容

参考

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

カスタム Matcher を実装する

まずはシンプルなカスタム Matcher を作ってみましょう。 ここでは新しい述語をテストケース内に無名クラスとして作成しています:

import java.time._
import java.time.Month._
import java.time.format.DateTimeFormatter

class CustomMatcherSpec extends FlatSpec with Matchers{

  // テストで使う日付オブジェクト
  val date_2016_4_1 = LocalDate.of(2016, APRIL, 1)
  val today         = LocalDate.of(2015, SEPTEMBER, 16)

  // エラーメッセージで使う日付のフォーマット
  val dateFormat = DateTimeFormatter.ofPattern("M月d日")

  "beAprilFool(LocalDate版)" should "Matcherでエイプリルフールであることを検証する" in {

    // 「SUT should beAprilFool」のように使う
    val beAprilFool = new Matcher[LocalDate]{
      override def apply(sut: LocalDate) =
        MatchResult(
          sut.getMonth == APRIL &&sut.getDayOfMonth == 1,
          s"""${sut.format(dateFormat)}はエイプリルフールではありません。""",
          s"""${sut.format(dateFormat)}はエイプリルフールです。"""
        )
    }

    // 検証
    date_2016_4_1 should beAprilFool  // OK
    today should beAprilFool  // 「9月16日はエイプリルフールではありません。」
  }
}
  • Matcher の型パラメータ(ここでは LocalDate)は SUT (テスト対象オブジェクト)の型にします。
  • apply メソッドは org.scalatest.matchers.MatchResult オブジェクトを返します。
  • MatchResult のコンストラクタに渡す引数は以下のようになっています:
    1. 検証が成功したかどうかの Boolean 値
    2. 検証が失敗したときのエラーメッセージ
    3. 否定の検証(shouldNot ... など)が失敗したときのエラーメッセージ

無名クラスではなく、Matcher コンパニオン・オブジェクトに定義されているファクトリメソッドを使って Matcher を定義することもできます:

    val beAprilFool = Matcher { sut: LocalDate =>
      MatchResult(
        sut.getMonth == Month.APRIL && sut.getDayOfMonth == 1,
        s"""${sut.format(dateFormat)}はエイプリルフールではありません。""",
        s"""${sut.format(dateFormat)}はエイプリルフールです。"""
      )
    }

ちょこっと短くできるので、以降、こちらの方法で書くことにします。 MatchResult オブジェクトの作成部分は全く同じです。

MatchResult クラスのフィールド
をまとめようと思ったけど書くのが面倒なので MatchResult の Scaladoc へのリンクだけ張っときます:

もしかしたら【追記】するかも。

TemporalAccessor 版 beAprilFool
これはカスタム Matcher とは関係ないのですが、beAprilFool が LocalDate オブジェクトだけにしか適用できないのはもったいないので、いろいろな日時オブジェクトが実装している TemporalAccessor 型に対する Matcher に拡張しておきましょう。

import java.time._
import java.time.Month._
import java.time.format.DateTimeFormatter
import java.time.temporal.TemporalAccessor

class CustomMatcherSpec extends FlatSpec with Matchers{

  // 検証に使う日時オブジェクト
  val now = ZonedDateTime.parse("2015-09-16T00:00+09:00[Asia/Tokyo]")
  val today = now.toLocalDate
  val date_2016_4_1 = LocalDate.of(2016, APRIL, 1)
  
  val aprilFool = MonthDay.of(APRIL, 1)
  val dateFormat = DateTimeFormatter.ofPattern("M月d日")

  "beAprilFool(TemporalAccessor版)" should "Matcherでエイプリルフールであることを検証する" in {

    val beAprilFool = Matcher { sut: TemporalAccessor =>
      val monthDay = MonthDay.from(sut)
      MatchResult(
        monthDay == aprilFool,
        s"""${dateFormat.format(monthDay)}はエイプリルフールではありません。""",
        s"""${dateFormat.format(monthDay)}はエイプリルフールです。"""
      )
    }

    // 検証
    date_2016_4_1 should beAprilFool  // OK
    today should beAprilFool  // 「9月16日はエイプリルフールではありません。」
    now should beAprilFool  // 「9月16日はエイプリルフールではありません。」
  }
  • MonthDay#from メソッドを使えば TemporalAccessor オブジェクトから MonthDay オブジェクトに変換できます(他の日時オブジェクトも同じ)。
  • MonthDay クラスは月と日のフィールドによる等価性評価を行うように equals メソッドが定義されているので、これを使ってエイプリルフールかどうかを検証しています。
  • TemporalAccessor 版では ZonedDateTime オブジェクト (now) についても検証が行えます(今の場合失敗しますが)。

以下では基本的に SUT は TemporalAccessor 型にしていますが、LocalDate や MonthDay と同じようなものと思って OK です。

引数を持つカスタム Matcher
固定された日付だけを検証できても有り難みがないので、Matcher の方に引数を渡して検証できる日付を指定できるようにしてみましょう。 いままでは val で(ローカル)変数として Matcher を定義していましたが、引数が必要な場合は関数として定義します*1

  "beDateOf" should "Matcherで指定された日付であることを検証する" in {

    def beDateOf(month: Month, day: Int) = Matcher { sut: TemporalAccessor =>
      val monthDay = MonthDay.from(sut)
      MatchResult(
        monthDay == MonthDay.of(month, day),
        s"""${dateFormat.format(monthDay)}は${month.getValue}月${day}日ではありません。""",
        s"""${dateFormat.format(monthDay)}は${month.getValue}月${day}日です。"""
      )
    }

    // 検証
    today should beDateOf (SEPTEMBER, 16)  // OK
    today should beDateOf (APRIL, 1)  // 「9月16日は4月1日ではありません。」
  }

まぁ、なんてことないですね。 この Matcher が、年を検証していないこと以外は『JUnit 実践入門』に載っているカスタム Matcher の例と一番近いものです。

その他の Matcher 系クラス

ScalaTest には Matcher 以外に同様のトレイトがいくつか定義されています。 JUnit では (org.hamcrest.)Matcher 型のサブタイプとしていくつかのクラスが定義されていましたが、ここで見ていくトレイトには継承関係はありません。 ただし、使い方は Matcher とあまり変わりません。

ScalaTest に定義されている Matcher 風のトレイトには以下のものがあります(org.scalatest.matchers パッケージに定義):

  • BeMatcher ・・・ MatchResult を返す
  • BePropertyMatcher ・・・ BePropertyMatchResult を返す
  • HavePropertyMatcher ・・・ HavePropertyMatchResult を返す

Matcher では MatchResult を返しましたが、それぞれの Matcher 風トレイトにはそれぞれの MatchResult 風クラスがあります(BeMatcher は Matcher と共通の MatchResult オブジェクト)

BeMatcher
BeMatcher は「should be」もしくは「shouldBe」の後におく述語を作る Matcher 風トレイトです。 先ほどの beAprilFool や beDateOf は BeMatcher の方が自然ですね。

ここでは beDateOf と同じ機能の BeMatcher を作ってみましょう:

  "be dateOf" should "BeMatcherで指定された日付であることを検証する" in {

    def dateOf(month: Month, day: Int) = BeMatcher { sut: TemporalAccessor =>
      val monthDay = MonthDay.from(sut)
      MatchResult(
        monthDay == MonthDay.of(month, day),
        s"""${dateFormat.format(monthDay)}は${month.getValue}月${day}日ではありません。""",
        s"""${dateFormat.format(monthDay)}は${month.getValue}月${day}日です。"""
      )
    }

    // 検証
    today should be (dateOf(SEPTEMBER, 1))  // OK 丸括弧()が余計に必要
    today shouldBe dateOf(SEPTEMBER, 1)  // OK
    today shouldBe dateOf(APRIL, 1)  // 「9月16日は4月1日ではありません。」
  }
  • Matcher のコンパニオン・オブジェクトに定義されているファクトリメソッドと同様に、BeMatcher のコンパニオン・オブジェクトにも BeMatcher のファクトリメソッドが定義されています。 ここでもそれを使っています。
  • BeMatcher で作った述語を検証文で使う場合、「should be」だと丸括弧 () を余計に付ける必要があります。 「shouldBe」を使う方がいいんじゃないかと思います。

BePropertyMatcher
BePropertyMatcher は「should be a ...」の形の検証文で使われる述語を作成します。 これは Boolean 値のプロパティが true であることを検証します。 「ScalaTest も『JUnit 実践入門』もまとめて相手してやんよ (7) : Matcher いろいろ~論理演算・その他編~ # JavaBeans」で出てきた「should be a 'プロパティ名」というのと同じような検証ですが、プロパティ名の前のクォート (') はいりません。

ここでは java.time.Year オブジェクトが表す年が閏年であるかどうかを検証する述語を作成してみます:

  "be a leapYear" should "BePropertyMatcherで閏年であることを検証する" in {
    val leapYear = BePropertyMatcher { sut: TemporalAccessor =>
      BePropertyMatchResult(Year.from(sut).isLeap, "leap year")
    }
    
    // 検証
    date_2016_4_1 should be a leapYear  // OK
    today should be a leapYear  // 「2015-09-16 was not a leap year」

    // ちなみに、SUT が Year オブジェクトならカスタム BePropetyMatcher を作る必要はない
    Year.of(2016) should be a 'leap
  }
  • Year クラスの Boolean 値プロパティ「leap」、検証の述語「leapYear」、エラーメッセージ「leap year」と使い分けていますが、通常は一致している方が分かりやすいと思います。 今の場合は、どこで何を指定して使っているのかを区別できるように変更しています。
  • BePropertyMatchResult は MatchResult より簡単で、検証の成否を表す Boolean 値と(エラーメッセージに表示される)プロパティ名を指定するだけです。
  • ちなみに SUT が Year クラスなら、(Year クラスが leap という Boolean 値のプロパティを持つので)カスタム BePropertyMatcher を作らなくても最後の行のように検証できますが・・・

HavePropertyMatcher
HavePropertyMatcher は「should have ( ... )」の形式でプロパティの値を検証する述語を作ります。 こちらも BePropertyMatcher と同じように、プロパティ名の前のクォート (') はいりません。

  "have (month (), day ())" should "HavePropertyMatcherでプロパティの値を検証する" in {
    // month プロパティを検証する述語
    def month(expected:Month) = HavePropertyMatcher { sut: TemporalAccessor =>
      val month = Month.from(sut)
      HavePropertyMatchResult(month == expected, "month", expected, month)
    }

    // day プロパティを検証する述語
    def day(expected:Int) = HavePropertyMatcher { sut: TemporalAccessor =>
      val day = MonthDay.from(sut).getDayOfMonth
      HavePropertyMatchResult(day == expected, "day", expected, day)
    }

    // 検証
    date_2016_4_1 should have (month (APRIL))

    today should have (
      month (SEPTEMBER),
      day (1)
    )  // 「The day property had value 16, instead of its expected value 1, on object 2015-09-16」
  }
  • HavePropertyMatcher では HavePropertyMatchResult オブジェクトを返します。 これは BePropertyMatchResult と似ていますが、プロパティ値が Boolean ではなく任意のオブジェクトなので、検証の成否の Boolean 値とプロパティ名の他に期待値と実測値を渡す必要があります。

まぁ、どの Matcher 風トレイトの実装も Matcher とほとんど同じかむしろ簡単なので、恐るるに足らず。

Matcher の合成

Matcher は既存のもの、自作のものを組み合わせて新たに作成することができます。 単純には論理演算の Matcher (not/and/or) を使って Matcher を作れます。 たとえば and を使って SUT の日付がある蛇遣い座(11月29日~12月18日)の範囲にあることを検証する Matcher は以下のように書けます(Date and Time API では日付などにデフォルトの順序関係が定義されている*2ので簡単に書けます):

import java.time._
import java.time.Month._

class ComposeMatcherSpec extends FlatSpec with Matchers{

  val birthday = MonthDay.of(DECEMBER, 11)

  "not/and/or" should "既存のMatcherを組み合わせて新たにMatcher定義する" in {
    // 誕生日が蛇遣い座であることを検証する Matcher。 MonthDay オブジェクトに対する検証
    val beOphiuchus = be >= MonthDay.of(NOVEMBER, 29) and be < MonthDay.of(DECEMBER, 18)

    // 検証
    birthday should beOphiuchus  // OK
  }
}

他の組み合わせ方法として、Scala で関数を合成する compose, andThen を使うものがあります*3。 ただし、これ以降の話はちょっとマニアックになるので興味ない方はここで読むのを止めてもらってOK です。

1回の compose
既存の Matcher に対して、あるオブジェクトの変換関数を合成することによって、「SUT に変換を施した後に Matcher で検証を行う」という一連の処理をまとめて行うことができます。

例えば、MonthDay オブジェクトに対する先ほど作った Matcher であるbeOphiuchus を TemporalAccessor に対しても適用できるようにするために、TemporalAccessor を MonthDay オブジェクトに変換する処理を合成してみましょう。 ちょっと、命名の都合上、先ほどの述語は名前を変えて、TemporalAccessor に対するものを新たな beOphiuchus とします:

  // 検証に使う日時オブジェクト
  val birthday = MonthDay.of(DECEMBER, 11)
  val birthTime = ZonedDateTime.parse("2015-12-11T00:00+09:00[Asia/Tokyo]")

  "f compose g" should "SUTを別オブジェクトへ変換してMatcherを適用するのと等価なMatcherを生成する" in{
    // 先ほど beOphiuchus だった Matcher
    val beInOphiuchusMonthDayPeriod =
      be >= MonthDay.of(NOVEMBER, 29) and be < MonthDay.of(DECEMBER, 18)

    // TemporalAccessor オブジェクトを MonthDay オブジェクトに変換する処理を合成
    val beOphiuchus =
      beInOphiuchusMonthDayPeriod compose { t: TemporalAccessor => MonthDay from t }

    // 検証
    birthday should beOphiuchus
    birthTime should beOphiuchus
  }

変換処理は compose の後に書いてます。 といってもこれじゃ分かりにくいと思うので、変数名を短縮して書いてみましょう:

  it should "変数名を短縮して分かりやすくしてみる" in {
    val f = be >= MonthDay.of(NOVEMBER, 29) and be < MonthDay.of(DECEMBER, 18)
      // f: Matcher[MonthDay]
    val g = MonthDay from _  // g: TemporalAccessor => MonthDay
    val h = f compose g  // h: Matcher[TemporalAccessor]

    // 検証
    birthTime should h

    // これは以下と等価
    birthTime should (f compose g)
    g(birthTime) should f
  }
  • 既存の Matcher である f に変換処理 g を「f compose g」のように合成しています。
  • 文法に則って型宣言を書くとゴチャゴチャして分かりにくいので、型宣言をコメントアウトして付近に書いてます。
  • 最後の行の等価な検証文を見れば、どのような処理を行っているかが一目瞭然かと。 ただし、等価といってもエラーメッセージは異なるかも知れません。

引数がある場合
既存の Matcher が引数をとる場合、合成して作成する Matcher も関数として定義すれば同じようにできます。 MonthDay オブジェクトが指定した日付であることを検証する既存の Matcher に対して、TemporalAccessor を MonthDay に変換する処理を合成してみましょう:

  it should "引数がある場合は関数として定義する" in {
    def f(month: Month, day: Int) = be (MonthDay.of(month, day))  // equal ではマズイ
    val g = MonthDay from _
    def h(month: Month, day: Int) = f(month, day) compose g

    // 検証
    birthTime should h(DECEMBER, 11)

    // これは以下と等価
    birthTime should (f(DECEMBER, 11) compose g)
    g(birthTime) should f(DECEMBER, 11)
  }

引数をとるものを def で関数にしてるだけですね。

2回の compose
SUT と期待値に同じ変換を施して、変換後の値に既存の検証を行う」ときには、2回の compose による合成を行います。

例えば2つの ZonedDateTime オブジェクトを Instant オブジェクトに変換してから等しいことを検証してみましょう:

  // 検証に使う日時オブジェクト
  val dtGreenwich0 = ZonedDateTime.parse("2015-09-16T00:00Z[Greenwich]")
  val dtTokyo9     = ZonedDateTime.parse("2015-09-16T09:00+09:00[Asia/Tokyo]")

  "2回のcompose" should "SUTにも期待値にも変換を施した後に検証を行う" in {
    val f= be (_: Instant)  // f: Instant => Matcher[Instant]
    val g = (_: ZonedDateTime).toInstant  // g: ZonedDateTime => Instant
    val beTheSameInstantAs = (f compose g) andThen (_ compose g)
      // beTheSameInstant: ZonedDateTime => Matcher[ZonedDateTime]

    // 検証
    dtGreenwich0 should not be dtTokyo9  // ZonedDateTime としては等しくない
    dtGreenwich0 should beTheSameInstantAs (dtTokyo9)  // Instant としては等しい

    // beTheSameInstantAs は以下と等価
    dtGreenwich0 should ((f compose g) andThen (_ compose g))(dtTokyo9)
    dtGreenwich0 should ((f compose g)(dtTokyo9) compose g)
    g(dtGreenwich0) should f(g(dtTokyo9))  // これをイメージしておけば充分
  }

(f compose g) andThen (_ compose g)」というのは定型文と思っておいた方が良さそうですね。 SUT と期待値で変換処理を変えることもできますが、あまりにもマニアックに過ぎるのでやめます。

エラーメッセージの変更・修正

compose で Matcher に変換処理を合成すると、失敗したときにエラーメッセージが分かりにくくなる場合があります。 原因が分かりやすいエラーメッセージはテストの重要部分なので、エラーメッセージを変更・修正する方法が提供されています。

まずは1回の compose の場合。

  // 検証に使う日時オブジェクト
  val now = ZonedDateTime.parse("2015-09-16T00:00+09:00[Asia/Tokyo]")
  
  // 日付のフォーマット
  val monthDayFormat = DateTimeFormatter.ofPattern("M/d")
  def formatDate(a: Any) = {
    val temporal = a.asInstanceOf[TemporalAccessor]
    MonthDay.from(temporal).format(monthDayFormat)
  }

  it should "mapResultで詳細なメッセージを構築する" in {
    val f = be >= MonthDay.of(NOVEMBER, 29) and be < MonthDay.of(DECEMBER, 18)
    val g = MonthDay from _

    val beOphiuchus = f compose g mapResult { mr =>
      mr.copy(
        failureMessageArgs =
          mr.failureMessageArgs map formatDate,
        negatedFailureMessageArgs =
          mr.negatedFailureMessageArgs map formatDate
      )
    }

    // 検証
    val ex = the [TestFailedException] thrownBy { now should beOphiuchus }
    ex should have message "\"9/16\" was not greater than or equal to \"11/29\""
  }
  • 「f compose g」のように合成した後、mapResult によって MatchResult を修正します。
  • 完全に新たな MatchResult オブジェクトを作成して返すことも可能ですが、MatchResult#copy を使って必要なフィールドだけ変更して複製を返す方が楽でしょう。 ここでは failureMessageArgs と negatedFailureMessageArgs (よくある printf でフォーマットされて文字列に埋め込まれるオブジェクトのようなものだと思う)を map で変換(日付を指定したフォーマットの文字列に変換)してコピーに渡してます。

このエラーメッセージの修正をしなかった場合、メッセージの日付部分が MonthDay オブジェクトに対して toString メソッドを呼び出した結果になります(--12-11 みたいなの)。

次は2回の compose の場合。

    import org.scalatest.matchers.MatcherProducers._
    import org.scalatest.matchers.LazyArg

  val dtGreenwich0 = ZonedDateTime.parse("2015-09-16T00:00Z[Greenwich]")
  val dtTokyo0     = ZonedDateTime.parse("2015-09-16T00:00+09:00[Asia/Tokyo]")

  it should "mapResultで詳細なメッセージを構築する" in {
    val f = be (_:Instant)
    val g = (_:ZonedDateTime).toInstant
    val beTheSameInstantAs = (f compose g) andThen (_ compose g) mapResult { mr =>
      mr.copy(
        failureMessageArgs =
          mr.failureMessageArgs map (LazyArg(_){ "[" + _.toString + "].toInstant" }),
        negatedFailureMessageArgs =
          mr.negatedFailureMessageArgs map (LazyArg(_){ "["+_.toString+"].toInstant" })
      )
    }

    // 検証
    val ex = the [TestFailedException] thrownBy {
      dtGreenwich0 should beTheSameInstantAs (dtTokyo0)
    }

    ex should have message "[2015-09-16T00:00:00Z].toInstant was not equal to [2015-09-15T15:00:00Z].toInstant"
  }
  • 2回の compose の場合は、インポート文「import org.scalatest.matchers.MatcherProducers._」を付けておかないと mapResult メソッドが呼び出せません。
  • MatchResult を copy によっていくつかのフィールドを修正して複製しているのは先ほどの場合と同じですが、map の変換の部分で LazyArg を使っています。 これは compose が1回か2回かには関係なく、どちらの場合でも使えます。 これを使うと、MatchResult の prettifier プロパティで得られる Prettifier オブジェクトによって変換された後の String オブジェクトが渡されます。 LazyArg を使わない場合は、検証文にあるオブジェクトがそのまま渡されます。
  • エラーメッセージの修正によって、ZonedDateTime オブジェクトに対して toString を呼び出して得られる文字列 2015-09-16T00:00:00Z などが [ ... ].toInstant で挟まれています。

うーむ。 カスタム Matcher 一応終わったけど、大半の機能なんてほとんど使われない気がする・・・ まぁ、Java8 の Date and Time API で結構遊べたのでよしとするか。 さて、ついに Matcher が終わった! 当初の目的は果たしたので次の記事まで間が空くかもしれないけど、『JUnit 実践入門』によると次はテストランナーか。

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:Comparable を実装している。

*3:ただし、Matcher に対する compose はオーバーライドされているので Matcher に対する compose はこういうものだという風に、関数合成とは別モノと思っておいた方が分かりやすいかも知れません。