前回
さて Kotlin で『テスト駆動開発』を写経するシリーズも Part I が今回で終わるので一区切りとなる。前回で書籍のコードから設計方針を転換しているので、1つ1つのタスクに対してどのように方針を立て実装していくか、きちんと過程を残すように書きたいと思う。
現在のコード:
MoneyTest.kt
class MoneyTest { @Test fun testMultiplication() { val five = dollar(5) assertEquals(dollar(10), five * 2) assertEquals(dollar(15), five * 3) } @Test fun testEquality() { assertTrue(dollar(5) == dollar(5)) assertFalse(dollar(5) == dollar(6)) assertFalse(dollar(5) == franc(5)) } @Test fun testCurrency() { assertEquals("USD", dollar(1).currency) assertEquals("CHF", franc(1).currency) } @Test fun testSimpleAddition() { val five = dollar(5) val sum = five + five val reduced = reduce(sum, "USD") assertEquals(dollar(10), reduced) } @Test fun testPlusReturnsSum() { val five = dollar(5) val result = five + five val sum = result as Sum assertEquals(five, sum.augend) assertEquals(five, sum.addend) } @Test fun testReduceSum() { val sum = Sum(dollar(3), dollar(4)) val result = reduce(sum, "USD") assertEquals(dollar(7), result) } @Test fun testReduceMoney() { val result = reduce(dollar(1), "USD") assertEquals(dollar(1), result) } }
Bank.kt
fun reduce(source: Expression, to: String) = when (source) { is Money -> source is Sum -> Money(sum(source.augend, source.addend, to), to) } private fun sum(augend: Expression, addend: Expression, to: String): Int = reduce(augend, to).amount + reduce(addend, to).amount
Money.kt
data class Money(val amount: Int, val currency: String) : Expression() { operator fun times(multiplier: Int) = Money(amount * multiplier, currency) operator fun plus(addend: Money): Expression = Sum(this, addend) companion object { fun dollar(amount: Int) = Money(amount, "USD") fun franc(amount: Int) = Money(amount, "CHF") } } sealed class Expression { data class Sum(val augend: Money, val addend: Money) : Expression() }
TODO リスト:
- [ ]
$5 + 10CHF = $10
(レートが 2:1 の場合) - [ ]
$5 + $5 = $10
- [ ]
$5 + $5
がMoney
を返す - [x]
Bank.reduce(Money)
- [ ]
Money
を変換して換算を行う - [x]
Reduce(Bank, String)
第14章 学習用テストと回帰テスト
この章ではまず「Money
を変換して換算を行う」に取り組む。まずはCHF -> USDの換算に関するテストを追加。
class MoneyTest { // ... @Test fun testReduceMoneyDifferentCurrency() { addRate("CHF", "USD", 2) val result = bank.reduce(franc(2), "USD") assertEquals(dollar(1), result) } }
仮実装の段階なのでとりあえず Bank.kt に addRate()
を追加する。
fun addRate(from: String, to: String, rate: Int) {}
このあと書籍では換算処理の記述を Money.reduce()
から始めて Bank
の中に持っていくために色々するが、現在の私の実装では該当する reduce()
の処理は Bank.kt の reduce()
の中に存在するので、いきなり Bank.kt に rate()
を作って reduce()
の中で呼ぶように書くだけでよい。
fun reduce(source: Expression, to: String) = when (source) { is Money -> Money(source.amount / rate(source.currency, to), to) is Sum -> Money(sum(source.augend, source.addend, to), to) } private fun rate(from: String, to: String) = if (from == "CHF" && to == "USD") 2 else 1
配列の比較は省略。このあと Pair
クラスの作成にかかるが Kotlin には標準で Pair
を持っているのでそれを使用することにする。Pair
を key, 為替レートを value とする map を Bank
に保持させることになり、ここで Bank
が状態を保存しておく必要が出てきたので Bank
クラスを作成し、これまで Bank.kt に定義していた関数群を Bank
のメンバとして持たせるよう変更する。
class Bank { private val rates: MutableMap<Pair<String, String>, Int> = HashMap() fun reduce(source: Expression, to: String) = when (source) { is Money -> Money(source.amount / rate(source.currency, to), to) is Expression.Sum -> Money(sum(source.augend, source.addend, to), to) } private fun rate(from: String, to: String) = if (from == "CHF" && to == "USD") 2 else 1 private fun sum(augend: Expression, addend: Expression, to: String): Int = reduce(augend, to).amount + reduce(addend, to).amount fun addRate(from: String, to: String, rate: Int) {} }
Bank
を作って関数を中に放り込んだためこれまで reduce()
を読んでたところがコンパイルエラーになったので修正する。
class MoneyTest { // ... @Test fun testSimpleAddition() { val five = dollar(5) val sum = five + dollar(5) val reduced = Bank().reduce(sum, "USD") assertEquals(dollar(10), reduced) } // ... @Test fun testReduceSum() { val sum = Expression.Sum(dollar(3), dollar(4)) val result = Bank().reduce(sum, "USD") assertEquals(dollar(7), result) } @Test fun testReduceMoney() { val result = Bank().reduce(dollar(1), "USD") assertEquals(dollar(1), result) } @Test fun testReduceMoneyDifferentCurrency() { val bank = Bank() bank.addRate("CHF", "USD", 2) val result = bank.reduce(franc(2), "USD") assertEquals(dollar(1), result) } }
テストが通るか確認をして、addRate()
で実際に為替レートを格納する処理、および rate()
で為替レートを取得を実装する。
class Bank { // ... private fun rate(from: String, to: String) = rates[Pair(from, to)] ?: throw IllegalArgumentException("Unregistered rate: $from to $to") // ... fun addRate(from: String, to: String, rate: Int) { rates.put(Pair(from, to), rate) } }
Map.get()
の返却値は V?
(今回の場合 Int?
) なので、為替レートが未登録の場合は例外を投げるか rate()
を rate(): Int?
として定義するか適当なデフォルト値を返すがということになるが、内部処理で使用しているのでここで null
を返されても困るし、例外設計は写経の本筋と離れるので今回は例外を投げるということにしておく。ここでテストを実行すると testReduceMoney()
がレッドになる。同じ通貨への為替レートを返さないといけないので、回帰テストを追加してから対応する。
class MoneyTest { // ... @Test fun testIdentityRate() { assertEquals(1, Bank().rate("USD", "USD")) } }
class Bank { // ... fun rate(from: String, to: String) = if (from == to) 1 else rates[Pair(from, to)] ?: throw IllegalArgumentException("Unregistered rate: $from to $to") // ... }
これで全テストがグリーンに戻り、14章の内容は終了。TODO リストを更新しておく:
- [ ]
$5 + 10CHF = $10
(レートが 2:1 の場合) - [x]
$5 + $5 = $10
- [ ]
$5 + $5
がMoney
を返す - [x]
Bank.reduce(Money)
- [x]
Money
を変換して換算を行う - [x]
Reduce(Bank, String)
第15章 テスト任せとコンパイラ任せ
この章で Part I の本題である $5 + 10CHF = $10
に着手することになる。
class MoneyTest { // ... @Test fun testMixedAddition() { val fiveBucks: Expression = dollar(5) val tenFrancs: Expression = franc(10) val bank = Bank() bank.addRate("CHF", "USD", 2) val result = bank.reduce(fiveBucks + tenFrancs, "USD") assertEquals(dollar(10), result) } }
$5 と 10CHF を Expression
として受けているためコンパイルエラーになる。書籍の次ステップの通り一旦これらを Money
で受けるようにするとコンパイルが通りテストが... グリーンになる。これは第13章で設計を転換した時に Bank.reduce()
の実装を Bank.sum()
を介して再帰的に書いていたためで、実はこのあとの書籍の対応と同じだったのだ。
そういうわけで先ほどやろうとした Expression.plus
の実現を進める。まずはテストケースを章の最初に示した状態に戻し、Sum
のフィールドと Money.times()
の返却値の型を Expression
にする。
data class Money(val amount: Int, val currency: String) : Expression() { operator fun times(multiplier: Int): Expression = Money(amount * multiplier, currency) operator fun plus(addend: Money): Expression = Sum(this, addend) companion object { fun dollar(amount: Int) = Money(amount, "USD") fun franc(amount: Int) = Money(amount, "CHF") } } sealed class Expression { data class Sum(val augend: Expression, val addend: Expression) : Expression() }
あとは Expression
自身が plus()
を持っていればよいので、Money
からそのまま連れてくる。
data class Money(val amount: Int, val currency: String) : Expression() { operator fun times(multiplier: Int): Expression = Money(amount * multiplier, currency) companion object { fun dollar(amount: Int) = Money(amount, "USD") fun franc(amount: Int) = Money(amount, "CHF") } } sealed class Expression { operator fun plus(addend: Money): Expression = Sum(this, addend) data class Sum(val augend: Expression, val addend: Expression) : Expression() }
最後に TODO リストを更新しておしまい:
- [x]
$5 + 10CHF = $10
(レートが 2:1 の場合) - [x]
$5 + $5 = $10
- [ ]
$5 + $5
がMoney
を返す - [x]
Bank.reduce(Money)
- [x]
Money
を変換して換算を行う - [x]
Reduce(Bank, String)
- [x]
Sum.plus
- [ ]
Expression.times
更新内容をみてわかるが、 Expression
に plus()
を持ってきた時点で Sum
もこの関数を使えるようになっているので完了してしまっている。
第16章 将来の読み手を考えたテスト
さて前章で Sum.plus
の実装も終わったことにしているが、本当にそうなのか? 本来 Sum.plus
を実装するはずだったこの章で追加されるテストで確認をする。
class MoneyTest { // ... @Test fun testSumPlusMoney() { val fiveBucks: Expression = dollar(5) val tenFrancs: Expression = franc(10) val bank = Bank() bank.addRate("CHF", "USD", 2) val sum = Expression.Sum(fiveBucks, tenFrancs) + fiveBucks val result = bank.reduce(sum, "USD") assertEquals(dollar(15), result) } }
きちんと通った。では次は Expression.times
の実装をする。
class MoneyTest { // ... @Test fun testSumTimes() { val fiveBucks: Expression = dollar(5) val tenFrancs: Expression = franc(10) val bank = Bank() bank.addRate("CHF", "USD", 2) val sum = Expression.Sum(fiveBucks, tenFrancs) * 2 val result = bank.reduce(sum, "USD") assertEquals(dollar(20), result) } }
フィクスチャーは今はスルー。書籍では Expression
はインターフェイスなので Sum
に実装を書いているが、私の実装では sealed class なので Money.times
を Expression
に持ってきて、when
で分岐させれば十分だろう。
data class Money(val amount: Int, val currency: String) : Expression() { companion object { fun dollar(amount: Int) = Money(amount, "USD") fun franc(amount: Int) = Money(amount, "CHF") } } sealed class Expression { operator fun times(multiplier: Int): Expression = when(this) { is Money -> Money(amount * multiplier, currency) is Sum -> Sum(augend * multiplier, addend * multiplier) } operator fun plus(addend: Money): Expression = Sum(this, addend) data class Sum(val augend: Expression, val addend: Expression) : Expression() }
テストも通ったのでこれでOK。
さて最後の「$5 + $5
が Money
を返す」だが、結果的に何もしないことになるので、これで第16章が終わる。
- [x]
$5 + 10CHF = $10
(レートが 2:1 の場合) - [x]
$5 + $5 = $10
- [x]
$5 + $5
がMoney
を返す - [x]
Bank.reduce(Money)
- [x]
Money
を変換して換算を行う - [x]
Reduce(Bank, String)
- [x]
Sum.plus
- [x]
Expression.times
まとめと振り返りなど
最終的な実装を以下に示す。差分を書き直すのが面倒なので GitHub のレポジトリと合わせるためにテストクラスに雑なフィクスチャー (っぽいもの) を作ったりMoney.currency
を enum class Currency
とした実装を含めたりしている。
MoneyTest.kt
package money import money.Money.Companion.dollar import money.Money.Companion.franc import money.Money.Currency.CHF import money.Money.Currency.USD import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue class MoneyTest { @Test fun testMultiplication() { val five = dollar(5) assertEquals(dollar(10), five * 2) assertEquals(dollar(15), five * 3) } @Test fun testEquality() { assertTrue(dollar(5) == dollar(5)) assertFalse(dollar(5) == dollar(6)) assertFalse(dollar(5) == franc(5)) } @Test fun testCurrency() { assertEquals(USD, dollar(1).currency) assertEquals(CHF, franc(1).currency) } @Test fun testSimpleAddition() { val five = dollar(5) val sum = five + dollar(5) val reduced = Bank().reduce(sum, USD) assertEquals(dollar(10), reduced) } @Test fun testPlusReturnSum() { val five = dollar(5) val sum = (five + five) as Expression.Sum assertEquals(five, sum.augend) assertEquals(five, sum.addend) } @Test fun testReduceSum() { val sum = Expression.Sum(dollar(3), dollar(4)) val result = Bank().reduce(sum, USD) assertEquals(dollar(7), result) } @Test fun testReduceMoney() { val result = Bank().reduce(dollar(1), USD) assertEquals(dollar(1), result) } @Test fun testReduceMoneyDifferentCurrency() { val bank = Bank() bank.addRate(CHF, USD, 2) val result = bank.reduce(franc(2), USD) assertEquals(dollar(1), result) } @Test fun testIdentityRate() { assertEquals(1, Bank().rate(USD, USD)) } private val fiveBucks: Expression = dollar(5) private val tenFrancs: Expression = franc(10) @Test fun testMixedAddition() { val bank = setUpBankWithCHF2USDRate() val result = bank.reduce(fiveBucks + tenFrancs, USD) assertEquals(dollar(10), result) } @Test fun testSumPlusMoney() { val bank = setUpBankWithCHF2USDRate() val sum = Expression.Sum(fiveBucks, tenFrancs) + fiveBucks val result = bank.reduce(sum, USD) assertEquals(dollar(15), result) } @Test fun testSumTimes() { val bank = setUpBankWithCHF2USDRate() val sum = Expression.Sum(fiveBucks, tenFrancs) * 2 val result = bank.reduce(sum, USD) assertEquals(dollar(20), result) } private fun setUpBankWithCHF2USDRate(): Bank { val bank = Bank() bank.addRate(CHF, USD, 2) return bank } }
Money.kt
package money data class Money(val amount: Int, val currency: Currency) : Expression() { companion object { fun dollar(amount: Int) = Money(amount, Currency.USD) fun franc(amount: Int) = Money(amount, Currency.CHF) } enum class Currency { USD, CHF } } sealed class Expression { operator fun plus(addend: Expression): Expression = Sum(this, addend) operator fun times(multiplier: Int): Expression = when (this) { is Money -> Money(amount * multiplier, currency) is Sum -> Sum(augend * multiplier, addend * multiplier) } data class Sum(val augend: Expression, val addend: Expression) : Expression() }
Bank.kt
package money import money.Money.Currency class Bank { private val rates: MutableMap<Pair<Currency, Currency>, Int> = HashMap() fun reduce(source: Expression, to: Currency) = when (source) { is Money -> Money(source.amount / rate(source.currency, to), to) is Expression.Sum -> Money(sum(source.augend, source.addend, to), to) } fun rate(from: Currency, to: Currency) = if (from == to) 1 else rates[Pair(from, to)] ?: throw IllegalArgumentException("Unregistered rate: $from to $to") private fun sum(augend: Expression, addend: Expression, to: Currency): Int = reduce(augend, to).amount + reduce(addend, to).amount fun addRate(from: Currency, to: Currency, rate: Int) { rates.put(Pair(from, to), rate) } }
今回取り組んだ3章に関しては、第13章で実施した設計の転換によって1つの機能に対して実装する箇所が基本的に1箇所に集約されるようになって、書籍と比べてあっちこっち見ずに済み感覚的に実装の負担が少ないように思えたのが印象的だった。とはいえ主な修正箇所が Bank
だったこと、そもそもの実装内容が小規模だったことからそれ以外は特に気になるような差異はなかったように思う。まあ sealed class を使うことで分岐における網羅性の保証について無駄なことを考慮しなくて済むので、積極的に分岐を使って実装を集約しやすいという点は前回と今回で割と活きたのではないだろうか。
全体を通して Kotlin という言語に持った印象は、便利機能が標準で入っていたり Java の Object にまつわるボイラープレート的な実装を省略できる機能があったりする点以外はやはり Java ベースの言語で、Kotlin は Java 8 とは異なる方向性の進化をした、順当な「モダンさ」を取り入れた Java という感じだった。これは data class やsealed class を用いて色々な実装の手間が省けた以外では記述方法が異なる場所があるだけでおおよそ Java の実装と変わらない形に落ち着いたなあという所感によるところが大きいと思う。ただ Bank
の実装で見たように、Java とは異なり最初からクラスやオブジェクトという構造を単位として作らなくてよく、関数という機能単位で実装を進められることは、ボトムアップなアプローチができるという点で設計のやりようが柔軟になっているかもしれない。この辺は実際に Kotlin でアプリなどを開発している現場が延べでどのくらい package-level function を作ってるのか聞いてみたい所である。
TDD に関しては、これまで聞きかじったり仕事でもなんとなくそれっぽくやってみていたりしたこともあってサイクルやテンポがとても自然に感じられた。ただ設計についての考え方や実装の粒度については学ぶところが多く、特に大きな流れの中で小さなサイクルを回しながら前進しつつ、ゴールにきちんと向かっていくという第5章から第11章までの流れがとても印象的だった。
そういえば途中で振り返りで触れるぞーと言っていた話題があったが、sealed class や enum を使ってバリエーションを開発側で制限するのって、例えばこの多国通貨のシステムがパッケージなりライブラリなりとして提供された時の使い勝手的にどうなのかなーというもので、ユーザ定義で増やせる方が使いやすいとかでも予期しないエラーがどうのこうのとかそういう感じの話だったのだが、TDDとはそこまで関係ないなーと今になって思ったのでこれだけ。最後に currency
を enum class
にしたのはやっぱそっちの方が楽だよねという気持ちがあって、でも最後にやっても何の恩恵にもあずかれないのであった。
Part II 以降については別にやりたいことを進めているため、読み進めているだけで写経は一旦お休みという状態である。こっちも写経の様子をブログにできたらとは思っている。