はじめに
単体テストを行う際には、テスト対象に渡したり、あるいはモックの引数や返り値などととして利用したりする値やインスタンスが必要となる1。このような単体テストでのみ利用する値をこの記事ではダミー値と呼ぶが、従来はテストを作成するプログラマーが手作業で作成しており、巨大な依存を持つユニットのテストは、ダミー値の作成だけで大きな労力が必要であった。この記事では任意の型A
のダミー値を自動で作成する型クラスTestObject[S, A]
について述べる。この型クラスTestObject[S, A]
のインスタンスは常に同じダミー値を生成することもできる一方で、Stateモナドを利用して異なる値を生成することも可能である。また、このTestObject[S, A]
を利用してどのようにテストを書くかについても言及する。この記事ではまず、モチベーションを説明するための例題として典型的なデータ構造を定義し、それをJSONへ変換するコンバーターにを考える。このコンバーターへの単体テストを作るに際して著者らが提案する型クラスTestObject
を利用する。
この記事を読み、疑問や改善点などを見つけた場合はコメントなどで気軽に教えて欲しい。また、この記事の成果は @ippei-takahashi との共同作業である。なお、記事内の完全なソースコードは下記のリポジトリにある。
データ構造のJSON変換
この記事では例として、次のようなデータ構造User
をJSONへ変換するプログラムのテストについて考える。まずUser
を次のように定義する。
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
)
case class EmailAddress(value: String)
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)")
case class Age(value: Int)
このようなデータ構造User
をJSONへ変換するプログラムUserJsonConverter
を次のように定義したとする。
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]
}
これは次のように利用する。
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
を利用すると次のように書くことができる。
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)
}
}
}
ConstantTestObject
はTestObject
から派生した型クラスであるが、このようにConstantTestObject[User]
するだけで、適当な値を投入した型User
の値constantUser
を作成できる。プログラマーはConstantTestObject[User]
を直接定義してはいない。
また、異なるいくつかの値が必要な場合には、次のように書くことができる。
"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モナドのラッパーである。
trait TestObject[S, A] {
def generate: State[S, A]
}
このTestObject[S, A]
の型パラメータS
に入れる型をUnit
としたものがConstantTestObject[A]
であり、Int
としたものがDeterministicTestObject[A]
である。
trait ConstantTestObject[A] extends TestObject[Unit, A]
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]
のインスタンスについて述べる。まずは型パラメーターA
をInt
やString
などとした自明なインスタンスは次のように作る。
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
であり、この値を利用することは意味がないので捨てている。このように他にもFloat
やDouble
といった自明な型について、対応するStateモナドの作り方を手で定義する。
次に上述したUser
のようなプログラマが定義した型のインスタンスについて考える。まず、次のようなAbstractTestObject[S]
を考える。
trait AbstractTestObject[S] {
trait TestHList[L <: HList] {
def generate: State[S, L]
}
trait TestCoproduct[C <: Coproduct] {
def generate: State[S, C]
}
}
AbstractTestObject
はStateモナドの状態の型を固定して、HList
やCoproduct
を結果の型として持つようなStateモナドを作成する型クラスを定義している。これを利用することでUser
を自動生成するConstantTestObject[User]
のインスタンスを作成できるが、まずはHList
とCoproduct
について軽く解説する。
HList
とCoproduct
とマクロ
HList
は次のような定義となっている2。
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
は次のようなデータ構造である。
case class User(id: UserId, info: UserView)
このデータ構造は先頭にUserId
型の値をもち、次にUserView
型の値を持つデータ構造とみなすこともできる。このように、ユーザーが定義した型は最終的にプリミティブな型をある順序で並べた構造としてHListで表現できそうである。ところが、型UserView
に含まれている型Sex
は次のようになっている。
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)")
このように型Sex
はFemale
かMale
かUnknown
のいずれかであって、これを全て並べたものではない。そこでこのようなデータ構造をHList
へ変換することはできない。このようなデータ構造はCoproduct
へ変換できる。Coproduct
は次のようなデータ構造である2。
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
HList
とCoproduct
を利用して、型User
をプリミティブな型だけで表現すると次のようになる。
(String :: HNil) :: ((Seq[String :: HNil] :: HNil) :: (Female :+: Male :+: Unknown :+: CNil) :: (Int :: HNil) :: DateTime :: HNil) :: HNil
さて、このようにHList
とCoproduct
を利用することで、ユーザーが定義した任意の型をプリミティブな型のあつまりで表現できることが分った。またshapelessのマクロを利用することで、ユーザーが定義した任意の型からHList
とCoproduct
へ変換することができる。
ConstantTestObject[HList]
とConstantTestObject[Coproduct]
あとはHList
とCoproduct
と、さきほど定義したプリミティブな型の値を作成するインスタンスからユーザーが定義した型の値のインスタンスを次のように作成できる。まずはHList
のインスタンスを次のように作成する。
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
の場合は次のような順序で生成する。
- リストの先頭の値を生成する
- 先頭の型
H
についてはConstantTestObject[H]
のインスタンスを要求するので、型H
の値を生成できる
- 先頭の型
- リストの先頭以外の値を生成する
-
TestHList[T]
のインスタンスを要求するので、型T <: HList
の値の列を生成できる
-
そして最後にshapelessから提供された型クラスGeneric.Aux
を利用して、任意の型A
に対応するHList
であるL
へ変換し、プリミティブな型のインスタンスから最終的な型A
の値を組み立てることができる。
これと同様にCoproduct
を次のように組み立てる。
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
は状態を無視していたが、こちらは状態を次のように利用する。
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
と同様にHList
とCoproduct
を利用して次のように任意の型へ拡張する。またOption
の場合は状態によってSome
となったり、またはNone
となるようにしてある。
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
については次のようになる。
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
と比べて複雑となっている。これは次のような処理を行っているためである。
- 左側の型
H
の値を生成する。これは確実に成功する - 右側の型
T
の値を生成する- しかし、型
T
がCNil
である場合は生成に失敗する。失敗した場合にそなえて、成功か失敗かのOption
値を返すようにしている
- しかし、型
- 次のように条件分岐をする
- もし右側の値を作成できなかった場合、左側の値を利用する。このとき次の状態は左側の作成によって更新された値を利用する
- もし右側の値を作成できた場合、次のように分岐する
- 右側の値を作成した後の状態が偶数であるとき、左側の値を利用する。このとき次の状態は左側の作成によって更新された値を利用する
- 右側の値を作成した後の状態が奇数であるとき、右側の値を利用する。このとき次の状態は最終的な状態から左側の値を作成によって発生した更新を削除する
このようにして最終的な値を得ることができる。
まとめ
型クラスとマクロを利用することで、テストなどで必要となるダミー値を簡単に作成することができた。ダミー値の作成に必要な時間を短縮してテストをより効率的に書けるのではないかと思われる。