ScalaCheckで日付/時刻のテストをする

ScalaCheckで日付、時刻を生成するGenを定義しテストを書いてみました。
2020.09.14

はじめに

ScalaCheckはプロパティベーステストを記述するテスティングライブラリです。ScalaCheckでは目的に応じたGenを定義することで様々なテストを記述できます。今回はScalaCheckで日付、時刻を入力とする関数をテストを記述してみました。

テスト対象のクラス

下記のように日付、時刻に応じて異なる割引(Discount)を返す関数(Discount#apply)をテストします。

import java.time.temporal.ChronoUnit
import java.time.{DayOfWeek, ZonedDateTime}

sealed trait Discount

object Discount {

  def apply(date: ZonedDateTime): Option[Discount] = date.getDayOfWeek match {
    //水曜日は学生割引
    case DayOfWeek.WEDNESDAY => Some(StudentsDay)
    //金曜日の19時以降は割引
    case DayOfWeek.FRIDAY if date.isAfter(date.withHour(19).truncatedTo(ChronoUnit.HOURS)) => Some(TGIF)
    //以上!!
    case _ => None
  }

  final case object TGIF extends Discount

  final case object StudentsDay extends Discount

}

日付/時刻生成の方針

以下のような方針で日付、時刻を生成してみます。

  • 指定する期間に含まれる日付および時刻を表すZonedDateTimeを生成するGenを作る
  • 詳細として以下を指定できる
    • 生成されるZonedDateTimeの曜日を指定できる(例: 月曜日)
    • 生成されるZonedDateTimeの開始時刻、終了時刻を指定できる(例: 10時〜17時)

コード

Term

まず指定期間の日付を生成するGenを以下のように定義します。実際には開始と終了の間の日数の範囲で整数を生成して、開始日に加えることで日付に変換します。

import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import java.time.{DayOfWeek, LocalDate, ZoneId, ZonedDateTime}

import DateTimeGen.ZonedDateTimeGenOps
import org.scalacheck.Gen
import cats.implicits._

import scala.util.Try
import eu.timepit.refined.auto._

final case class Term(from: ZonedDateTime, end: ZonedDateTime) {
  //期間内の任意の日付を生成する
  lazy val anyDate: Gen[ZonedDateTime] = {
    val days = ChronoUnit.DAYS.between(from, end)
    Gen.choose(0, days).map(from.truncatedTo(ChronoUnit.DAYS).plusDays)
  }

  //特定の曜日の日付を生成する
  //anyDateで生成した日付から、指定する曜日の分ずらす
  def dayOfWeek(d: DayOfWeek): Gen[ZonedDateTime] = anyDate.map(date => date.plusDays((d.getValue - date.getDayOfWeek.getValue).toLong))
    .betweenHM((0, 0), (23, 59))
}

object Term {
  private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("y/M/d")

  def apply(from: String, end: String): Try[Term] = {
    def tryParse(s: String): Try[ZonedDateTime] = Try(LocalDate.parse(s, dateFormatter).atStartOfDay(ZoneId.of("Asia/Tokyo")))

    (tryParse(from), tryParse(end)).mapN(apply)
  }
}

DateTimeGen

特定時刻を生成するためGenと時刻の指定に必要なエイリアスを以下のように定義します。時分はRefinedで値を制限しています。曜日を指定する場合は#anyDateで生成した日付にさらにオフセットを加えて曜日をずらします。

import java.time.ZonedDateTime
import java.time.temporal.ChronoUnit

import eu.timepit.refined.W
import eu.timepit.refined.api.Refined
import eu.timepit.refined.numeric.Interval
import org.scalacheck.Gen

import scala.concurrent.duration._

object DateTimeGen {
  //時
  type Hour = Int Refined Interval.Closed[W.`0`.T, W.`23`.T]
  //分
  type Minute = Int Refined Interval.Closed[W.`0`.T, W.`59`.T]
  //時刻
  type HM = (Hour, Minute)

  implicit class HMOps(hm: HM) {
    def toDuration: Duration = hm match {
      case (h, m) => h.value.hours + m.value.minutes
    }
  }

  implicit class ZonedDateTimeGenOps(gen: Gen[ZonedDateTime]) {
    //特定の時間帯を生成する
    def betweenHM(from: HM, end: HM): Gen[ZonedDateTime] = gen.flatMap(date => Gen
      .choose(
        from.toDuration.toSeconds,
        end.toDuration.toSeconds
      ).map(date.truncatedTo(ChronoUnit.DAYS).plusSeconds))
  }

}

プロパティ例

上記のGenとヘルパーを使っていくつかのプロパティを記述してみます。この例にはGenの動作を確かめるため失敗するケースも含めています。

import java.time.DayOfWeek

import DateTimeGen.ZonedDateTimeGenOps
import eu.timepit.refined.auto._
import org.scalacheck.Prop._
import org.scalacheck.Properties
import cats.implicits._
import cats.kernel.Eq

object DiscountSpec extends Properties("Discount") {

  implicit val dayOfWeekEq:Eq[Option[Discount]] = Eq.fromUniversalEquals

  val term = Term("2020/01/01", "2021/03/31").get

  property("No discount except Friday or Wednesday") = forAll(term.anyDate
    .suchThat(d => DayOfWeek.FRIDAY != d.getDayOfWeek && DayOfWeek.WEDNESDAY != d.getDayOfWeek )) { date =>
    Discount(date) === None
  }
  //+ Discount.No discount except Friday or Wednesday: OK, passed 100 tests.

  property("Student's day") = forAll(term.dayOfWeek(DayOfWeek.WEDNESDAY)) { time =>
    Discount(time) === Some(Discount.StudentsDay)
  }
  //+ Discount.Student's day: OK, passed 100 tests.

  property("TGIF") = forAll(term.dayOfWeek(DayOfWeek.FRIDAY).betweenHM((19, 0), (23, 59))) { time =>
    Discount(time) === Some(Discount.TGIF)
  }
  //+ Discount.TGIF: OK, passed 100 tests.

  property("it isn't Friday night yet") = forAll(term.dayOfWeek(DayOfWeek.FRIDAY).betweenHM((18, 0), (23, 59))) { time =>
    Discount(time) === Some(Discount.TGIF)
  }
  //failing seed for Discount.it isn't Friday night yet is r7fzITjgMXePgDi9VWvvSA53TsUZIovDgCMF8JFAJXJ=
  //! Discount.it isn't Friday night yet: Falsified after 11 passed tests.
  //> ARG_0: 2020-06-05T18:02:07+09:00[Asia/Tokyo]
  //Found 1 failing properties.

}

やらなかったこと

上記のコードにたどり着く前に以下のようなfilterを使ったパターンも試してみましたが、filterで弾かれたケースはアサートの失敗としてカウントされてテスト自体が失敗するため今回のような実装になりました。

def dayOfWeek(d: DayOfWeek): Gen[ZonedDateTime] = anyDate.filter(date => date.getDayOfWeek == d)

まとめ

ScalaCheckで日付、時刻を生成するGenを定義しテストを書いてみました。