LoginSignup
2

More than 3 years have passed since last update.

ベックのTDD本のMoneyサンプルをプロパティベースでやってみた

Last updated at Posted at 2017-12-15

プロパティベースのテストコーディングの練習として、ベックの 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)
}

gist: ch02 degenerate objects

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$ の組について等号が成立ことを書き下した。ヘルパ用のテストコードも若干書き足している。

gist: ch03 equality for all

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)

catsApplyインスタンスを下のように定義しておけば、

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((_, _, _))

gist: ch04 privacy

5. Franc-ly Speaking

プロダクトコードでは、ほぼ Dollar からのコピペで Franc を追加。重複コードはテキストの通りあとで取り除く。

プロパティとしては、「特殊解」 CHF * 2 = 10 CHFのみ記述。

property("5 CHF * 2 = 10 CHF") = Prop {
  Franc(5) * 2 == Franc(10)
}

プログラミングを前に進めるという意味では、forAllによる一般的性質の記述ではなく、こんな感じでPropで「特殊解」だけ書いてとりあえず先に進むのもありだと思う(あとで直す前提で)。

ch5 franc-ly speaking

6. Equality for All, Redux

プロパティは変えないままDollarFrancで共通の基底クラス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なども書いた。

gist: ch07 apples and oranges

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(_(_))

gist: ch08 makin' objects

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

DollarFrancの重複要素を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ドルを返すダミー実装とした。

gist: ch12 addition finally

13. Make it

プロダクトコードでは BankreduceExpression に移譲するように修正。

プロパティ

前章のダミーコードを直した上で、さらに一つ追加。

  • 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)
  }

gist: ch14 make it

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
}

gist: ch14 change

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)
}

以下、プロダクトコードで気になる点

  • MoneyExpressionextends しているのが気になる。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 に陥りそうなので、適当な見切りが必要かもしれない。
  • ScalaCheck自体、あまり勉強してないので、もうちょい慣れが必要。suchThatgive upしたら、retryUntilを使えば何とかなる場合があることなどは今回学んだ。(参考:29 GIFs Only ScalaCheck Witches Will Understand

  1. "Test Driven Development: By Example"(Kent Beck 2002/11) 

  2. プロダクトコードは「一般解」、テストコードは「特殊解」を扱うべしとするガイドライン。日本での JUnit普及期に故石井勝氏が提唱したもの。 

  3. 例えば $\log(a \times b)= \log(a) + \log(b)$のような、線形代数などでおなじみの準同型ぽいプロパティが得られる。 

  4. wikipedia によれば、命令型に手続き型とオブジェクト指向が含まれ、宣言型に関数型と論理型が含まれる。なのでOOとFPは同一階層の異なるパラダイムではなく一階層上ですでに別物なので、より一層根本的な発想の違いがある。 

  5. DDD の Declarative Style に通じるものがある。Side effect free functions や Closure of Operation など。 

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
2