nashcft's blog

時々何か書く。

Kotlin で『テスト駆動開発』を進める (第14章 - 第16章)

前回

さて 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 + $5Money を返す
  • [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 + $5Money を返す
  • [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 + $5Money を返す
  • [x] Bank.reduce(Money)
  • [x] Money を変換して換算を行う
  • [x] Reduce(Bank, String)
  • [x] Sum.plus
  • [ ] Expression.times

更新内容をみてわかるが、 Expressionplus() を持ってきた時点で 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.timesExpression に持ってきて、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 + $5Money を返す」だが、結果的に何もしないことになるので、これで第16章が終わる。

  • [x] $5 + 10CHF = $10 (レートが 2:1 の場合)
  • [x] $5 + $5 = $10
  • [x] $5 + $5Money を返す
  • [x] Bank.reduce(Money)
  • [x] Money を変換して換算を行う
  • [x] Reduce(Bank, String)
  • [x] Sum.plus
  • [x] Expression.times

まとめと振り返りなど

最終的な実装を以下に示す。差分を書き直すのが面倒なので GitHub のレポジトリと合わせるためにテストクラスに雑なフィクスチャー (っぽいもの) を作ったりMoney.currencyenum 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とはそこまで関係ないなーと今になって思ったのでこれだけ。最後に currencyenum class にしたのはやっぱそっちの方が楽だよねという気持ちがあって、でも最後にやっても何の恩恵にもあずかれないのであった。

Part II 以降については別にやりたいことを進めているため、読み進めているだけで写経は一旦お休みという状態である。こっちも写経の様子をブログにできたらとは思っている。