LoginSignup
9
8

More than 1 year has passed since last update.

“ダミー値”を自動で作成する型クラス

Last updated at Posted at 2018-05-05

はじめに

単体テストを行う際には、テスト対象に渡したり、あるいはモックの引数や返り値などととして利用したりする値やインスタンスが必要となる1。このような単体テストでのみ利用する値をこの記事ではダミー値と呼ぶが、従来はテストを作成するプログラマーが手作業で作成しており、巨大な依存を持つユニットのテストは、ダミー値の作成だけで大きな労力が必要であった。この記事では任意の型Aのダミー値を自動で作成する型クラスTestObject[S, A]について述べる。この型クラスTestObject[S, A]のインスタンスは常に同じダミー値を生成することもできる一方で、Stateモナドを利用して異なる値を生成することも可能である。また、このTestObject[S, A]を利用してどのようにテストを書くかについても言及する。この記事ではまず、モチベーションを説明するための例題として典型的なデータ構造を定義し、それをJSONへ変換するコンバーターにを考える。このコンバーターへの単体テストを作るに際して著者らが提案する型クラスTestObjectを利用する。
この記事を読み、疑問や改善点などを見つけた場合はコメントなどで気軽に教えて欲しい。また、この記事の成果は @ippei-takahashi との共同作業である。なお、記事内の完全なソースコードは下記のリポジトリにある。

データ構造のJSON変換

この記事では例として、次のようなデータ構造UserをJSONへ変換するプログラムのテストについて考える。まずUserを次のように定義する。

User.scala
import org.joda.time.DateTime

case class User(id: UserId, info: UserView)

case class UserId(value: String)

case class UserView(
  emailAddress: Seq[EmailAddress],
  sex: Sex,
  age: Age,
  createdAt: DateTime
)
EmailAddress.scala
case class EmailAddress(value: String)
Sex.scala
sealed abstract class Sex(val value: String)

case object Female extends Sex("female")

case object Male extends Sex("male")

case class Unknown(v: String) extends Sex(s"Unknown($v)")
Age.scala
case class Age(value: Int)

このようなデータ構造UserをJSONへ変換するプログラムUserJsonConverterを次のように定義したとする。

UserJsonConverter.scala
class UserJsonConverter {
  def toJson(user: User): JsValue = {
    import UserJsonConverter._
    Json.toJson(user)
  }

  def prettyPrint(user: User): String =
    Json.prettyPrint(toJson(user))
}

object UserJsonConverter {
  implicit val datetimeFormat: Writes[DateTime] = JodaWrites.JodaDateTimeWrites
  implicit val userIdWrites: Writes[UserId] = Writes(id => JsString(id.value))
  implicit val userViewWrites: Writes[UserView] = (
      (__ \ "email_address").write[Seq[String]].contramap[Seq[EmailAddress]](_.map(_.value)) and
      (__ \ "sex").write[String].contramap[Sex](_.value) and
      (__ \ "age").write[Int].contramap[Age](_.value) and
      (__ \ "created_at").write[DateTime]
    )(unlift(UserView.unapply))
  implicit val userWrites: Writes[User] = Json.writes[User]
}

これは次のように利用する。

Main.scala
object Main {
  def main(args: Array[String]): Unit = {
    val user = User(
      id = UserId("2"),
      info = UserView(
        emailAddress = Seq(EmailAddress("a@example.com"), EmailAddress("b@example.com")),
        sex = Male,
        age = Age(20),
        createdAt = new DateTime("2018-03-15")
      )
    )

    val converter = new UserJsonConverter

    println(converter.prettyPrint(user))
  }
}

このプログラムは次のように動作する。

$ sbt run
{
  "id" : "2",
  "info" : {
    "email_address" : [ "a@example.com", "b@example.com" ],
    "sex" : "male",
    "age" : 20,
    "created_at" : "2018-03-15T00:00:00.000+09:00"
  }
}

TestObjectの使い方

型クラスTestObjectを解説する前に、まずはTestObjectをどのように利用するかについて述べる。いま、UserJsonConverterへの単体テストをいくつか定義したい。このとき、UserConvereterに渡す型Userの値が必要である。このとき我々のTestObjectを利用すると次のように書くことができる。

UserJsonConverterSpec.scala
class UserJsonConverterSpec extends WordSpec with MustMatchers {
  trait SetUp {
    val sut = new UserJsonConverter
  }
  
  "toJson" should {
    "return a constant JSON successfully" in new SetUp {
      val constantUser: User = ConstantTestObject[User]

      val expected: JsValue = Json.parse(
        """
          |{
          |  "id" : "string",
          |  "info" : {
          |    "email_address" : ["string", "string", "string"],
          |    "sex" : "female",
          |    "age" : 123,
          |    "created_at" : "2018-03-13T00:00:00.000+09:00"
          |  }
          |}
        """.stripMargin
      )

      val actual = sut.toJson(constantUser)

      actual must be(expected)
    }
  }
}

ConstantTestObjectTestObjectから派生した型クラスであるが、このようにConstantTestObject[User]するだけで、適当な値を投入した型Userの値constantUserを作成できる。プログラマーはConstantTestObject[User]を直接定義してはいない。
また、異なるいくつかの値が必要な場合には、次のように書くことができる。

UserJsonConverterSpec.scala
"return some JSONs successfully" in new SetUp {
  val someUsers: Seq[User] = (for {
    u1 <- DeterministicTestObject[User]
    u2 <- DeterministicTestObject[User]
  } yield Seq(u1, u2)).apply(0)._2
 
  val expectedSeq: Seq[JsValue] = Seq(
    Json.parse(
      """
        |{
        |  "id" : "0",
        |  "info" : {
        |    "email_address" : ["1", "2", "3"],
        |    "sex" : "Unknown(4)",
        |    "age" : 5,
        |    "created_at" : "2018-03-19T00:00:00.000+09:00"
        |  }
        |}
      """.stripMargin
    ),
    Json.parse(
      """
        |{
        |  "id" : "7",
        |  "info" : {
        |    "email_address" : ["8", "9", "10"],
        |    "sex" : "male",
        |    "age" : 11,
        |    "created_at" : "2018-03-25T00:00:00.000+09:00"
        |  }
        |}
      """.stripMargin
    )
  )
 
  (someUsers zip expectedSeq) foreach {
    case (user, expected) =>
      val actual = sut.toJson(user)
      actual must be(expected)
  }
}

このようにダミー値を作ることができるTestObjectの詳細について次から解説する。

TestObject[S, A]

型クラスTestObject[S, A]は次のようなStateモナドのラッパーである。

TestObject.scala
trait TestObject[S, A] {
  def generate: State[S, A]
}

このTestObject[S, A]の型パラメータSに入れる型をUnitとしたものがConstantTestObject[A]であり、IntとしたものがDeterministicTestObject[A]である。

ConstantTestObject.scala
trait ConstantTestObject[A] extends TestObject[Unit, A]
DeterministicTestObject.scala
trait DeterministicTestObject[A] extends TestObject[Int, A]

Stateモナド

具体的なインスタンスについて述べる前に、Stateモナドについて軽く説明する。Stateモナドとは次のように定義されたデータ構造である。状態の型Sと結果の型Aを用いて次のようになる。

abstract class State[S, A] { self =>
  def run(s: S): (S, A)

  def flatMap[B](f: A => State[S, B]): State[S, B] =
    new State[S, B] {
      def run(s: S): (S, B) = {
        val (n, a) = self.run(s)
        f(a).run(n)
      }
    }

  def map[B](f: A => B): State[S, B] =
    new State[S, B] {
      def run(s: S): (S, B) = {
        val (n, a) = self.run(s)
        (n, f(a))
      }
    }
}

object State {
  def apply[S, A](f: S => (S, A)): State[S, A] =
    new State[S, A] {
      def run(s: S): (S, A) = f(s)
    }
  
  def get[S]: State[S, S] =
    new State[S, S] {
      def run(s: S): (S, S) = (s, s)
    }
}

Stateモナドはこのように状態を更新しながら、最終的に関数runに初期値を与えると型Aの値を生成する。また、ユーティリティーとして関数getを定義する。この関数はStateモナドState[S, A]から現在の状態の値を入手することができる。

ConstantTestObject[A]のインスタンス

それではConstantTestObject[A]のインスタンスについて述べる。まずは型パラメーターAIntStringなどとした自明なインスタンスは次のように作る。

ConstantTestObject.scala
type UnitState[A] = State[Unit, A]

private def state[A](a: A): UnitState[A] =
  State(_ => ((), a))
 
implicit val testString: ConstantTestObject[String] = new ConstantTestObject[String] {
  def generate: UnitState[String] = state("string")
}
 
implicit val testInt: ConstantTestObject[Int] = new ConstantTestObject[Int] {
  def generate: UnitState[Int] = state(123)
}

implicit def testOption[A: ConstantTestObject]: ConstantTestObject[Option[A]] = new ConstantTestObject[Option[A]] {
  def generate: UnitState[Option[A]] = {
    state(Some(implicitly[ConstantTestObject[A]].generate(())._2))
  }
}

プライベート関数stateはStateモナドを作成するユーティリティ関数である。Stateモナドの状態の型はUnitであり、この値を利用することは意味がないので捨てている。このように他にもFloatDoubleといった自明な型について、対応するStateモナドの作り方を手で定義する。
次に上述したUserのようなプログラマが定義した型のインスタンスについて考える。まず、次のようなAbstractTestObject[S]を考える。

AbstractTestObject.scala
trait AbstractTestObject[S] {
  trait TestHList[L <: HList] {
    def generate: State[S, L]
  }

  trait TestCoproduct[C <: Coproduct] {
    def generate: State[S, C]
  }
}

AbstractTestObjectはStateモナドの状態の型を固定して、HListCoproductを結果の型として持つようなStateモナドを作成する型クラスを定義している。これを利用することでUserを自動生成するConstantTestObject[User]のインスタンスを作成できるが、まずはHListCoproductについて軽く解説する。

HListCoproductとマクロ

HListは次のような定義となっている2

HList.scala
sealed trait HList
case class ::[+A, +B <: HList](h: A, t: B) extends HList {
  def ::[C](x: C): C :: A :: B = ::(x, this)
}
sealed trait HNil extends HList {
  def ::[A](x: A): A :: HNil = ::(x, this)
}

通常のリストはList[A]のように型Aの値が任意の数あるようなデータ構造であるが、一方でHListは型レベルで値の型とインデックスを保存しているため、たとえば次のようにいくつかの型を同時に格納するようなリストを表現できる。

1 :: "string" :: 1.0 :: HNil

これの型はInt :: String :: Float :: HNilとなる。つまり先頭にInt型の値があり、次にString型の値があり、最後にFloat型の値があるということを表現できる。このように型とその型の値が出現するインデックスを型レベルで持つデータ構造をHListと呼ぶ。さて、上記のUser型について考える。Userは次のようなデータ構造である。

User.scala
case class User(id: UserId, info: UserView)

このデータ構造は先頭にUserId型の値をもち、次にUserView型の値を持つデータ構造とみなすこともできる。このように、ユーザーが定義した型は最終的にプリミティブな型をある順序で並べた構造としてHListで表現できそうである。ところが、型UserViewに含まれている型Sexは次のようになっている。

Sex.scala
sealed abstract class Sex(val value: String)

case object Female extends Sex("female")

case object Male extends Sex("male")

case class Unknown(v: String) extends Sex(s"Unknown($v)")

このように型SexFemaleMaleUnknownのいずれかであって、これを全て並べたものではない。そこでこのようなデータ構造をHListへ変換することはできない。このようなデータ構造はCoproductへ変換できる。Coproductは次のようなデータ構造である2

Coproduct.scala
sealed trait Coproduct

sealed trait :+:[+H, +T <: Coproduct] extends Coproduct

case class Inl[+H, +T <: Coproduct](head : H) extends :+:[H, T]

case class Inr[+H, +T <: Coproduct](tail : T) extends :+:[H, T]

sealed trait CNil extends Coproduct

簡単に言えばEitherであるが、右側(Inr)が取る型がCoproductのサブタイプであることを強制している3。これを利用することで、次のように先程のSexを対応するCoproductへ変換することができる。

Female :+: Male :+: Unknown :+: CNil

HListCoproductを利用して、型Userをプリミティブな型だけで表現すると次のようになる。

(String :: HNil) :: ((Seq[String :: HNil] :: HNil) :: (Female :+: Male :+: Unknown :+: CNil) :: (Int :: HNil) :: DateTime :: HNil) :: HNil

さて、このようにHListCoproductを利用することで、ユーザーが定義した任意の型をプリミティブな型のあつまりで表現できることが分った。またshapelessのマクロを利用することで、ユーザーが定義した任意の型からHListCoproductへ変換することができる。

ConstantTestObject[HList]ConstantTestObject[Coproduct]

あとはHListCoproductと、さきほど定義したプリミティブな型の値を作成するインスタンスからユーザーが定義した型の値のインスタンスを次のように作成できる。まずはHListのインスタンスを次のように作成する。

ConstantTestObject.scala
implicit val testHNil: TestHList[HNil] = new TestHList[HNil] {
  def generate: UnitState[HNil] = state(HNil)
}

implicit def testHCons[H, T <: HList](
  implicit
  head: ConstantTestObject[H],
  tail: TestHList[T]
): TestHList[H :: T] = new TestHList[H :: T] {
  def generate: UnitState[H :: T] =
    for {
      h <- head.generate
      t <- tail.generate
    } yield h :: t
}

implicit def testHList[A, L <: HList](
  implicit
  gen: Generic.Aux[A, L],
  testHList: Lazy[TestHList[L]]
): ConstantTestObject[A] = new ConstantTestObject[A] {
  def generate: UnitState[A] =
    testHList.value.generate.map(gen.from)
}

まず、HNilの場合はそのままHNilを返す自明な実装となり、一方でH :: TというHListの場合は次のような順序で生成する。

  1. リストの先頭の値を生成する
    • 先頭の型HについてはConstantTestObject[H]のインスタンスを要求するので、型Hの値を生成できる
  2. リストの先頭以外の値を生成する
    • TestHList[T]のインスタンスを要求するので、型T <: HListの値の列を生成できる

そして最後にshapelessから提供された型クラスGeneric.Auxを利用して、任意の型Aに対応するHListであるLへ変換し、プリミティブな型のインスタンスから最終的な型Aの値を組み立てることができる。
これと同様にCoproductを次のように組み立てる。

ConstantTestObject.scala
implicit val testCNil: TestCoproduct[CNil] = new TestCoproduct[CNil] {
  def generate: UnitState[CNil] = throw new RuntimeException()
}
 
implicit def testCCons[H, T <: Coproduct](
  implicit
  inl: ConstantTestObject[H],
  inr: TestCoproduct[T]
): TestCoproduct[H :+: T] = new TestCoproduct[H :+: T] {
  def generate: UnitState[H :+: T] = inl.generate.map(Inl(_))
}

implicit def testCoproduct[A, C <: Coproduct](
  implicit
  gen: Generic.Aux[A, C],
  testCoproduct: Lazy[TestCoproduct[C]]
): ConstantTestObject[A] = new ConstantTestObject[A] {
  def generate: UnitState[A] =
    testCoproduct.value.generate.map(gen.from)
}

まずCNilはインスタンス化ができないので、型をあわせるために適当な例外を例外を投げるようにする。ただこのインスタンスtestCNilが利用されることはなく、インスタンスtestCConsでは常に左側の値を用いることとしている。このようにしてCoproductの生成ルールを与え、最後にGeneric.Auxを利用してHListと同様に任意の型Aの値を作成することができる。

DeterministicTestObject[A]のインスタンス

さて、DeterministicTestObject[A]は型Aの値を返すが、それはStateモナドの状態(Int)によって生成される。ConstantTestObjectは状態を無視していたが、こちらは状態を次のように利用する。

DeterministicTestObject.scala
implicit val testString: DeterministicTestObject[String] = new DeterministicTestObject[String] {
  def generate: IntState[String] = State(s => (s + 1, s.toString))
}
 
implicit val testInt: DeterministicTestObject[Int] = new DeterministicTestObject[Int] {
  def generate: IntState[Int] = State(s => (s + 1, s))
}

implicit def testOption[A: DeterministicTestObject]: DeterministicTestObject[Option[A]] = new DeterministicTestObject[Option[A]] {
  def generate: IntState[Option[A]] =
    for {
      a <- implicitly[DeterministicTestObject[A]].generate
      s <- State.get
    } yield
      if (s % 2 == 0)
        Some(a)
      else
        None
}

このようにプリミティブな型についてはInt型の値から雑に生成し、そして状態に1を加算するという更新を行う。また、前述のConstantTestObjectと同様にHListCoproductを利用して次のように任意の型へ拡張する。またOptionの場合は状態によってSomeとなったり、またはNoneとなるようにしてある。

DeterministicTestObject.scala
implicit val testHNil: TestHList[HNil] = new TestHList[HNil] {
  def generate: IntState[HNil] = State(s => (s, HNil))
}
 
implicit def testHCons[H, T <: HList](
  implicit
  head: DeterministicTestObject[H],
  tail: TestHList[T]
): TestHList[H :: T] = new TestHList[H :: T] {
  def generate: IntState[H :: T] = {
    for {
      h <- head.generate
      t <- tail.generate
    } yield h :: t
  }
}
 
implicit def testHList[A, L <: HList](
  implicit
  gen: Generic.Aux[A, L],
  testHList: Lazy[TestHList[L]]
): DeterministicTestObject[A] = new DeterministicTestObject[A] {
  def generate: IntState[A] =
    testHList.value.generate.map(gen.from)
}

HListについて述べることは、HNilの値を生成する場合は状態を更新しないことである。HNilの生成は最終的にtestHListインスタンスでユーザーの定義した型へ戻す際に失われるため、状態を更新しない方が自然な結果が得られる。
Coproductについては次のようになる。

DeterministicTestObject.scala
implicit val testCNil: TestCoproduct[CNil] = new TestCoproduct[CNil] {
  def generate: IntState[CNil] = throw new RuntimeException
}
 
implicit def testCCons[H, T <: Coproduct](
  implicit
  inl: DeterministicTestObject[H],
  inr: TestCoproduct[T]
): TestCoproduct[H :+: T] = new TestCoproduct[H :+: T] {
  def generate: IntState[H :+: T] = {
    def or(l: H, rOpt: Option[T], s0: Int, s1: Int, s2: Int): IntState[H :+: T] = rOpt match {
      case Some(r) =>
        if (s2 % 2 == 0)
          State(_ => (s1, Inl(l)))
        else
          State(_ => (s0 + s2 - s1, Inr(r)))
      case None =>
        State(_ => (s1, Inl(l)))
    }
 
    for {
      s0 <- State.get
      l <- inl.generate
      s1 <- State.get
      rOpt <- try {
        inr.generate.map(Some(_))
      } catch {
        case e: Throwable =>
          State((s: Int) => (s, None))
      }
      s2 <- State.get
      out <- or(l, rOpt, s0, s1, s2)
    } yield out
  }
}
 
implicit def testCoproduct[A, C <: Coproduct](
  implicit
  gen: Generic.Aux[A, C],
  testCoproduct: Lazy[TestCoproduct[C]]
): DeterministicTestObject[A] = new DeterministicTestObject[A] {
  def generate: IntState[A] =
    testCoproduct.value.generate.map(gen.from)
}

H :+: TのケースがConstantTestObjectと比べて複雑となっている。これは次のような処理を行っているためである。

  1. 左側の型Hの値を生成する。これは確実に成功する
  2. 右側の型Tの値を生成する
    • しかし、型TCNilである場合は生成に失敗する。失敗した場合にそなえて、成功か失敗かのOption値を返すようにしている
  3. 次のように条件分岐をする
    • もし右側の値を作成できなかった場合、左側の値を利用する。このとき次の状態は左側の作成によって更新された値を利用する
    • もし右側の値を作成できた場合、次のように分岐する
      • 右側の値を作成した後の状態が偶数であるとき、左側の値を利用する。このとき次の状態は左側の作成によって更新された値を利用する
      • 右側の値を作成した後の状態が奇数であるとき、右側の値を利用する。このとき次の状態は最終的な状態から左側の値を作成によって発生した更新を削除する

このようにして最終的な値を得ることができる。

まとめ

型クラスとマクロを利用することで、テストなどで必要となるダミー値を簡単に作成することができた。ダミー値の作成に必要な時間を短縮してテストをより効率的に書けるのではないかと思われる。

  1. この記事では今後、クラスなどのインスタンスと値を区別せずにどちらも値と表現する。

  2. 実装ではshapelessの実装を用いたが、手で実装してもよい。 2

  3. この定義とは異なり、Coproductは次のような高階なEitherを指すこともある。

      case class Coproduct[F[_], G[_], A](value: Either[F[A], G[A]])
    
9
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
8