テスト駆動開発入門写経

ケント・ベック著のテスト駆動開発入門のMoneyオブジェクト編を写経してみた。
Javaはあまり知らないというのと個人的にPythonが好きというのでPythonを選定。
Pythonにはテストフレームワークというとnoseやらdoctestやらあるけど、他言語でも潰しがきくだろうと思って標準のunittestを使った。

言語的な違い

写経中では言語仕様の面でPythonJavaでいろいろ違うことがあった。
例えば

  • コンパイルがいらない
    • JavaではエラーになるがPythonはエラーにならないでとりあえず動くけどFAILになるもしくはそのままSuccessになる
  • interfaceが言語仕様にない
    • DuckTypingということでとりあえず無視
    • うごけば別にいいんじゃね?
  • 演算子オーバーライド
    • Javaのequalsとplusだけ実装。
    • timesは実装し忘れてまあ実装レベルもあんまりかわらないだろうと思って後でリファクタリングでカバーする方向で進める。
  • パッケージの違い
    • お互いにimportが必要なところがあり、PythonだとNGになる。
      • 結局1つのファイルに2つのクラス定義を書くことにした。


といったところ。
他にもあったかもしれないけど忘れた。

感想

これでだいたいのリズムはつかめたかなというレベルまではいったものの、別でやってるオレオレプロジェクトではアサート書くより実装始めたりとかやったりする。
この辺は練習重ねれば改善される。
はず。

個人的にTDDのここがいいと思うのは

  • クラスがある程度大きくなってきても全体の整合性がとりやすい
  • リファクタリングの際にテストケースという命綱で守られてる
    • SCMと組み合わせればより強力

とまあ教科書通りの回答で。
個人的にはリファクタリング大好きなんだけど、リファクタリングした結果全体の整合性無くなりましたとかいう場合どうしようとか考えると動いてるコードは触るなという考えになってしまっていた。
まあそんな事を担保してくれるという意味では非常に強力。

逆にTDDのここが嫌と思うのは

  • 初期の開発速度が体感的に遅い
    • 練度不足?

という点。
やっぱりテスト書いて実装コードを書くって事をするので初期は遅い感じがする。
他にも自分はとにかくせっかちなので、コードが動いているところを見たいという衝動に駆られテストめんどくせーという流れになっていた。
そのためにマジックナンバーやらリテラルやら書いてとにかくバーをグリーンにするんだという別の解釈も持った訳だけど。
ま、でもMoneyオブジェクトの後半の体感速度はかなり早く感じたので、総合的にみるとTDDの方がいいかなという結論。

今回のソース

今回できたソースコードを転載。


testMoney.py

#! python
# coding:utf-8

import unittest
from Money import *
from Bank import *

class testMoney(unittest.TestCase):

    def testMaltiplication(self):
        '''掛け算のテスト'''
        five = Money.doller(5)
        self.assertEqual(Money.doller(10), five.times(2))
        self.assertEqual(Money.doller(15), five.times(3))

    def testCurrency(self):
        '''通貨概念のテスト'''
        self.assertEqual("USD", Money.doller(1).currency)
        self.assertEqual("CHF", Money.franc(1).currency)

    def testEquality(self):
        '''等価性のテスト'''
        #ドル
        self.assertTrue(Money.doller(5) == Money.doller(5))
        self.assertTrue(Money.doller(5) != Money.doller(6))
        #通貨が変われば同じとは限らない
        self.assertTrue(Money.doller(5) != Money.franc(5))
        #フランはカバレッジ率を考慮して削除

    def testReduceSum(self):
        '''合計を特定の通貨に換金する'''
        sum = Sum(Money.doller(3), Money.doller(4))
        bank = Bank()
        result = bank.reduce(sum, "USD")
        self.assertEqual(Money.doller(7), result)

    def testReduceMoney(self):
        '''reduce()はMoneyを返す'''
        bank = Bank()
        result = bank.reduce(Money.doller(1), "USD")
        self.assertEquals(Money.doller(1), result)

    def testReduceMoneyDifferentCurrency(self):
        '''レートを設定してドルをフランに変える'''
        bank = Bank()
        bank.addRate("CHF", "USD", 2)
        result = bank.reduce(Money.franc(2), "USD")
        self.assertEquals(Money.doller(1), result)

    def testIdentityRate(self):
        '''同じ通貨の場合はレートは1'''
        bank = Bank()
        self.assertEquals(1, bank.rate("USD", "USD"))

    def testSimpleAddtion(self):
        '''$5 + $5 = $10'''
        five = Money.doller(5)
        sum = five + five
        bank = Bank()
        reduced = bank.reduce(sum, "USD")
        self.assertEqual(Money.doller(10), reduced)

    def testPlusReturnsSum(self):
        '''加算ではSumクラスを返す'''
        five = Money.doller(5)
        sum = five + five
        self.assertEquals(five, sum.augend)
        self.assertEquals(five, sum.addend)

    def testMixedAddition(self):
        '''$5 + 10 CHF = $10'''
        fiveBucks = Money.doller(5)
        tenFrancs = Money.franc(10)
        bank = Bank()
        bank.addRate("CHF", "USD", 2);
        result = bank.reduce(fiveBucks + tenFrancs, "USD")
        self.assertEquals(Money.doller(10), result)

    def testSumPlusMoney(self):
        '''SumとMoneyの加算'''
        fiveBucks = Money.doller(5)
        tenFrancs = Money.franc(10)
        bank = Bank()
        bank.addRate("CHF", "USD", 2);
        #Pythonだと分かりにくい
        sum = Sum(fiveBucks, tenFrancs) + fiveBucks
        result = bank.reduce(sum, "USD")
        self.assertEquals(Money.doller(15), result)
    
    def testSumTimes(self):
        '''SumとMoneyの乗算'''
        fiveBucks = Money.doller(5)
        tenFrancs = Money.franc(10)
        bank = Bank()
        bank.addRate("CHF", "USD", 2);
        #Pythonだと分かりにくい
        sum = Sum(fiveBucks, tenFrancs).times(2)
        result = bank.reduce(sum, "USD")
        self.assertEquals(Money.doller(20), result)
    
    def testPlusSameCurrencyReturnMoney(self):
        oneBuck1 = Money.doller(1)
        oneBuck2 = Money.doller(1)
        sum = oneBuck1 + oneBuck2 
        #書籍ではassertTrue(isinstance(sum, Money))
        self.assertTrue(isinstance(sum, Sum))
    

if __name__ == "__main__":
    suite = unittest.TestLoader().loadTestsFromTestCase(testMoney)
    unittest.TextTestRunner(verbosity=1).run(suite)

Money.py

#! python
# coding:utf-8

from Sum import *

class Money:

    def __init__(self, amount, currency):
        self.amount = amount
        self.currency = currency 

    def __eq__(self, money):
        return self.amount == money.amount and self.currency == money.currency

    def __add__(self, addend):
        return Sum(self, addend)

    def times(self, multiplier):
        return Money(self.amount * multiplier, self.currency)

    def reduce(self, bank, to):
        rate = bank.rate(self.currency, to)
        return Money(self.amount / rate, to)

    @classmethod
    def doller(cls, amount):
        return Money(amount, "USD")

    @classmethod
    def franc(cls, amount):
        return Money(amount, "CHF")


class Sum:
    
    def __init__(self, augend, addend):
        self.augend = augend
        self.addend = addend

    def reduce(self, bank, to):
        augendAmount = self.augend.reduce(bank, to).amount 
        addendAmount = self.addend.reduce(bank, to).amount 
        return Money(augendAmount + addendAmount, to)

    def __add__(self, addend):
        return Sum(self, addend)

    def times(self, multiplier):
        return Sum(self.augend.times(multiplier), self.addend.times(multiplier))

Bank.py

#! python
# coding:utf-8

from Money import *
from Pair import *

class Bank:

    def __init__(self):
        self.rates = {}

    def reduce(self, source, to):
        return source.reduce(self, to)

    def rate(self, From, To):
        if From == To:
            return 1
        return self.rates[Pair(From, To)]

    def addRate(self, From, To, rate):
        self.rates[Pair(From, To)] = rate