(cache)Property Based Testing でドメインロジックをテストする
+ - 0:00:00
Notes for current slide
Notes for next slide

Property Based Testing で
ドメインロジックを
テストする

Scala関西Summit 2017/09/09

1 / 45

自己紹介

  • 中村 学(Nakamura Manabu)
  • @gakuzzzz
  • Tech to Value 代表取締役
  • Opt Technologies 技術顧問 Opt Technologies
  • f-code チーフアーキテクト f-code
2 / 45

テスト、書いてますか?

3 / 45

型 > テスト

偉い人は言いました

テストで示せるのはバグの存在であって、
バグの不在は証明できない

型システムはある種のバグの不在を証明できる

4 / 45

そうは言っても

値に関するロジックのテストは必要

5 / 45

テストの困りごと

6 / 45

良くあるケース1

ReadStatus: Unread, Read があるとして、
新着表示の時は ReadStatus: Unread のものだけ取得する

case class Message(id: Long, read: ReadStatus)
7 / 45

良くあるケース1

// fixture
Message(1, ReadStatus.Unread)
Message(2, ReadStatus.Unread)
Message(3, ReadStatus.Read)
Message(4, ReadStatus.Read)
it("新着表示の時は ReadStatus: Unread のものだけ取得する") {
val actual = listNewMessages()
assert(actual === List(
Message(1, ReadStatus.Unread),
Message(2, ReadStatus.Unread)
))
}
8 / 45

良くあるケース1

機能改修 Category の追加

case class Message(
id: Long,
read: ReadStatus,
category: Category = Category.Unknown
)
9 / 45

良くあるケース1

追加したCategoryのテストのためにレコード追加

// fixture
Message(1, ReadStatus.Unread, Category.Mathematics)
Message(2, ReadStatus.Unread, Category.Physics)
Message(3, ReadStatus.Read, Category.Mathematics)
Message(4, ReadStatus.Read, Category.Physics)
Message(5, ReadStatus.Unread, Category.Chemistry)
Message(6, ReadStatus.Read, Category.Chemistry)
10 / 45

良くあるケース1

結果 FooStatus: A のレコードが増えて既存テストが fail !!

it("新着表示の時は ReadStatus: Unread のものだけ取得する") {
val actual = listNewMessages()
assert(actual === List( // 失敗
Message(1, ReadStatus.Unread),
Message(2, ReadStatus.Unread)
))
}
11 / 45

良くあるケース 2

fixtures を利用して、全てのテストでテストデータを共通化してるからさっきみたいな事が起こるのだ

テスト毎に専用のデータを用意すればテストの独立性が保たれる!

12 / 45

良くあるケース 2

機能改修が入りました
テーブルαにカラムが追加になります

13 / 45

全テストメソッド毎のデータを
全て修正する必要が!

14 / 45

こうしてテストが書かれない改修が増えていく……

15 / 45

テストデータ管理したくない!

16 / 45

そこで Property Based Testing

17 / 45

Property Based Testing とは

テストデータをランダムに半自動生成して、
その生成された全ての値について、
満たすべき性質を満たしているかテストする

例)
property("Listのreverseを2回行うと元のListに一致する") {
forAll { (list: List[String]) =>
assert(list.reverse.reverse === list)
}
}
18 / 45

Property Based Testing とは

Haskell だと QuickCheck
Scala だと scalapropsScalaCheck という
ライブラリが有名

19 / 45

テストデータを半自動生成とは

乱数をもとに値を生成する Generator を定義する

case class User(name: String, age: Int)
val userGen: Gen[User] = for {
name <- Gen.alphaNumStr
age <- Gen.coose(0, 150)
} yield User(name, age)
it("全てのユーザは必ず空文字ではない名前を持つ") {
forAll { (user: User) =>
assert(user.name.nonEmpty)
}
}
20 / 45

テストデータを半自動生成とは

case class User(name: String, age: Int)
val userGen: Gen[User] = for {
name <- Gen.alphaNumStr
age <- Gen.coose(0, 150)
} yield User(name, age)

標準で様々なGeneratorが提供されていて、Generatorは合成可能なため、 独自定義したエンティティなどのGeneratorも容易に定義できる

21 / 45

データの生成方法だけ定義すればよいので、
仕様変更や改修でフィールドが増減しても
その生成方法だけ変更すれば OK

22 / 45

でも性質のテストと言われても……

難しい数学的なライブラリとかじゃないと
使い処がないのでは?

23 / 45

そんなことないよ

24 / 45

普通のテスト

it("クレジットの期限が今日より前ならば、決済結果は期限切れとなる") {
val card = CreditCard(
number = "XXXX-XXXX-XXXX-XXXX",
limit = YearMonth(1999,10),
holder = "MANABU NAKAMURA"
)
val order = Order(Item(id = 10, name = "Coffee") -> 3)
assert(card.charge(order) === ChargeResult.Expired)
}
25 / 45

Property Based Testing してみる

it("クレジットの期限が今日より前ならば、決済結果は期限切れとなる") {
forAll { (card: CreditCard, order: Order) =>
whenever(card.limit < today) {
assert(card.charge(order) === ChargeResult.Expired)
}
}
}
26 / 45

ドメインロジックで
「普遍的な性質」
を探そうとすると難しい

27 / 45

「特定条件の時に満たす性質」
であれば見つけやすい

28 / 45

特定条件の表し方

29 / 45

特定条件の表し方は複数ある

  • 性質を定義する際にfilterする
  • 専用のGenを定義する
  • 生成された値に手を加える
30 / 45

性質を定義する際にfilterする

it("クレジットの期限が今日より前ならば、決済結果は期限切れとなる") {
forAll { (card: CreditCard, order: Order) =>
whenever(card.limit < today) {
assert(card.charge(order) === ChargeResult.Expired)
}
}
}
  • 一番手軽
  • 条件によってはテストの実行に時間がかかる場合が出てくる
31 / 45

専用のGenを定義する

val expiredCardGen = for {
num1 <- Gen.choose(0, 9999)
num2 <- Gen.choose(0, 9999)
num3 <- Gen.choose(0, 9999)
num4 <- Gen.choose(0, 9999)
holder <- Gen.alphaNumStr
diff <- Gen.posNum
} yield CredicCard(
f"$num1%04d-$num2%04d-$num3%04d-$num4%04d"
today - diff.months
holder
)
it("クレジットの期限が今日より前ならば、決済結果は期限切れとなる") {
forAll(expiredCardGen, orderGen) {
(card: CreditCard, order: Order) =>
assert(card.charge(order) === ChargeResult.Expired)
}
}
32 / 45

専用のGenを定義する

it("クレジットの期限が今日より前ならば、決済結果は期限切れとなる") {
forAll(expiredCardGen, orderGen) {
(card: CreditCard, order: Order) =>
assert(card.charge(order) === ChargeResult.Expired)
}
}
  • 複数のテストで繰り返し使われる条件の時便利
  • あまりに多くなるとテスト事にテストデータを定義しているのと変わらなくなり、仕様変更時に全部直すのがつらくなる
33 / 45

生成された値に手を加える

it("クレジットの期限が今日より前ならば、決済結果は期限切れとなる") {
forAll { (card: CreditCard, order: Order) =>
val expiredCard = card.copy(limit = today - 1.month)
assert(expiredCard.charge(order) === ChargeResult.Expired)
}
}
  • これも手軽
  • テスト失敗時に出力される値と実際にロジックに渡した値が異なるのでテスト失敗時に読みにくい場合がある(大抵は手を加えた値以外が原因のため問題にならない)
34 / 45

生成された値に手を加える

case class ZipCode(major: Int, minor: Int)
case class Address(prefecture: String, city: String, zip: ZipCode)
case class Person(name: String, address: Address)
forAll { (person: Person) =>
val targetParson = person.copy(
address = person.address.copy(
zip = person.address.zip.copy(
major = 101
)
)
)
...
}
  • 複雑な構造のオブジェクトを変更したい場合は煩雑になる
35 / 45

生成された値に手を加える

こういう場合 Lens ライブラリが便利

import monocle.macros.syntax.lens._
val majorLens =
forAll { (person: Person) =>
val targetParson = person.lens(_.address.zip.major).set(101)
...
}

※これは Monocle の例
※Lensは様々なライブラリがあり ShapelessScalaz にもある

36 / 45

特定条件の表し方は複数ある

  • 性質を定義する際にfilterする
  • 専用のGenを定義する
  • 生成された値に手を加える
37 / 45

その他ポイント

  • 制約を型で表現するとGenの定義が楽
  • 暗黙的に期待している制約に注意
38 / 45

制約を型で表現する

型で表現しない例

case class User(name: String, isLeader: Boolean)
case class Group(name: String, members: List[User]) {
require(memberes.count(_.isLeader) == 1)
// リーダーは一人だけ必ず存在するというのをロジックで明示
}
val userGen = ...
val groupGen = for {
name <- Gen.alphaNumStr
members <- Gen.listOf(userGen).suchThat(_.count(_.isLeader)==1)
} yield Group(name, members)

テストデータの生成試行回数が増える

39 / 45

制約を型で表現する

型で表現しない例(別パターン)

case class User(name: String, isLeader: Boolean)
case class Group(name: String, members: List[User]) {
require(memberes.count(_.isLeader) == 1)
// リーダーは一人だけ必ず存在するというのをロジックで明示
}
val groupGen = for {
name <- Gen.alphaNumStr
leader <- userGen
others <- Gen.listOf(userGen)
} yield Group(
name,
leader.copy(isLeader=true) +: others.map(_.copy(isLeader=false))
)
40 / 45

制約を型で表現する

型で表現する例

case class User(name: String)
case class Group(name: String, leader: User, members: List[User])
val userGen = ...
val groupGen = for {
name <- Gen.alphaNumStr
leader <- userGen
members <- Gen.listOf(userGen)
} yield Group(name, memberes)
  • すっきり
41 / 45

暗黙的に期待している制約に注意

例)DBで採番しているIDがユニークであること

42 / 45

暗黙的に期待している制約に注意

case class User(id: Int, name: String)
case class Group(id: Int, name: String, users: List[User])
val userGen = for {
id <- Gen.posNum[Int]
name <- Gen.asciiPrintableStr
} yield User(id, name)
val groupGen = for {
id <- Gen.posNum[Int]
name <- Gen.asciiPrintableStr
users <- Gen.listOf(userGen)
} yield Group(id, name, users)
forAll { (group: Group) =>
group.users // 同じIDを持つUserがList中に含まれる可能性!
...
}
43 / 45

まとめ

  • テストデータの管理は大変
  • Property Based Testing でデータを半自動生成
  • 「特定条件時に満たす性質」は見つけやすい
  • 制約を型で表現すると捗る
  • 暗黙的に期待している制約に注意
  • ドメインロジックのテストにも
    Property Based Testing は使える
44 / 45

質問とか

45 / 45

自己紹介

  • 中村 学(Nakamura Manabu)
  • @gakuzzzz
  • Tech to Value 代表取締役
  • Opt Technologies 技術顧問 Opt Technologies
  • f-code チーフアーキテクト f-code
2 / 45
Paused

Help

Keyboard shortcuts

, , Pg Up, k Go to previous slide
, , Pg Dn, Space, j Go to next slide
Home Go to first slide
End Go to last slide
Number + Return Go to specific slide
b / m / f Toggle blackout / mirrored / fullscreen mode
c Clone slideshow
p Toggle presenter mode
t Restart the presentation timer
?, h Toggle this help
Esc Back to slideshow