プロパティベースのテストコーディングの練習として、ベックの TDD本を題材に Scala と ScalaCheck でやってみた記録。OOP と FP の感覚の違いについてもちょっと考えてみる。
##方針など
- 『Test Driven Development: By Example』1 をテキストにした
- テキストの中のテストコードはアサーションベースで書かれていて、いわゆる「特殊解2」を並べたテストコードになっているが、ここでは「一般解」を意識してプロパティとして記述してみる。
- なるべくテキストに追従するが、必ずしも記載通りの手順をなぞってはいない。ただしTDDのステップを細かく刻んでいくスタイルは踏襲する。
- なるべくプロパティを先に考えるようにして、いわばプロパティファースト、プロパティ駆動のような段取りを意識する。
- 各段階のソースを gist に置いた。依存ライブラリは以下の感じ。
- "org.typelevel" % "cats-core_2.12" % "1.0.0-MF"
- "org.scalacheck" %% "scalacheck" % "1.13.4" % "test"
##試行
###1. Multi-Currency Money
- Dollarクラス(のちの Money クラス)の叩き台。テキストにならって、あえてひとまずミュータブルに実装したが、後でイミュータブルな実装にリファクタする。
class Dollar(var amount: Int) {
def times(multiplier: Int): Unit = amount = amount * multiplier
}
プロパティ
times
はドルを整数倍するメソッドで、、、
- 0ドルを x倍したものは常に 0ドル (xは整数)
- 1ドルを x倍したものは常に xドル (xは整数)
- aドルの b倍と bドルの a倍は等しい (a, bは整数)
- aドルの b倍を c倍したものと、aドルを b × c倍したものは等しい (a, b, cは整数)
property("$0 * x = $0") = forAll { (x: Int) =>
val zero: Dollar = new Dollar(0)
zero.times(x)
zero.amount == 0
}
property("$1 * x = $x") = forAll { (x: Int) =>
val five: Dollar = new Dollar(1)
five.times(x)
five.amount == x
}
property("$a * b = $b * a") = forAll { (a: Int, b: Int) =>
val d1: Dollar = new Dollar(a)
d1.times(b)
val d2: Dollar = new Dollar(b)
d2.times(a)
d1.amount == d2.amount
}
property("($a * b) * c = $a * (b * c)") = forAll { (a: Int, b: Int, c: Int) =>
val d1: Dollar = new Dollar(a)
d1.times(b)
d1.times(c)
val d2: Dollar = new Dollar(a)
d2.times(b * c)
d1.amount == d2.amount
}
Dollar がミュータブルなせいもあってか、だいぶモッサリしている。
gist: ch01 mumti-currency money
###2. Degenerate Objects
プロパティは変えないままDollar
をイミュータブルにし、テストコードも合わせて変更。乗算の関数は *
をオーバーロードした。これ以降、プロダクトコードの変更過程には触れず、最終段階のみ最後に示すことにする。
case class Dollar(amount: Int) {
def *(multiplier: Int): Dollar = Dollar(amount * multiplier)
}
プロパティの実装もだいぶスッキリした
property("$0 * x = $0") = forAll { (x: Int) =>
Dollar(0) * x == Dollar(0)
}
property("$1 * x = $x") = forAll { (x: Int) =>
Dollar(1) * x == Dollar(x)
}
property("$a * b = $b * a") = forAll { (a: Int, b: Int) =>
Dollar(a) * b == Dollar(b) * a
}
property("($a * b) * c = $a * (b * c)") = forAll { (a: Int, b: Int, c: Int) =>
(Dollar(a) * b) * c == Dollar(a) * (b * c)
}
###3. Equality for All
equals 実装の章。
プロパティ
- aドル == bドル ⇔ a == b (ドル同士の等値性と金額を表す数の等値性は同値)
- aドル == bドル ⇔ (aドル equals bドル) (
==
とequals
は同じ) -
equals
の基本のプロパティ - 反射率:aドル == aドル
- 対象律:aドル == bドル ⇔ bドル == aドル
- 推移律:aドル == bドル ∧ bドル == cドル ⇒ aドル == cドル
property("$a == $b <=> a == b") = forAll { (a: Int, b: Int) =>
(Dollar(a) == Dollar(b)) == (a == b)
}
property("$a == $b <=> $a.equals($b)") = forAll(dollar2) { case (a, b) =>
(a equals b) == (a == b)
}
property("reflexive law") = forAll(dollars) { (a) =>
a equals a
}
property("symmetric law") = forAll(dollar2) { case (a, b) =>
(a equals b) == (b equals a)
}
property("transitive law") = forAll( for {
oneOf3 <- unique3.map { case (a, b, c)=> Gen.oneOf(a, b, c) }
x <- oneOf3
y <- oneOf3.suchThat(_ equals x)
z <- oneOf3.retryUntil(_ equals y)
} yield (x, z)) { case ((x, z)) => x equals z }
推移律だけ少しややこしい。ここでは異なる3つの数から、まず任意の $x$ を選び、次に $x$ と等しい $y$ を選び、さらに $y$ と等しい $z$ を選ぶと、全ての $x$ , $z$ の組について等号が成立ことを書き下した。ヘルパ用のテストコードも若干書き足している。
###4. Privacy
amount
を privateとしたが、プロパティには影響なし。
以下のようにcats
を用いてテストコードを少しリファクタした。例えば下のような変更前コードに対して、
private def dollar2 = for {
a <- dollars
b <- dollars
} yield (a, b)
private def dollar3 = for {
a <- dollars
b <- dollars
c <- dollars
} yield (a, b, c)
cats
のApply
インスタンスを下のように定義しておけば、
implicit val genApply: Apply[Gen] = new Apply[Gen] {
override def ap[A, B](gf: Gen[A => B])(ga: Gen[A]) = gf flatMap ga.map
override def map[A, B](ga: Gen[A])(f: A => B) = ga map f
}
cats.syntax.apply._を用いて以下のように置き換えられ、dollarのGen以外にも使えるようになる。
def double[G[_]: Apply, A](ga: G[A]): G[(A, A)] = (ga, ga).mapN((_, _))
def triple[G[_]: Apply, A](ga: G[A]): G[(A, A, A)] = (ga, ga, ga).mapN((_, _, _))
###5. Franc-ly Speaking
プロダクトコードでは、ほぼ Dollar
からのコピペで Franc
を追加。重複コードはテキストの通りあとで取り除く。
プロパティとしては、「特殊解」 CHF * 2 = 10 CHFのみ記述。
property("5 CHF * 2 = 10 CHF") = Prop {
Franc(5) * 2 == Franc(10)
}
プログラミングを前に進めるという意味では、forAll
による一般的性質の記述ではなく、こんな感じでProp
で「特殊解」だけ書いてとりあえず先に進むのもありだと思う(あとで直す前提で)。
###6. Equality for All, Redux
プロパティは変えないままDollar
とFranc
で共通の基底クラスMoney
クラスを作り、equals
を引き上げた
gist: ch06 equality for all, redux
###7. Apples and Oranges
この章では通貨が異なる場合を含むequals
を実装。
まず「amount
が等しいMoney
の等値性は、Money
の型の等値性と同値」というプロパティを記述(getClass がまずいのはあとで直す)。
property("(m1 == m2) == (m1.getClass == m2.getClass)") =
forAll(double(consGen), intGen) { case ((c1, c2), n) =>
val m1 = c1(n)
val m2 = c2(n)
(m1 == m2) == (m1.getClass == m2.getClass)
}
テストヘルパとして、整数から任意のMoney
型のコンストラクタを生成するGen
なども書いた。
###8. Makin' Objects
この章ではプロパティの中でDollar
型固定で書いてた部分を、任意の通貨のMoney
を用いるようにして一般化した。このため 5章で書いたFranc
のための特殊解テストも無用になり削除した。
property("m(0) * x = m(0)") = forAll { (x: Int, m: Int => Money) =>
m(0) * x == m(0)
}
property("m(1) * x = m(x)") = forAll { (x: Int, m: Int => Money) =>
m(1) * x == m(x)
}
property("m(a) * b = m(b) * a") = forAll { (a: Int, b: Int, m: Int => Money) =>
m(a) * b == m(b) * a
}
property("(m(a) * b) * c = m(a) * (b * c)") = forAll { (a: Int, b: Int, c: Int, m: Int => Money) =>
(m(a) * b) * c == m(a) * (b * c)
}
...など
任意の通貨のMoney
型のオブジェクトを生成する Genも書いた。
implicit val intGen: Gen[Int] = Gen.chooseNum(-1000000, 1000000)
implicit val consGen: Gen[Int => Money] = Gen.oneOf(Dollar(_:Int): Money, Franc(_:Int): Money)
implicit val moneyGen: Gen[Money] = (consGen, intGen).mapN(_(_))
###9. Times We're Living in
通貨フィールドを追加する章. 等値性テストにも通貨を加味。
プロパティの中でも getClass
など 'smelly'なコードを、Money#currency
へのアクセスに替えた。
property("(m1==m2) == (m1.currency==m2.currency)") =
forAll(double(currencyGen), intGen) { case ((c1, c2), n) =>
val (m1, m2) = mapPair(c1, c2)(constructor(_)(n))
(m1 == m2) == (m1.currency == m2.currency)
}
gist: ch09 times we're living in
###10. Interesting Times - 11. the Root of All Evil
Dollar
とFranc
の重複要素をMoney
に引き上げて、不要になったDollar
/Franc
を削除。プロパティはそのまま。(10章と11章をひとまとめに書いた.)
gist: ch10 interesting times- ch11 the root of all evil
###12. Addition Finally
加算の実装、Bankクラス追加、Expressionクラスのダミー実装の章
プロパティ
整数 $a$, $b$ をそれぞれ Money にしてから足し合わせた Money と、整数 $a + b$ を Money にしたものは等しい3。
property("money(a + b) == money(a) + money(b)") =
forAll(intGen, intGen, moneyConsGen) { (a, b, money) =>
Money.dollar(10) == new Bank().reduce(money(a) + money(b), "USD")
}
Bank#reduce
(通貨換算メソッド)について、ここでは差し当たり常に10ドルを返すダミー実装とした。
###13. Make it
プロダクトコードでは Bank
の reduce
を Expression
に移譲するように修正。
プロパティ
前章のダミーコードを直した上で、さらに一つ追加。
- 2数
a
,b
を足してからMoney
にしても、Money
にしてから足しても同じ -
Money
オブジェクトをそれ自身の通貨でreduce
したものはそれ自身に等しい
property("m(a + b) == m(a) + m(b)") =
forAll(double(intGen), moneyConsGen) { case ((a, b), m) =>
m(a + b) equiv m(a) + m(b)
}
property("m == reduce(m, m.currency)") =
forAll(intGen, moneyConsGen) { (n, m) =>
reduce(m(n)) == m(n)
}
###14. Change
reduce 完成の章。
プロパティ
- 「c1 : c2 == r」 ならば 「reduce(c1(n)) == c2(n / r)」
- 例) c1→フラン、c2→ドル、c1:c2=r=2、n=10 とすると、10フランをレート2:1でドル換算したものと(10/2)ドルは等しい
- 同じ通貨間の変換レートは1
property("reduce(c1(n)) == c2(n / rate)") =
forAll(intGen, rateGen) { case (n, rate@((src, dst), r)) =>
val (c1, c2) = mapPair(src, dst)(constructor)
val reduce = (e: Expression) => bank(rate).reduce(e, dst)
reduce(c1(n)) == c2(n / r)
}
property("identity rate") = forAll(currencyGen) { c: String =>
new Bank().rate(c, c) == 1
}
###15. Mixed Concurrencies - 16. Abstraction Finally
15-16章では通貨が異なる場合をふくむ加算を実装(作業の都合上15章と16章をまとめた)。
プロパティ
- reduce(c1(n1) + c2(n2)) == c2(n1 / r + n2)
- 例) フランとドルの比が2:1のとき、10フランと5ドルを足し合わせたものをドルに換算したものは、(10/2+5)ドルに等しい。(c1→フラン, c2→ドル, r→2, n1→10, n2→5)
- reduce(x + y) equiv reduce(x) + y
- $x$ が換算元のお金、 $y$ が換算先のお金の場合、 $x$ と $y$ を足してから $y$ の通貨にならしても、 $x$ を $y$ の通貨に合わせてから足しても同じ
- 結合則: (x + y) + z = x + (y + z)・・・(異なる通貨を含む)
- 分配則: x * m + y * m = (x + y) * m ・・・(異なる通貨を含む)
property("reduce(c1(n1) + c2(n2)) == c2(n1 / r + n2)") =
forAll(intGen, intGen, rateGen) { case (n1, n2, rate@((src, dst), r)) =>
val (c1, c2) = mapPair(src, dst)(constructor)
val reduce = (e: Expression) => bank(rate).reduce(e, dst)
reduce(c1(n1) + c2(n2)) == c2(n1 / r + n2)
}
property("reduce(x + y) equiv reduce(x) + y") =
forAll(intGen, intGen, rateGen) { case (n1, n2, ((src, dst), r)) =>
val reduce = (e: Expression) => new Bank().addRate(src -> dst, r).reduce(e, dst)
val x = constructor(src)(n1)
val y = constructor(dst)(n2)
reduce(x + y) equiv reduce(x) + y
}
property("(x + y) + z = x + (y + z)") =
forAll(triple(moneyGen), rateGen) { case ((x, y, z), ((src, dst), r)) =>
implicit val _ = compair(new Bank().addRate(src -> dst, r), dst) _
(x + y) + z ==== x + (y + z)
}
property("x * m + y * m = (x + y) * m") =
forAll(double(moneyGen), intGen, rateGen) { case ((x, y), m, ((src, dst), r)) =>
implicit val _ = compair(new Bank().addRate(src -> dst, r), dst) _
x * m + y * m ==== (x + y) * m
}
gist: ch15 mixed concurrencies - ch16 abstraction finally
###プロダクトコード
16章終わりの時点のプロダクトコードの状態。
trait Expression {
def *(multiplier: Int): Expression
def +(addend: Expression): Expression
def reduce(bank: Bank, to: String): Money
}
class Money(private[Chapter16] val amount: Int, val currency: String) extends Expression {
def *(multiplier: Int): Money = Money(amount * multiplier, currency)
def +(addend: Expression): Expression = Sum(this, addend)
def reduce(bank: Bank, to: String): Money =
Money(amount / bank.rate(currency -> to), to)
override def equals(a: Any): Boolean = a match { case another: Money =>
another.amount == amount &&
another.currency == currency
}
}
case class Sum(augend: Expression, append: Expression) extends Expression {
def *(multiplier: Int): Expression = Sum(augend * multiplier, append * multiplier)
def +(addend: Expression): Expression = Sum(this, addend)
def reduce(bank: Bank, to: String): Money = {
val amount = augend.reduce(bank, to).amount + append.reduce(bank, to).amount
Money(amount, to)
}
}
object Money {
def apply(amount: Int, currency: String) = new Money(amount, currency)
def dollar(amount: Int): Money = Money(amount, "USD")
def franc(amount: Int): Money = Money(amount, "CHF")
}
type CurrencyPair = (String, String)
class Bank(private val rates: Map[CurrencyPair, Int] = Map.empty) {
def reduce(source: Expression, to: String): Money = source.reduce(this, to)
def addRate(fromTo: CurrencyPair, rate: Int): Bank = new Bank(rates + (fromTo -> rate))
def rate(fromTo: CurrencyPair): Int = rates.getOrElse(fromTo, 1)
}
以下、プロダクトコードで気になる点
-
Money
がExpression
をextends
しているのが気になる。Scalaで FPするなら型クラスなどを利用した方がいいかもしれない。 -
Expression
以下のクラスがreduce
の引数としてBank
を参照しているのが気になる。FPでいくなら、Reader
モナドなどを用いて計算の「文脈」として換算レートが与えられるなどの方が、Bank
のメタファーよりも自然かもしれない。 -
amount
は別にprivate
じゃなくても良い気がする。ただのcase class
でよければ、プロダクトコードもプロパティベーステストも格段にシンプルになる。 - (通貨を String型で表していることや、レートや金額などの数値の型が適当なことなども気になるが、プロパティベースTDD練習用のお題だからこんなもので良しとする。)
##所感
- OOP色が濃い題材をFP寄りの手法でやったのでいろいろ無理があり、最後のプロダクトコードにもパラダイムのギャップの違い4がにじんでる気がするが、最初からFPでまっすぐ書けばコーディングの過程も結果のコードもスッキリしたものになったと思う。
- 2002年時点で既にOOPの大家だった著者が、TDD指南の題材としてFPとも親和性が高そうな、イミュータブルで演算が閉じている5テスト対象を選んでいるのが、現代の視点で見てもちょっと面白い。
- 汎用性高そうでイディオマティックなプロパティが結構多い。普通の数学の代数的な法則なども頭の引き出しに用意しておくとはかどりそう。
- 思いついたプロパティが正しいか、あるいは不十分だったり冗長だったりしていないか判断が難しいが、あまりここで悩みすぎても [Analysis Paralysis] (https://en.wikipedia.org/wiki/Analysis_paralysis) に陥りそうなので、適当な見切りが必要かもしれない。
- ScalaCheck自体、あまり勉強してないので、もうちょい慣れが必要。
suchThat
でgive up
したら、retryUntil
を使えば何とかなる場合があることなどは今回学んだ。(参考:29 GIFs Only ScalaCheck Witches Will Understand)
-
プロダクトコードは「一般解」、テストコードは「特殊解」を扱うべしとするガイドライン。日本での JUnit普及期に故石井勝氏が提唱したもの。 ↩
-
例えば $\log(a \times b)= \log(a) + \log(b)$のような、線形代数などでおなじみの準同型ぽいプロパティが得られる。 ↩
-
wikipedia によれば、命令型に手続き型とオブジェクト指向が含まれ、宣言型に関数型と論理型が含まれる。なのでOOとFPは同一階層の異なるパラダイムではなく一階層上ですでに別物なので、より一層根本的な発想の違いがある。 ↩
-
DDD の Declarative Style に通じるものがある。Side effect free functions や Closure of Operation など。 ↩