はじめに
単体テストを行う際には、テスト対象に渡したり、あるいはモックの引数や返り値などととして利用したりする値やインスタンスが必要となる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値を返すようにしている
- しかし、型
- 次のように条件分岐をする
- もし右側の値を作成できなかった場合、左側の値を利用する。このとき次の状態は左側の作成によって更新された値を利用する
- もし右側の値を作成できた場合、次のように分岐する
- 右側の値を作成した後の状態が偶数であるとき、左側の値を利用する。このとき次の状態は左側の作成によって更新された値を利用する
- 右側の値を作成した後の状態が奇数であるとき、右側の値を利用する。このとき次の状態は最終的な状態から左側の値を作成によって発生した更新を削除する
このようにして最終的な値を得ることができる。
まとめ
型クラスとマクロを利用することで、テストなどで必要となるダミー値を簡単に作成することができた。ダミー値の作成に必要な時間を短縮してテストをより効率的に書けるのではないかと思われる。