2
7

More than 3 years have passed since last update.

Pythonで、書籍「テスト駆動開発」の第I部「多国通貨」を体験してみる

Last updated at Posted at 2020-01-04

書籍「テスト駆動開発」の第I部「多国通貨」で取り上げられている実例は、JAVAベースのため、自分の理解を深めるためにも、Pythonで同等のプラクティスに挑んでみました。

ちなみに、テスト駆動開発プラクティスのコードは、以下のGithubに保管しています。
https://github.com/ttsubo/study_of_test_driven_development/tree/master/python

■ テスト駆動開発の進め方

書籍「テスト駆動開発」で提唱しているテスト駆動開発の進め方は、次の通りです。

  • 小さいテストを1つ書く。
  • すべてのテストを実行し、1つ失敗することを確認する。
  • 小さい変更を行う。
  • 再びテストを実行し、すべて成功することを確認する。
  • リファクタリングを行い、重複を除去する。

今回のプラクティスでも、極力、この進め方に追従していきます。

■ 多国通貨を通じて、テスト駆動開発を経験する

実現すべき要件は、以下の2つ。

  • 通貨の異なる2つの金額を足し、通貨間の為替レートに基づいて換算された金額を得る。
  • 金額(通貨単位あたりの額)に数値(通貨単位数)を掛け、金額を得る。

第1章: 仮実装 (Multi-Currency Money)

まずは、金額(通貨単位あたりの額)に数値(通貨単位数)を掛け、金額を得る。という仕組みを目指します。
具体的には、$5*2=$10が成立するように、コード化を進めていきます。

(1) まずは、小さいテストを1つ書く。

$5*2=$10が成立することを確認するテストを作成します。

tests/test_money.py
from testtools import TestCase
from example.dollar import Dollar

class MoneyTest(TestCase):
    def testMultiplication(self):
        five = Dollar(5)
        five.times(2)
        self.assertEqual(10, five.amount)

(2) テスト対象のコードを準備しておく

Dollarクラスをロードするだけのコードを配備しておきます。

example/dollar.py
class Dollar:
    pass

(3) テストを実行し、1つ失敗することを確認する。

Dollarクラスのオブジェクト化できませんので、テストは失敗します。

$ pytest -v

...(snip)

tests/test_money.py::MoneyTest::testMultiplication FAILED                [100%]
================================== FAILURES ===================================
___________________________ MoneyTest.testMultiplication ______________________
NOTE: Incompatible Exception Representation, displaying natively:

testtools.testresult.real._StringException: Traceback (most recent call last):
  File "/Users/ttsubo/source/study_of_test_driven_development/tests/test_money.py", line 6, in testMultiplication
    five = Dollar(5)
TypeError: Dollar() takes no arguments

(4) 小さい変更を行う。

Dollarクラスのオブジェクト化が行えるように、コンストラクタを定義します。

example/dollar.py
class Dollar:
    def __init__(self, amount):  
        self.amount = amount

(5) 再度、テストを実行し、1つ失敗することを確認する。

Dollarクラスには、timesメソッドが定義されていないので、テストは失敗します。

$ pytest -v

...(snip)
tests/test_money.py::MoneyTest::testMultiplication FAILED                [100%]
================================== FAILURES ===================================
___________________________ MoneyTest.testMultiplication ______________________
NOTE: Incompatible Exception Representation, displaying natively:

testtools.testresult.real._StringException: Traceback (most recent call last):
  File "/Users/ttsubo/source/study_of_test_driven_development/tests/test_money.py", line 7, in testMultiplication
    five.times(2)
AttributeError: 'Dollar' object has no attribute 'times'

(6) 再度、小さい変更を行う。

金額に数値を掛け、金額を得る。ことができるように、timesメソッドが定義します。

example/dollar.py
class Dollar:
    def __init__(self, amount):  
        self.amount = amount

    def times(self, multiplier):                                                    
        self.amount *= multiplier

(7) 再びテストを実行し、すべて成功することを確認する。-> OK

ようやく、テストが成功するようになりました。

$ pytest -v

...(snip)
tests/test_money.py::MoneyTest::testMultiplication PASSED                [100%]

第2章: 明白な実装 (Degenerate Objects)

第1章では、金額(通貨単位あたりの額)に数値(通貨単位数)を掛け、金額を得る。という仕組みとして、必要最低限の実装を試みました。さらに、ここでは、金額(通貨単位あたりの額)に数値(通貨単位数)を掛け、金額を得る。という仕組みを、何度も、呼び出せるように拡張していきます。

(1) まずは、小さいテストを1つ書く。

$5*2=$10が成立することを確認したのち、$5*3=$15も成立するテストを作成します。
Dollarクラスのオブジェクト化を一度行った上で、掛け算の値を色々と変更してテストしていくことを想定します。

tests/test_money.py
from testtools import TestCase
from example.dollar import Dollar

class MoneyTest(TestCase):
    def testMultiplication(self):
        five = Dollar(5)
        product = five.times(2)
        self.assertEqual(10, product.amount)
        product = five.times(3)
        self.assertEqual(15, product.amount)

(2) テストを実行し、1つ失敗することを確認する。

まだ、Dollarオブジェクトのtimesメソッドの実行結果を取得するロジックを実装されていないので、テストは失敗します。

$ pytest -v

...(snip)

tests/test_money.py::MoneyTest::testMultiplication FAILED                [100%]
================================== FAILURES ===================================
___________________________ MoneyTest.testMultiplication ______________________
NOTE: Incompatible Exception Representation, displaying natively:

testtools.testresult.real._StringException: Traceback (most recent call last):
  File "/Users/ttsubo/source/study_of_test_driven_development/python/tests/test_money.py", line 8, in testMultiplication
    self.assertEqual(10, product.amount)
AttributeError: 'NoneType' object has no attribute 'amount'

(3) 小さい変更を行う。

Dollarオブジェクトのtimesメソッドの実行結果を取得するロジックを実装します。

  • Dollarオブジェクトのtimesメソッド呼び出し時、新たなDollarオブジェクトを生成する
  • 新たなDollarオブジェクトのインスタンス変数amountに、掛け算結果を保存する
example/dollar.py
class Dollar:
    def __init__(self, amount):  
        self.amount = amount

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

(4) 再びテストを実行し、すべて成功することを確認する。-> OK

テストが成功するようになりました。

$ pytest -v

...(snip)
tests/test_money.py::MoneyTest::testMultiplication PASSED                [100%]

第3章: 三角測量 (Equality for All)

第2章では、金額(通貨単位あたりの額)に数値(通貨単位数)を掛け、金額を得る。という仕組みを、何度も、呼び出せるように拡張しました。ここでは、ある5ドルは、他の5ドルと同じ価値であることを確認できる仕組みを追加していきます。ちなみに、pythonの特殊メソッド__eq__を活用してオブジェクトの同一性を確認しています。

(1) まずは、小さいテストを1つ書く。

ある5ドルは、他の5ドルと等価であることを確認するテストを作成します。

tests/test_money.py
from testtools import TestCase
from example.dollar import Dollar

class MoneyTest(TestCase):
    def testMultiplication(self):
        five = Dollar(5)
        product = five.times(2)
        self.assertEqual(10, product.amount)
        product = five.times(3)
        self.assertEqual(15, product.amount)

    def testEquality(self):
        self.assertTrue(Dollar(5) == Dollar(5))
        self.assertFalse(Dollar(5) == Dollar(6))

(2) テストを実行し、1つ失敗することを確認する。

まだ、Dollarクラスには、ある5ドルは、他の5ドルと等価であることが確認できる仕組みが存在しないため、テストは失敗します。

$ pytest -v

...(snip)
tests/test_money.py::MoneyTest::testEquality FAILED              [ 50%]
tests/test_money.py::MoneyTest::testMultiplication PASSED                [100%]
================================== FAILURES ===================================
___________________________ MoneyTest.testMultiplication ______________________
NOTE: Incompatible Exception Representation, displaying natively:

testtools.testresult.real._StringException: Traceback (most recent call last):
  File "/Users/ttsubo/source/study_of_test_driven_development/python/tests/test_money.py", line 13, in testEquality
    self.assertTrue(Dollar(5) == Dollar(5))
  File "/Users/ttsubo/.pyenv/versions/3.8.0/lib/python3.8/unittest/case.py", line 765, in assertTrue
    raise self.failureException(msg)
AssertionError: False is not true

(3) 小さい変更を行う。

特殊メソッド__eq__を活用して、ある5ドルは、他の5ドルと等価であることが確認できるように定義します。

example/dollar.py
class Dollar:
    def __init__(self, amount):  
        self.amount = amount

    def __eq__(self, other):
        return self.amount == other.amount

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

(4) 再びテストを実行し、すべて成功することを確認する。-> OK

テストが成功するようになりました。

$ pytest -v

...(snip)

tests/test_money.py::MoneyTest::testEquality PASSED              [ 50%]
tests/test_money.py::MoneyTest::testMultiplication PASSED                [100%]

第4章: 意図を語るテスト (Privacy)

第3章では、ある5ドルは、他の5ドルと同じ価値であることを確認できる仕組みを追加しました。
ここでは、 これまでのテストの記述について、重複している箇所を見通し良くリファクタリングしていきます。
さらに、Dollarオブジェクトのインスタンス変数amountをプライベートメンバに変更します。

(1) テスト:MoneyTest:testMultiplicationのリファクタリングを行う。

Dollarクラスのtimesメソッドは、自身の金額とmultiplier 引数を掛けた値を、Dollarオブジェクトのインスタンス変数amountに保持するDollarオブジェクトが返却されます。
これまで書いていたテスト記述では、Dollarオブジェクトが返却されることが伝わりにくいため、リファクタリングを行って、可読性を高めていきます。

tests/test_money.py
from testtools import TestCase
from example.dollar import Dollar

class MoneyTest(TestCase):
    def testMultiplication(self):
        five = Dollar(5)
        self.assertEqual(Dollar(10), five.times(2))
        self.assertEqual(Dollar(15), five.times(3))

    def testEquality(self):
        self.assertTrue(Dollar(5) == Dollar(5))
        self.assertFalse(Dollar(5) == Dollar(6))

(2) テストを実行し、すべて成功することを確認する。

リファクタリング後も、テストは成功しています。

$ pytest -v

...(snip)

tests/test_money.py::MoneyTest::testEquality PASSED              [ 50%]
tests/test_money.py::MoneyTest::testMultiplication PASSED                [100%]

(3) 小さい変更を行う。

Dollarオブジェクトのインスタンス変数amountをプライベートメンバに変更します。

example/dollar.py
class Dollar:
    def __init__(self, amount):  
        self.__amount = amount

    def __eq__(self, other):
        return self.__amount == other.__amount

    def times(self, multiplier):                                                    
        return Dollar(self.__amount * multiplier)

(4) テストを実行し、すべて成功することを確認する。-> OK

テストは成功しています。

$ pytest -v

...(snip)

tests/test_money.py::MoneyTest::testEquality PASSED              [ 50%]
tests/test_money.py::MoneyTest::testMultiplication PASSED                [100%]

第5章: 原則をあえて破るとき (Franc-ly Speaking)

第4章までで、多通貨の一つとして、ドル通貨に関わる金額(通貨単位あたりの額)に数値(通貨単位数)を掛け、金額を得る。の仕組みを実現してきました。ここでは、 別の通貨として、フラン通貨でも同様な仕組みを実現していきます。

(1) まずは、小さいテストを1つ書く。

ドル通貨のテストと同じものを、フラン通貨でも確認できるように、テストを追加します。

tests/test_money.py
from testtools import TestCase
from example.dollar import Dollar
from example.franc import Franc

class MoneyTest(TestCase):
    def testMultiplication(self):
        five = Dollar(5)
        self.assertEqual(Dollar(10), five.times(2))
        self.assertEqual(Dollar(15), five.times(3))

    def testEquality(self):
        self.assertTrue(Dollar(5) == Dollar(5))
        self.assertFalse(Dollar(5) == Dollar(6))

    def testFrancMultiplication(self):
        five = Franc(5)
        self.assertEqual(Franc(10), five.times(2))
        self.assertEqual(Franc(15), five.times(3))

(2) テスト対象のコードを準備しておく

Francクラスをロードするだけのコードを配備しておきます。

example/franc.py
class Franc:
    pass

(3) テストを実行し、1つ失敗することを確認する。

Francクラスのオブジェクト化できませんので、テストは失敗します。

$ pytest -v

...(snip)

tests/test_money.py::MoneyTest::testEquality PASSED                      [ 33%]
tests/test_money.py::MoneyTest::testFrancMultiplication FAILED           [ 66%]
tests/test_money.py::MoneyTest::testMultiplication PASSED                [100%]
================================== FAILURES ===================================
___________________________ MoneyTest.testMultiplication ______________________
NOTE: Incompatible Exception Representation, displaying natively:

testtools.testresult.real._StringException: Traceback (most recent call last):
  File "/Users/ttsubo/source/study_of_test_driven_development/tests/test_money.py", line 16, in testFrancMultiplication
    five = Franc(5)
TypeError: Franc() takes no arguments

(4) 小さい変更を行う。

第1章でやったことを参考に、Francクラスを定義していきます。

example/franc.py
class Franc:
    def __init__(self, amount):  
        self.__amount = amount

    def __eq__(self, other):
        return self.__amount == other.__amount

    def times(self, multiplier):                                                    
        return Franc(self.__amount * multiplier)

(4) テストを実行し、すべて成功することを確認する。-> OK

テストは成功しています。

$ pytest -v

...(snip)

tests/test_money.py::MoneyTest::testEquality PASSED                      [ 33%]
tests/test_money.py::MoneyTest::testFrancMultiplication PASSED           [ 66%]
tests/test_money.py::MoneyTest::testMultiplication PASSED                [100%]

第6章: テスト不足に気づいたら (Equality for All, Redux)

第5章では、フラン通貨に関わる金額(通貨単位あたりの額)に数値(通貨単位数)を掛け、金額を得る。の仕組みを実現したわけですが、その方法は、これまでのドル通貨の仕組みをまるまるコピーして対応したため、重複箇所が大量に発生してしまいました。
重複を排除する営みとして、まず、手始めに、第3章で手掛けたある5ドルは、他の5ドルと同じ価値であるある5フランは、他の5フランと同じ価値であるの部分に着手します。

(1) まずは、小さいテストを1つ書く。

ドル通貨のテストある5ドルは、他の5ドルと等価であるを、フラン通貨でも確認できるように、テストを追加します。

tests/test_money.py
from testtools import TestCase
from example.dollar import Dollar
from example.franc import Franc

class MoneyTest(TestCase):
    def testMultiplication(self):
        five = Dollar(5)
        self.assertEqual(Dollar(10), five.times(2))
        self.assertEqual(Dollar(15), five.times(3))

    def testEquality(self):
        self.assertTrue(Dollar(5) == Dollar(5))
        self.assertFalse(Dollar(5) == Dollar(6))
        self.assertTrue(Franc(5) == Franc(5))
        self.assertFalse(Franc(5) == Franc(6))

    def testFrancMultiplication(self):
        five = Franc(5)
        self.assertEqual(Franc(10), five.times(2))
        self.assertEqual(Franc(15), five.times(3))

(2) テストを実行し、すべて成功することを確認する。-> OK

追加したテスト部分も、問題なく成功しています。

$ pytest -v

...(snip)

tests/test_money.py::MoneyTest::testEquality PASSED                      [ 33%]
tests/test_money.py::MoneyTest::testFrancMultiplication PASSED           [ 66%]
tests/test_money.py::MoneyTest::testMultiplication PASSED                [100%]

(3) リファクタリングを行い、重複を除去する。

ここでは、次のリファクタリングを実施します。

  • 親クラスMoneyを新たに定義する
  • Dollar, Francクラスで定義していた特殊メソッド__eq__の重複箇所を排除し、Moneyクラスで共通化する
example/money.py
class Money:
    def __init__(self, amount):  
        self.__amount = amount

    def __eq__(self, other):
        return self.__amount == other.__amount
example/dollar.py
from example.money import Money

class Dollar(Money):
    def __init__(self, amount):  
        self.__amount = amount

    def times(self, multiplier):                                                    
        return Dollar(self.__amount * multiplier)
example/franc.py
from example.money import Money

class Franc(Money):
    def __init__(self, amount):  
        self.__amount = amount

    def times(self, multiplier):                                                    
        return Franc(self.__amount * multiplier)

(4) 再びテストを実行し、すべて成功することを確認する。-> NG

想定外に、テストが全滅してしまいました。

$ pytest -v

...(snip)
tests/test_money.py::MoneyTest::testEquality FAILED                      [ 33%]
tests/test_money.py::MoneyTest::testFrancMultiplication FAILED           [ 66%]
tests/test_money.py::MoneyTest::testMultiplication FAILED                [100%]
================================== FAILURES ===================================
______________________________ MoneyTest.testEquality _________________________
NOTE: Incompatible Exception Representation, displaying natively:

testtools.testresult.real._StringException: Traceback (most recent call last):

...(snip)

  File "/Users/ttsubo/source/study_of_test_driven_development/example/money.py", line 6, in __eq__
    return self.__amount == other.__amount
AttributeError: 'Dollar' object has no attribute '_Money__amount'

________________________ MoneyTest.testFrancMultiplication ____________________
NOTE: Incompatible Exception Representation, displaying natively:

testtools.testresult.real._StringException: Traceback (most recent call last):

...(snip)

  File "/Users/ttsubo/source/study_of_test_driven_development/example/money.py", line 6, in __eq__
    return self.__amount == other.__amount
AttributeError: 'Franc' object has no attribute '_Money__amount'

__________________________ MoneyTest.testMultiplication ______________________
NOTE: Incompatible Exception Representation, displaying natively:

testtools.testresult.real._StringException: Traceback (most recent call last):

...(snip)

File "/Users/ttsubo/source/study_of_test_driven_development/example/money.py", line 6, in __eq__
    return self.__amount == other.__amount
AttributeError: 'Dollar' object has no attribute '_Money__amount'

(5) リファクタリングにより、テストが失敗した原因に対処する。

テストが失敗した原因は、Dollar, Francのオブジェクト化のタイミングで、親クラスMoneyのインスタンス変数amountにも、値を保存する必要がありましたので、コード修正します。

example/dollar.py
from example.money import Money

class Dollar(Money):
    def __init__(self, amount):  
        super(Dollar, self).__init__(amount)
        self.__amount = amount

    def times(self, multiplier):                                                    
        return Dollar(self.__amount * multiplier)
example/franc.py
from example.money import Money

class Franc(Money):
    def __init__(self, amount):  
        super(Franc, self).__init__(amount)
        self.__amount = amount

    def times(self, multiplier):                                                    
        return Franc(self.__amount * multiplier)

(6) 再びテストを実行し、すべて成功することを確認する。-> OK

今度は、テストは成功しました。
ちょっとしたリファクタリングのミスにも、すぐに気がつくことができるのは、まさに、テスト駆動開発のおかげですね。

$ pytest -v

...(snip)
tests/test_money.py::MoneyTest::testEquality PASSED                      [ 33%]
tests/test_money.py::MoneyTest::testFrancMultiplication PASSED           [ 66%]
tests/test_money.py::MoneyTest::testMultiplication PASSED                [100%]

第7章: 疑念をテストに翻訳する (Apples and Oranges)

これまで、多通貨として、"ドル通貨"と"フラン通貨"に対応してきました。
ここで、疑問に思うのが、「ドル通貨フラン通貨を比較したらどうなるだろうか?」ということです。

(1) まずは、小さいテストを1つ書く。

ドル通貨フラン通貨が等しくないことを確認するテストを追加します。

tests/test_money.py
from testtools import TestCase
from example.dollar import Dollar
from example.franc import Franc

class MoneyTest(TestCase):
    def testMultiplication(self):
        five = Dollar(5)
        self.assertEqual(Dollar(10), five.times(2))
        self.assertEqual(Dollar(15), five.times(3))

    def testEquality(self):
        self.assertTrue(Dollar(5) == Dollar(5))
        self.assertFalse(Dollar(5) == Dollar(6))
        self.assertTrue(Franc(5) == Franc(5))
        self.assertFalse(Franc(5) == Franc(6))
        self.assertFalse(Franc(5) == Dollar(5))

    def testFrancMultiplication(self):
        five = Franc(5)
        self.assertEqual(Franc(10), five.times(2))
        self.assertEqual(Franc(15), five.times(3))

(2) テストを実行する。-> NG

テストは失敗します。つまり、ドルとフランが等しいという結果になってしまってます。

$ pytest -v

...(snip)

tests/test_money.py::MoneyTest::testEquality FAILED                      [ 33%]
tests/test_money.py::MoneyTest::testFrancMultiplication PASSED           [ 66%]
tests/test_money.py::MoneyTest::testMultiplication PASSED                [100%]
================================== FAILURES ===================================
______________________________ MoneyTest.testEquality _________________________
NOTE: Incompatible Exception Representation, displaying natively:

testtools.testresult.real._StringException: Traceback (most recent call last):
  File "/Users/ttsubo/source/study_of_test_driven_development/tests/test_money.py", line 16, in testEquality
    self.assertFalse(Franc(5) == Dollar(5))
  File "/Users/ttsubo/.pyenv/versions/3.8.0/lib/python3.8/unittest/case.py", line 759, in assertFalse
    raise self.failureException(msg)
AssertionError: True is not false

(3) テストが失敗した原因に対処する。

等価性比較を行うには、DollarオブジェクトとFrancオブジェクトを比較する必要がありましたので、Moneyクラスの特殊メソッド__eq__の判定ロジック箇所を、修正します。

example/money.py
class Money:
    def __init__(self, amount):  
        self.__amount = amount

    def __eq__(self, other):
        return (self.__amount == other.__amount
                and self.__class__.__name__ == other.__class__.__name__)

(4) 再びテストを実行し、すべて成功することを確認する。-> OK

今度は、テストは成功しました。

$ pytest -v

...(snip)
tests/test_money.py::MoneyTest::testEquality PASSED                      [ 33%]
tests/test_money.py::MoneyTest::testFrancMultiplication PASSED           [ 66%]
tests/test_money.py::MoneyTest::testMultiplication PASSED                [100%]

第8章: 実装を隠す (Makin’ Objects)

第6,7章で、等価性比較に関わるリファクタリングを行いました。
これから、しばらくの間、金額(通貨単位あたりの額)に数値(通貨単位数)を掛け、金額を得る。という仕組みの重複箇所の排除を試みます。

(1) まずは、小さいテストを書く。

ここでは、Dollar, Francクラスで定義していたtimesメソッドの重複箇所を排除し、Moneyクラスで共通化するための前段として、次のリファクタリングを実施することを想定して、テストを修正します。

  • Moneyクラスにクラスメソッドdollarを定義して、Dollarオブジェクトを生成するようにする
  • Moneyクラスにクラスメソッドfrancを定義して、Francオブジェクトを生成するようにする
tests/test_money.py
from testtools import TestCase
from example.money import Money

class MoneyTest(TestCase):
    def testMultiplication(self):
        five = Money.dollar(5)
        self.assertEqual(Money.dollar(10), five.times(2))
        self.assertEqual(Money.dollar(15), five.times(3))

    def testEquality(self):
        self.assertTrue(Money.dollar(5) == Money.dollar(5))
        self.assertFalse(Money.dollar(5) == Money.dollar(6))
        self.assertTrue(Money.franc(5) == Money.franc(5))
        self.assertFalse(Money.franc(5) == Money.franc(6))
        self.assertFalse(Money.franc(5) == Money.dollar(5))

    def testFrancMultiplication(self):
        five = Money.franc(5)
        self.assertEqual(Money.franc(10), five.times(2))
        self.assertEqual(Money.franc(15), five.times(3))

(2) リファクタリングを行う。

ここでは、Dollar, Francクラスで定義していたtimesメソッドの重複箇所を排除し、Moneyクラスで共通化するための前段として、次のリファクタリングを実施します。

  • Moneyクラスにクラスメソッドdollarを定義して、Dollarオブジェクトを生成するようにする
  • Moneyクラスにクラスメソッドfrancを定義して、Francオブジェクトを生成するようにする
example/money.py
from abc import ABCMeta, abstractmethod
from example.dollar import Dollar
from example.franc import Franc

class Money(metaclass=ABCMeta):
    def __init__(self, amount):  
        self.__amount = amount

    def __eq__(self, other):
        return (self.__amount == other.__amount
                and self.__class__.__name__ == other.__class__.__name__)

    @abstractmethod
    def times(self, multiplier):
        pass

    @classmethod
    def dollar(cls, amount):
        return Dollar(amount)

    @classmethod
    def franc(cls, amount):
        return Franc(amount)

(3) テストを実行する。-> NG

テストが起動できず、エラーになってしまいました。Pythonのモジュールの循環インポートが原因のようですね。

$ pytest -v

...(snip)

tests/test_money.py::MoneyTest::testEquality FAILED                      [ 33%]
tests/test_money.py::MoneyTest::testFrancMultiplication FAILED           [ 66%]
tests/test_money.py::MoneyTest::testMultiplication PASSED                [100%]
=================================== ERRORs ====================================
ImportError while importing test module '/Users/ttsubo/source/study_of_test_driven_development/tests/test_money.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
tests/test_money.py:2: in <module>
    from example.money import Money
example/money.py:2: in <module>
    from example.dollar import Dollar
example/dollar.py:1: in <module>
    from example.money import Money
E   ImportError: cannot import name 'Money' from partially initialized module 'example.money' (most likely due to a circular import) (/Users/ttsubo/source/study_of_test_driven_development/example/money.py)

(4) テストが失敗した原因に対処する。

Pythonのモジュールの循環インポートが原因なので、Dollarクラスと、Francクラスを、money.pyで定義するように修正します。

example/money.py
from abc import ABCMeta, abstractmethod

class Money(metaclass=ABCMeta):
    def __init__(self, amount):  
        self.__amount = amount

    def __eq__(self, other):
        return (self.__amount == other.__amount
                and self.__class__.__name__ == other.__class__.__name__)

    @abstractmethod
    def times(self, multiplier):
        pass

    @classmethod
    def dollar(cls, amount):
        return Dollar(amount)

    @classmethod
    def franc(cls, amount):
        return Franc(amount)


class Dollar(Money):
    def __init__(self, amount):  
        super(Dollar, self).__init__(amount)
        self.__amount = amount

    def times(self, multiplier):                                                    
        return Dollar(self.__amount * multiplier)


class Franc(Money):
    def __init__(self, amount):
        super(Franc, self).__init__(amount)
        self.__amount = amount

    def times(self, multiplier):
        return Franc(self.__amount * multiplier)

(5) 再びテストを実行し、すべて成功することを確認する。-> OK

今度は、テストは成功しました。

$ pytest -v

...(snip)
tests/test_money.py::MoneyTest::testEquality PASSED                      [ 33%]
tests/test_money.py::MoneyTest::testFrancMultiplication PASSED           [ 66%]
tests/test_money.py::MoneyTest::testMultiplication PASSED                [100%]

第9章: 歩幅の調整 (Times We’re Livin’ In)

前章に引き続き、金額(通貨単位あたりの額)に数値(通貨単位数)を掛け、金額を得る。という仕組みの重複箇所の排除を試みます。

(1) まずは、小さいテストを書く。

ここでは、Dollar, Francクラスで定義していたtimesメソッドの重複箇所を排除し、Moneyクラスで共通化するための前段として、DollarオブジェクトおよびFrancオブジェクトを区別することを目的とした"currency"という概念を適用すること想定して、テストtestCurrencyを追加します。

  • Dollarオブジェクトを生成する際に、インスタンス変数__currencyに、"USD"を設定する
  • Dollarオブジェクトのプライベートメンバ__currencyを参照できるように、currencyメソッドを定義する
  • Francオブジェクトを生成する際に、インスタンス変数__currencyに、"CHF"を設定する
  • Francオブジェクトのプライベートメンバ__currencyを参照できるように、currencyメソッドを定義する
tests/test_money.py
from testtools import TestCase
from example.money import Money

class MoneyTest(TestCase):
    def testMultiplication(self):
        five = Money.dollar(5)
        self.assertEqual(Money.dollar(10), five.times(2))
        self.assertEqual(Money.dollar(15), five.times(3))

    def testEquality(self):
        self.assertTrue(Money.dollar(5) == Money.dollar(5))
        self.assertFalse(Money.dollar(5) == Money.dollar(6))
        self.assertTrue(Money.franc(5) == Money.franc(5))
        self.assertFalse(Money.franc(5) == Money.franc(6))
        self.assertFalse(Money.franc(5) == Money.dollar(5))

    def testFrancMultiplication(self):
        five = Money.franc(5)
        self.assertEqual(Money.franc(10), five.times(2))
        self.assertEqual(Money.franc(15), five.times(3))

    def testCurrency(self):
        self.assertEqual("USD", Money.dollar(1).currency())
        self.assertEqual("CHF", Money.franc(1).currency())

(2) リファクタリングを行う。

ここでは、Dollar, Francクラスで定義していたtimesメソッドの重複箇所を排除し、Moneyクラスで共通化するための前段として、DollarオブジェクトおよびFrancオブジェクトを区別することを目的としたcurrencyという概念を適用します。

  • Dollarオブジェクトを生成する際に、インスタンス変数__currencyに、"USD"を設定する
  • Dollarオブジェクトのプライベートメンバ__currencyを参照できるように、currencyメソッドを定義する
  • Francオブジェクトを生成する際に、インスタンス変数__currencyに、"CHF"を設定する
  • Francオブジェクトのプライベートメンバ__currencyを参照できるように、currencyメソッドを定義する
example/money.py
from abc import ABCMeta, abstractmethod

class Money(metaclass=ABCMeta):
    def __init__(self, amount, currency):  
        self.__amount = amount
        self.__currency = currency

    def __eq__(self, other):
        return (self.__amount == other.__amount
                and self.__class__.__name__ == other.__class__.__name__)

    @abstractmethod
    def times(self, multiplier):
        pass

    def currency(self):
        return self.__currency

    @classmethod
    def dollar(cls, amount):
        return Dollar(amount, "USD")

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


class Dollar(Money):
    def __init__(self, amount, currency):
        super().__init__(amount, currency)
        self.__amount = amount

    def times(self, multiplier):                                                    
        return Money.dollar(self.__amount * multiplier)


class Franc(Money):
    def __init__(self, amount, currency):
        super().__init__(amount, currency)
        self.__amount = amount

    def times(self, multiplier):
        return Money.franc(self.__amount * multiplier)

(3) テストを実行し、すべて成功することを確認する。-> OK

期待通りに、テストは成功しました。

$ pytest -v

...(snip)
tests/test_money.py::MoneyTest::testCurrency PASSED                      [ 25%]
tests/test_money.py::MoneyTest::testEquality PASSED                      [ 50%]
tests/test_money.py::MoneyTest::testFrancMultiplication PASSED           [ 75%]
tests/test_money.py::MoneyTest::testMultiplication PASSED                [100%]

第10章: テストに聞いてみる (Interesting Times)

これまで着手してきた、金額(通貨単位あたりの額)に数値(通貨単位数)を掛け、金額を得る。という仕組みの重複箇所の排除を試みを完成させます。

(1) まずは、小さいテストを書く。

金額(通貨単位あたりの額)に数値(通貨単位数)を掛け、金額を得る。という仕組みの重複箇所の排除のリファクタリングが完成したことを想定して、テストtestDifferentClassEqualityを追加します。

tests/test_money.py
from testtools import TestCase
from example.money import Money, Franc

class MoneyTest(TestCase):
    def testMultiplication(self):
        five = Money.dollar(5)
        self.assertEqual(Money.dollar(10), five.times(2))
        self.assertEqual(Money.dollar(15), five.times(3))

    def testEquality(self):
        self.assertTrue(Money.dollar(5) == Money.dollar(5))
        self.assertFalse(Money.dollar(5) == Money.dollar(6))
        self.assertTrue(Money.franc(5) == Money.franc(5))
        self.assertFalse(Money.franc(5) == Money.franc(6))
        self.assertFalse(Money.franc(5) == Money.dollar(5))

    def testFrancMultiplication(self):
        five = Money.franc(5)
        self.assertEqual(Money.franc(10), five.times(2))
        self.assertEqual(Money.franc(15), five.times(3))

    def testCurrency(self):
        self.assertEqual("USD", Money.dollar(1).currency())
        self.assertEqual("CHF", Money.franc(1).currency())

    def testDifferentClassEquality(self):
        self.assertTrue(Money(10, "CHF") == Franc(10, "CHF"))

(2) リファクタリングを行う。

次のリファクタリングを実施します。

  • Dollar, Francクラスで定義していたtimesメソッドを削除する
  • Moneyクラスに、共通化されたtimesメソッドとして定義する
  • Moneyオブジェクトの等価性比較のやり方を見直し、特殊メソッド__eq__の修正する
  • Moneyオブジェクト生成を想定して、抽象クラスとして扱っていたMoneyクラスも変更する
example/money.py
class Money():
    def __init__(self, amount, currency):  
        self.__amount = amount
        self.__currency = currency

    def __eq__(self, other):
        return (self.__amount == other.__amount
                and self.currency() == other.currency())

    def times(self, multiplier):
        return Money(self.__amount * multiplier, self.__currency)

    def currency(self):
        return self.__currency

    @classmethod
    def dollar(cls, amount):
        return Dollar(amount, "USD")

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


class Dollar(Money):
    def __init__(self, amount, currency):
        super().__init__(amount, currency)


class Franc(Money):
    def __init__(self, amount, currency):
        super().__init__(amount, currency)

(3) テストを実行し、すべて成功することを確認する。-> OK

期待通りに、テストは成功しました。

$ pytest -v

...(snip)
tests/test_money.py::MoneyTest::testCurrency PASSED                      [ 20%]
tests/test_money.py::MoneyTest::testDifferentClassEquality PASSED        [ 40%]
tests/test_money.py::MoneyTest::testEquality PASSED                      [ 60%]
tests/test_money.py::MoneyTest::testFrancMultiplication PASSED           [ 80%]
tests/test_money.py::MoneyTest::testMultiplication PASSED                [100%]

第11章: 不要になったら消す (The Root of All Evil)

Dollar, Francクラスも、不要にできるので、これまで実施してきたリファクタリングを完結させます。

(1) まずは、小さいテストを書く。

金額(通貨単位あたりの額)に数値(通貨単位数)を掛け、金額を得る。という仕組みのリファクタリングが完成したことを想定して、テスト自体もリファクタリングします。

tests/test_money.py
from testtools import TestCase
from example.money import Money

class MoneyTest(TestCase):
    def testMultiplication(self):
        five = Money.dollar(5)
        self.assertEqual(Money.dollar(10), five.times(2))
        self.assertEqual(Money.dollar(15), five.times(3))

    def testEquality(self):
        self.assertTrue(Money.dollar(5) == Money.dollar(5))
        self.assertFalse(Money.dollar(5) == Money.dollar(6))
        self.assertFalse(Money.franc(5) == Money.dollar(5))

    def testCurrency(self):
        self.assertEqual("USD", Money.dollar(1).currency())
        self.assertEqual("CHF", Money.franc(1).currency())

(2) リファクタリングを行う。

すでに、Moneyオブジェクトにて、"ドル通貨"、および"フラン通貨"を区別できるようになっているので、Dollar, Francクラスの定義は、不要になります。
リファクタリングの完結として、Dollar, Francクラスを削除します。

example/money.py
class Money():
    def __init__(self, amount, currency):  
        self.__amount = amount
        self.__currency = currency

    def __eq__(self, other):
        return (self.__amount == other.__amount
                and self.currency() == other.currency())

    def times(self, multiplier):
        return Money(self.__amount * multiplier, self.__currency)

    def currency(self):
        return self.__currency

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

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

(3) テストを実行し、すべて成功することを確認する。-> OK

期待通りに、テストは成功しました。

$ pytest -v

...(snip)
tests/test_money.py::MoneyTest::testCurrency PASSED                      [ 33%]
tests/test_money.py::MoneyTest::testEquality PASSED                      [ 66%]
tests/test_money.py::MoneyTest::testMultiplication PASSED                [100%]

第12章: 設計とメタファー (Addition, Finally)

これから、通貨の異なる2つの金額を足し、通貨間の為替レートに基づいて換算された金額を得る。の要件を満足する仕組みを実現していきます。なお、この要件は、次の2つのToDoに分解することができそうです。

  • $5 + $5 = $10
  • $5 + 10 CHF = $10(レートが 2:1 の場合)

まずは、2つの金額を足し、金額を得る。の仕組みとして、$5 + $5 = $10が成立するように、コード化を進めています。

(1) まずは、小さいテストを書く。

2つの金額を足し、金額を得る。の仕組みを確認するため、テストtestSimpleAdditionを追加します。
さらに、今後の設計について、次の二つの概念を念頭に置くものとします。

  • 通貨を換算するのは銀行の責務であるべきという考えに基づくBankオブジェクトという概念
  • 為替レートを適用することによって得られる換算結果を保存するためのreduced変数という概念
tests/test_money.py
from testtools import TestCase
from example.money import Money
from example.bank import Bank

class MoneyTest(TestCase):
    def testMultiplication(self):
        five = Money.dollar(5)
        self.assertEqual(Money.dollar(10), five.times(2))
        self.assertEqual(Money.dollar(15), five.times(3))

    def testEquality(self):
        self.assertTrue(Money.dollar(5) == Money.dollar(5))
        self.assertFalse(Money.dollar(5) == Money.dollar(6))
        self.assertFalse(Money.franc(5) == Money.dollar(5))

    def testCurrency(self):
        self.assertEqual("USD", Money.dollar(1).currency())
        self.assertEqual("CHF", Money.franc(1).currency())

    def testSimpleAddition(self):
        five = Money.dollar(5)
        _sum = five.plus(five)
        bank = Bank()
        reduced = bank.reduce(_sum, "USD")
        self.assertEqual(Money.dollar(10), reduced)

(2) 小さい変更を行う。

この章で扱う実装は、だいぶややこしいので、結構、大きめな変更になってしまいますが、こんな感じです。

  • 2つの金額を足し、金額を得る。の仕組みを実現するために、Moneyクラスに、plusメソッドを追加します。
  • 抽象クラスExpressionを継承して、Moneyオブジェクトは生成される
  • Bankクラスのreduceメソッドについては、ここでは、仮実装とする
example/money.py
from example.expression import Expression

class Money(Expression):
    def __init__(self, amount, currency):  
        self.__amount = amount
        self.__currency = currency

    def __eq__(self, other):
        return (self.__amount == other.__amount
                and self.currency() == other.currency())

    def times(self, multiplier):
        return Money(self.__amount * multiplier, self.__currency)

    def plus(self, addend):
        return Money(self.__amount + addend.__amount, self.__currency)

    def currency(self):
        return self.__currency

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

    @classmethod
    def franc(cls, amount):
        return Money(amount, "CHF")
example/bank.py
from example.money import Money

class Bank():
    def reduce(self, source , toCurrency):
        return Money.dollar(10)
example/expression.py
from abc import ABCMeta

class Expression(metaclass=ABCMeta):
    pass

(3) テストを実行し、すべて成功することを確認する。-> OK

テストは成功しています。

$ pytest -v

...(snip)
tests/test_money.py::MoneyTest::testCurrency PASSED                      [ 25%]
tests/test_money.py::MoneyTest::testEquality PASSED                      [ 50%]
tests/test_money.py::MoneyTest::testMultiplication PASSED                [ 75%]
tests/test_money.py::MoneyTest::testSimpleAddition PASSED                [100%]

第13章: 実装を導くテスト (Make It)

引き続き、2つの金額を足し、金額を得る。の仕組みとして、$5 + $5 = $10が成立するように、コード化を進めます。
前回は、Bankクラスのreduceメソッドの振る舞いを、仮実装として定義していた箇所に着手します。
なお、ここでの取り組みは、結構なボリュームになりそうなので、以下のステップで進めていきます。

  • STEP1: $5 + $5の処理を書く
  • STEP2: Bankクラスのreduceメソッドの仮実装を実体化する
  • STEP3: Bankクラスのreduceメソッドの仮実装を実体化する(Cont.)

STEP1: $5 + $5の処理を書く

$5 + $5 = $10を実現する前段として、$5 + $5の部分の処理を実体化します。

(1) まずは、小さいテストを書く。

テストtestPlusReturnsSumを追加します。

tests/test_money.py
from testtools import TestCase
from example.money import Money
from example.bank import Bank

class MoneyTest(TestCase):
    def testMultiplication(self):
        five = Money.dollar(5)
        self.assertEqual(Money.dollar(10), five.times(2))
        self.assertEqual(Money.dollar(15), five.times(3))

    def testEquality(self):
        self.assertTrue(Money.dollar(5) == Money.dollar(5))
        self.assertFalse(Money.dollar(5) == Money.dollar(6))
        self.assertFalse(Money.franc(5) == Money.dollar(5))

    def testCurrency(self):
        self.assertEqual("USD", Money.dollar(1).currency())
        self.assertEqual("CHF", Money.franc(1).currency())

    def testSimpleAddition(self):
        five = Money.dollar(5)
        _sum = five.plus(five)
        bank = Bank()
        reduced = bank.reduce(_sum, "USD")
        self.assertEqual(Money.dollar(10), reduced)

    def testPlusReturnsSum(self):
        five = Money.dollar(5)
        _sum = five.plus(five)
        self.assertEqual(five, _sum.augend)
        self.assertEqual(five, _sum.addend)

(2) 小さい変更を行う。

次の変更を行います。

  • Sumクラスを新たに定義する
  • Sumオブジェクトでは、2つの金額: "augend(被加算数)"と"addend(加数)"の状態を保存する
  • Moneyクラスのplusメソッドでは、Sumオブジェクトを返却するように変更する
example/money.py
from example.expression import Expression

class Money(Expression):
    def __init__(self, amount, currency):  
        self.__amount = amount
        self.__currency = currency

    def __eq__(self, other):
        return (self.__amount == other.__amount
                and self.currency() == other.currency())

    def times(self, multiplier):
        return Money(self.__amount * multiplier, self.__currency)

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

    def currency(self):
        return self.__currency

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

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

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

(3) テストを実行し、すべて成功することを確認する。-> OK

テストは成功しています。

$ pytest -v

...(snip)
tests/test_money.py::MoneyTest::testCurrency PASSED                      [ 20%]
tests/test_money.py::MoneyTest::testEquality PASSED                      [ 40%]
tests/test_money.py::MoneyTest::testMultiplication PASSED                [ 60%]
tests/test_money.py::MoneyTest::testPlusReturnsSum PASSED                [ 80%]
tests/test_money.py::MoneyTest::testSimpleAddition PASSED                [100%]

STEP2: Bankクラスのreduceメソッドの仮実装を実体化する

Bankクラスのreduceメソッドを実体化していきます。ここでは、Sumオブジェクトを対象にします。

(1) まずは、小さいテストを書く。

テストtestReduceSumを追加します。

tests/test_money.py
from testtools import TestCase
from example.money import Money, Sum
from example.bank import Bank

class MoneyTest(TestCase):
    def testMultiplication(self):
        five = Money.dollar(5)
        self.assertEqual(Money.dollar(10), five.times(2))
        self.assertEqual(Money.dollar(15), five.times(3))

    def testEquality(self):
        self.assertTrue(Money.dollar(5) == Money.dollar(5))
        self.assertFalse(Money.dollar(5) == Money.dollar(6))
        self.assertFalse(Money.franc(5) == Money.dollar(5))

    def testCurrency(self):
        self.assertEqual("USD", Money.dollar(1).currency())
        self.assertEqual("CHF", Money.franc(1).currency())

    def testSimpleAddition(self):
        five = Money.dollar(5)
        _sum = five.plus(five)
        bank = Bank()
        reduced = bank.reduce(_sum, "USD")
        self.assertEqual(Money.dollar(10), reduced)

    def testPlusReturnsSum(self):
        five = Money.dollar(5)
        _sum = five.plus(five)
        self.assertEqual(five, _sum.augend)
        self.assertEqual(five, _sum.addend)

    def testReduceSum(self):
        _sum = Sum(Money.dollar(3), Money.dollar(4))
        bank = Bank()
        result = bank.reduce(_sum, "USD")
        self.assertEqual(Money.dollar(7), result)

(2) 小さい変更を行う。

次の変更を行います。

  • Bankクラスのreduceメソッドでは、Sumオブジェクトのreduceメソッドの処理結果を返却できるようにする
  • Sumクラスのreduceメソッドにて、2つの金額を足し、金額を得る。の仕組みを定義する
  • Moneyクラスに、プライベートメンバ__amountを外部から参照できるよう、amountメソッドを定義する
example/money.py
from example.expression import Expression

class Money(Expression):
    def __init__(self, amount, currency):  
        self.__amount = amount
        self.__currency = currency

    def __eq__(self, other):
        return (self.__amount == other.__amount
                and self.currency() == other.currency())

    def times(self, multiplier):
        return Money(self.__amount * multiplier, self.__currency)

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

    def amount(self):
        return self.__amount

    def currency(self):
        return self.__currency

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

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

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

    def reduce(self, toCurrency):
        amount = self.augend.amount() + self.addend.amount()
        return Money(amount, toCurrency)
example/bank.py
class Bank():
    def reduce(self, source , toCurrency):
        return source.reduce(toCurrency)

(3) テストを実行し、すべて成功することを確認する。-> OK

テストは成功しています。

$ pytest -v

...(snip)
tests/test_money.py::MoneyTest::testCurrency PASSED                      [ 16%]
tests/test_money.py::MoneyTest::testEquality PASSED                      [ 33%]
tests/test_money.py::MoneyTest::testMultiplication PASSED                [ 50%]
tests/test_money.py::MoneyTest::testPlusReturnsSum PASSED                [ 66%]
tests/test_money.py::MoneyTest::testReduceSum PASSED                     [ 83%]
tests/test_money.py::MoneyTest::testSimpleAddition PASSED                [100%]

STEP3: Bankクラスのreduceメソッドの仮実装を実体化する(Cont.)

Bankクラスのreduceメソッドを実体化していきます。ここでは、Moneyオブジェクトを対象にします。

(1) まずは、小さいテストを書く。

テストtestReduceMoneyを追加します。

tests/test_money.py
from testtools import TestCase
from example.money import Money, Sum
from example.bank import Bank

class MoneyTest(TestCase):
    def testMultiplication(self):
        five = Money.dollar(5)
        self.assertEqual(Money.dollar(10), five.times(2))
        self.assertEqual(Money.dollar(15), five.times(3))

    def testEquality(self):
        self.assertTrue(Money.dollar(5) == Money.dollar(5))
        self.assertFalse(Money.dollar(5) == Money.dollar(6))
        self.assertFalse(Money.franc(5) == Money.dollar(5))

    def testCurrency(self):
        self.assertEqual("USD", Money.dollar(1).currency())
        self.assertEqual("CHF", Money.franc(1).currency())

    def testSimpleAddition(self):
        five = Money.dollar(5)
        _sum = five.plus(five)
        bank = Bank()
        reduced = bank.reduce(_sum, "USD")
        self.assertEqual(Money.dollar(10), reduced)

    def testPlusReturnsSum(self):
        five = Money.dollar(5)
        _sum = five.plus(five)
        self.assertEqual(five, _sum.augend)
        self.assertEqual(five, _sum.addend)

    def testReduceSum(self):
        _sum = Sum(Money.dollar(3), Money.dollar(4))
        bank = Bank()
        result = bank.reduce(_sum, "USD")
        self.assertEqual(Money.dollar(7), result)

    def testReduceMoney(self):
        bank = Bank()
        result = bank.reduce(Money.dollar(1), "USD")
        self.assertEqual(Money.dollar(1), result)

(2) 小さい変更を行う。

次の変更を行います。

  • Bankクラスのreduceメソッドでは、Moneyオブジェクトのreduceメソッドの処理結果を返却できるようにする
  • Moneyクラスのreduceメソッドを定義する(仮実装)
  • 抽象クラスExpressionに、抽象メソッドreduceを定義して、Moneyクラスおよび、Sumクラスでのreduceメソッド定義を強制させる
example/money.py
from example.expression import Expression

class Money(Expression):
    def __init__(self, amount, currency):  
        self.__amount = amount
        self.__currency = currency

    def __eq__(self, other):
        return (self.__amount == other.__amount
                and self.currency() == other.currency())

    def times(self, multiplier):
        return Money(self.__amount * multiplier, self.__currency)

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

    def reduce(self, toCurrency):
        return self

    def amount(self):
        return self.__amount

    def currency(self):
        return self.__currency

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

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

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

    def reduce(self, toCurrency):
        amount = self.augend.amount() + self.addend.amount()
        return Money(amount, toCurrency)
example/expression.py
from abc import ABCMeta, abstractmethod

class Expression(metaclass=ABCMeta):
    @abstractmethod
    def reduce(self, toCurrency):
        pass

(3) テストを実行し、すべて成功することを確認する。-> OK

テストは成功しています。

$ pytest -v

...(snip)
tests/test_money.py::MoneyTest::testCurrency PASSED                      [ 14%]
tests/test_money.py::MoneyTest::testEquality PASSED                      [ 28%]
tests/test_money.py::MoneyTest::testMultiplication PASSED                [ 42%]
tests/test_money.py::MoneyTest::testPlusReturnsSum PASSED                [ 57%]
tests/test_money.py::MoneyTest::testReduceMoney PASSED                   [ 71%]
tests/test_money.py::MoneyTest::testReduceSum PASSED                     [ 85%]
tests/test_money.py::MoneyTest::testSimpleAddition PASSED                [100%]

第14章: 学習用テストと回帰テスト (Change)

ここでは、2フランを1ドルに換算する処理を実現していきます。

  • STEP1: 2フランを1ドルに換算する処理(為替レートは、仮実装)
  • STEP2: 2フランを1ドルに換算する処理(為替レート表の実装)

STEP1: 2フランを1ドルに換算する処理(為替レートは、仮実装)

2フランを1ドルに換算する処理を実現していきます。
ただし、為替レート-> USD:CHF=2:1を前提とします。

(1) まずは、小さいテストを書く。

テストtestReduceMoneyDifferentCurrencyを追加します。

tests/test_money.py
from testtools import TestCase
from example.money import Money, Sum
from example.bank import Bank

class MoneyTest(TestCase):
    def testMultiplication(self):
        five = Money.dollar(5)
        self.assertEqual(Money.dollar(10), five.times(2))
        self.assertEqual(Money.dollar(15), five.times(3))

    def testEquality(self):
        self.assertTrue(Money.dollar(5) == Money.dollar(5))
        self.assertFalse(Money.dollar(5) == Money.dollar(6))
        self.assertFalse(Money.franc(5) == Money.dollar(5))

    def testCurrency(self):
        self.assertEqual("USD", Money.dollar(1).currency())
        self.assertEqual("CHF", Money.franc(1).currency())

    def testSimpleAddition(self):
        five = Money.dollar(5)
        _sum = five.plus(five)
        bank = Bank()
        reduced = bank.reduce(_sum, "USD")
        self.assertEqual(Money.dollar(10), reduced)

    def testPlusReturnsSum(self):
        five = Money.dollar(5)
        _sum = five.plus(five)
        self.assertEqual(five, _sum.augend)
        self.assertEqual(five, _sum.addend)

    def testReduceSum(self):
        _sum = Sum(Money.dollar(3), Money.dollar(4))
        bank = Bank()
        result = bank.reduce(_sum, "USD")
        self.assertEqual(Money.dollar(7), result)

    def testReduceMoney(self):
        bank = Bank()
        result = bank.reduce(Money.dollar(1), "USD")
        self.assertEqual(Money.dollar(1), result)

    def testReduceMoneyDifferentCurrency(self):
        bank = Bank()
        bank.add_rate("CHF", "USD", 2)
        result = bank.reduce(Money.franc(2), "USD")
        self.assertEqual(Money.dollar(1), result)

(2) 小さい変更を行う。

次の変更を行います。

  • Bankクラスに、add_rateメソッドする(仮実装)
  • Bankクラスのreduceメソッドから、Moneyオブジェクトのreduceメソッドを呼び出す際に、自分のBankオブジェクトを渡すようにする
  • Bankクラスのreduceメソッドから、Sumオブジェクトのreduceメソッドを呼び出す際に、自分のBankオブジェクトを渡すようにする
  • Bankクラスのrateメソッドを定義し、暫定的に、為替レートを取得できるようにする(為替レート-> USD:CHF=2:1)
example/money.py
from example.expression import Expression

class Money(Expression):
    def __init__(self, amount, currency):  
        self.__amount = amount
        self.__currency = currency

    def __eq__(self, other):
        return (self.__amount == other.__amount
                and self.currency() == other.currency())

    def times(self, multiplier):
        return Money(self.__amount * multiplier, self.__currency)

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

    def reduce(self, bank, toCurrency):
        rate = bank.rate(self.__currency, toCurrency)
        return Money(self.__amount / rate, toCurrency)

    def amount(self):
        return self.__amount

    def currency(self):
        return self.__currency

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

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

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

    def reduce(self, bank, toCurrency):
        amount = self.augend.amount() + self.addend.amount()
        return Money(amount, toCurrency)
example/bank.py
class Bank():
    def reduce(self, source , toCurrency):
        return source.reduce(self, toCurrency)

    def add_rate(self, fromCurrency, toCurrency, rate):
        pass

    def rate(self, fromCurrency, toCurrency):
        return 2 if (fromCurrency == "CHF" and toCurrency == "USD") else 1
example/expression.py
from abc import ABCMeta, abstractmethod

class Expression(metaclass=ABCMeta):
    @abstractmethod
    def reduce(self, bank, toCurrency):
        pass

(3) テストを実行し、すべて成功することを確認する。-> OK

テストは成功しています。

$ pytest -v

...(snip)
tests/test_money.py::MoneyTest::testCurrency PASSED                      [ 12%]
tests/test_money.py::MoneyTest::testEquality PASSED                      [ 25%]
tests/test_money.py::MoneyTest::testMultiplication PASSED                [ 37%]
tests/test_money.py::MoneyTest::testPlusReturnsSum PASSED                [ 50%]
tests/test_money.py::MoneyTest::testReduceMoney PASSED                   [ 62%]
tests/test_money.py::MoneyTest::testReduceMoneyDifferentCurrency PASSED  [ 75%]
tests/test_money.py::MoneyTest::testReduceSum PASSED                     [ 87%]
tests/test_money.py::MoneyTest::testSimpleAddition PASSED                [100%]

STEP2: 2フランを1ドルに換算する処理(為替レート表の実装)

為替レート表に基づき、2フランを1ドルに換算する処理を実現していきます。

(1) まずは、小さいテストを書く。

テストtestIdentityRateを追加します。

tests/test_money.py
from testtools import TestCase
from example.money import Money, Sum
from example.bank import Bank

class MoneyTest(TestCase):
    def testMultiplication(self):
        five = Money.dollar(5)
        self.assertEqual(Money.dollar(10), five.times(2))
        self.assertEqual(Money.dollar(15), five.times(3))

    def testEquality(self):
        self.assertTrue(Money.dollar(5) == Money.dollar(5))
        self.assertFalse(Money.dollar(5) == Money.dollar(6))
        self.assertFalse(Money.franc(5) == Money.dollar(5))

    def testCurrency(self):
        self.assertEqual("USD", Money.dollar(1).currency())
        self.assertEqual("CHF", Money.franc(1).currency())

    def testSimpleAddition(self):
        five = Money.dollar(5)
        _sum = five.plus(five)
        bank = Bank()
        reduced = bank.reduce(_sum, "USD")
        self.assertEqual(Money.dollar(10), reduced)

    def testPlusReturnsSum(self):
        five = Money.dollar(5)
        _sum = five.plus(five)
        self.assertEqual(five, _sum.augend)
        self.assertEqual(five, _sum.addend)

    def testReduceSum(self):
        _sum = Sum(Money.dollar(3), Money.dollar(4))
        bank = Bank()
        result = bank.reduce(_sum, "USD")
        self.assertEqual(Money.dollar(7), result)

    def testReduceMoney(self):
        bank = Bank()
        result = bank.reduce(Money.dollar(1), "USD")
        self.assertEqual(Money.dollar(1), result)

    def testReduceMoneyDifferentCurrency(self):
        bank = Bank()
        bank.add_rate("CHF", "USD", 2)
        result = bank.reduce(Money.franc(2), "USD")
        self.assertEqual(Money.dollar(1), result)

    def testIdentityRate(self):
        self.assertEqual(1, Bank().rate("USD", "USD"))

(2) 小さい変更を行う。

次の変更を行います。

  • Bankクラスにて、為替レート表を保持できるようにする
  • Bankオブジェクトのadd_rateメソッドにより、為替レート表に各種レートを追加できる
  • Bankオブジェクトのrateメソッドにより、必要なレートを参照できる
example/bank.py
class Bank():
    def __init__(self):
        self._rates = {}

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

    def add_rate(self, fromCurrency, toCurrency, rate):
        target_rate = "{0}:{1}".format(fromCurrency, toCurrency)
        self._rates[target_rate] = rate

    def rate(self, fromCurrency, toCurrency):
        target_rate = "{0}:{1}".format(fromCurrency, toCurrency)
        if fromCurrency == toCurrency:
            return 1
        return self._rates.get(target_rate)

(3) テストを実行し、すべて成功することを確認する。-> OK

テストは成功しています。

$ pytest -v

...(snip)
tests/test_money.py::MoneyTest::testCurrency PASSED                      [ 11%]
tests/test_money.py::MoneyTest::testEquality PASSED                      [ 22%]
tests/test_money.py::MoneyTest::testIdentityRate PASSED                  [ 33%]
tests/test_money.py::MoneyTest::testMultiplication PASSED                [ 44%]
tests/test_money.py::MoneyTest::testPlusReturnsSum PASSED                [ 55%]
tests/test_money.py::MoneyTest::testReduceMoney PASSED                   [ 66%]
tests/test_money.py::MoneyTest::testReduceMoneyDifferentCurrency PASSED  [ 77%]
tests/test_money.py::MoneyTest::testReduceSum PASSED                     [ 88%]
tests/test_money.py::MoneyTest::testSimpleAddition PASSED                [100%]

第15章: テスト任せとコンパイラ任せ (Mixed Currencies)

これまで、通貨の異なる2つの金額を足し、通貨間の為替レートに基づいて換算された金額を得る。の要件を満足する仕組みの実現化を目指してきました。ここでは、$5 + 10 CHF = $10(レートが 2:1 の場合)のToDoに着手していきます。

(1) まずは、小さいテストを書く。

テストtestMixedAdditionを追加します。

tests/test_money.py
from testtools import TestCase
from example.money import Money, Sum
from example.bank import Bank

class MoneyTest(TestCase):
    def testMultiplication(self):
        five = Money.dollar(5)
        self.assertEqual(Money.dollar(10), five.times(2))
        self.assertEqual(Money.dollar(15), five.times(3))

    def testEquality(self):
        self.assertTrue(Money.dollar(5) == Money.dollar(5))
        self.assertFalse(Money.dollar(5) == Money.dollar(6))
        self.assertFalse(Money.franc(5) == Money.dollar(5))

    def testCurrency(self):
        self.assertEqual("USD", Money.dollar(1).currency())
        self.assertEqual("CHF", Money.franc(1).currency())

    def testSimpleAddition(self):
        five = Money.dollar(5)
        _sum = five.plus(five)
        bank = Bank()
        reduced = bank.reduce(_sum, "USD")
        self.assertEqual(Money.dollar(10), reduced)

    def testPlusReturnsSum(self):
        five = Money.dollar(5)
        _sum = five.plus(five)
        self.assertEqual(five, _sum.augend)
        self.assertEqual(five, _sum.addend)

    def testReduceSum(self):
        _sum = Sum(Money.dollar(3), Money.dollar(4))
        bank = Bank()
        result = bank.reduce(_sum, "USD")
        self.assertEqual(Money.dollar(7), result)

    def testReduceMoney(self):
        bank = Bank()
        result = bank.reduce(Money.dollar(1), "USD")
        self.assertEqual(Money.dollar(1), result)

    def testReduceMoneyDifferentCurrency(self):
        bank = Bank()
        bank.add_rate("CHF", "USD", 2)
        result = bank.reduce(Money.franc(2), "USD")
        self.assertEqual(Money.dollar(1), result)

    def testIdentityRate(self):
        self.assertEqual(1, Bank().rate("USD", "USD"))

    def testMixedAddition(self):
        fiveBucks = Money.dollar(5)
        tenFrancs = Money.franc(10)
        bank = Bank()
        bank.add_rate("CHF", "USD", 2)
        result = bank.reduce(fiveBucks.plus(tenFrancs), "USD")
        self.assertEqual(Money.dollar(10), result)

(2) 小さい変更を行う。

次の変更を行います。

  • Sumオブジェクトのreduceメソッドでのamount導出方法を修正する
  • Sumクラスに、plusメソッドを定義する
  • 抽象クラスExpressionに、抽象メソッドplusを定義して、Moneyクラスおよび、Sumクラスでのplusメソッド定義を強制させる
example/money.py
from example.expression import Expression

class Money(Expression):
    def __init__(self, amount, currency):  
        self.__amount = amount
        self.__currency = currency

    def __eq__(self, other):
        return (self.__amount == other.__amount
                and self.currency() == other.currency())

    def times(self, multiplier):
        return Money(self.__amount * multiplier, self.__currency)

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

    def reduce(self, bank, toCurrency):
        rate = bank.rate(self.__currency, toCurrency)
        return Money(self.__amount / rate, toCurrency)

    def amount(self):
        return self.__amount

    def currency(self):
        return self.__currency

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

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

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

    def reduce(self, bank, toCurrency):
        amount = self.augend.reduce(bank, toCurrency).amount() + \
            self.addend.reduce(bank, toCurrency).amount()
        return Money(amount, toCurrency)

    def plus(self, addend):
        pass
example/expression.py
from abc import ABCMeta, abstractmethod

class Expression(metaclass=ABCMeta):
    @abstractmethod
    def plus(self, addend):
        pass

    @abstractmethod
    def reduce(self, bank, toCurrency):
        pass

(3) テストを実行し、すべて成功することを確認する。-> OK

テストは成功しています。

$ pytest -v

...(snip)
tests/test_money.py::MoneyTest::testCurrency PASSED                      [ 10%]
tests/test_money.py::MoneyTest::testEquality PASSED                      [ 20%]
tests/test_money.py::MoneyTest::testIdentityRate PASSED                  [ 30%]
tests/test_money.py::MoneyTest::testMixedAddition PASSED                 [ 40%]
tests/test_money.py::MoneyTest::testMultiplication PASSED                [ 50%]
tests/test_money.py::MoneyTest::testPlusReturnsSum PASSED                [ 60%]
tests/test_money.py::MoneyTest::testReduceMoney PASSED                   [ 70%]
tests/test_money.py::MoneyTest::testReduceMoneyDifferentCurrency PASSED  [ 80%]
tests/test_money.py::MoneyTest::testReduceSum PASSED                     [ 90%]
tests/test_money.py::MoneyTest::testSimpleAddition PASSED                [100%]

第16章: 将来の読み手を考えたテスト (Abstraction, Finally)

いよいよ、通貨の異なる2つの金額を足し、通貨間の為替レートに基づいて換算された金額を得る。の要件を満足する仕組みの実現化を完成させます。

  • STEP1: Sumクラスのplusメソッドを完成させる
  • STEP2: Sumクラスのtimesメソッドを完成させる

STEP1: Sumクラスのplusメソッドを完成させる

Sumクラスのplusメソッドを完成させます。

(1) まずは、小さいテストを書く。

テストtestSumPlusMoneyを追加します。

tests/test_money.py
from testtools import TestCase
from example.money import Money, Sum
from example.bank import Bank

class MoneyTest(TestCase):
    def testMultiplication(self):
        five = Money.dollar(5)
        self.assertEqual(Money.dollar(10), five.times(2))
        self.assertEqual(Money.dollar(15), five.times(3))

    def testEquality(self):
        self.assertTrue(Money.dollar(5) == Money.dollar(5))
        self.assertFalse(Money.dollar(5) == Money.dollar(6))
        self.assertFalse(Money.franc(5) == Money.dollar(5))

    def testCurrency(self):
        self.assertEqual("USD", Money.dollar(1).currency())
        self.assertEqual("CHF", Money.franc(1).currency())

    def testSimpleAddition(self):
        five = Money.dollar(5)
        _sum = five.plus(five)
        bank = Bank()
        reduced = bank.reduce(_sum, "USD")
        self.assertEqual(Money.dollar(10), reduced)

    def testPlusReturnsSum(self):
        five = Money.dollar(5)
        _sum = five.plus(five)
        self.assertEqual(five, _sum.augend)
        self.assertEqual(five, _sum.addend)

    def testReduceSum(self):
        _sum = Sum(Money.dollar(3), Money.dollar(4))
        bank = Bank()
        result = bank.reduce(_sum, "USD")
        self.assertEqual(Money.dollar(7), result)

    def testReduceMoney(self):
        bank = Bank()
        result = bank.reduce(Money.dollar(1), "USD")
        self.assertEqual(Money.dollar(1), result)

    def testReduceMoneyDifferentCurrency(self):
        bank = Bank()
        bank.add_rate("CHF", "USD", 2)
        result = bank.reduce(Money.franc(2), "USD")
        self.assertEqual(Money.dollar(1), result)

    def testIdentityRate(self):
        self.assertEqual(1, Bank().rate("USD", "USD"))

    def testMixedAddition(self):
        fiveBucks = Money.dollar(5)
        tenFrancs = Money.franc(10)
        bank = Bank()
        bank.add_rate("CHF", "USD", 2)
        result = bank.reduce(fiveBucks.plus(tenFrancs), "USD")
        self.assertEqual(Money.dollar(10), result)

    def testSumPlusMoney(self):
        fiveBucks = Money.dollar(5)
        tenFrancs = Money.franc(10)
        bank = Bank()
        bank.add_rate("CHF", "USD", 2)
        _sum = Sum(fiveBucks, tenFrancs).plus(fiveBucks)
        result = bank.reduce(_sum, "USD")
        self.assertEqual(Money.dollar(15), result)

(2) 小さい変更を行う。

次の変更を行います。

  • Sumオブジェクトの、plusメソッドが呼ばれたとき、Sumオブジェクトを返却する
  • 抽象クラスExpressionに、抽象メソッドplusを定義して、Moneyクラスおよび、Sumクラスでのplusメソッド定義を強制させる
example/money.py
from example.expression import Expression

class Money(Expression):
    def __init__(self, amount, currency):  
        self.amount = amount
        self._currency = currency

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

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

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

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

    def currency(self):
        return self._currency

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

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

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

    def reduce(self, bank, toCurrency):
        amount = self.augend.reduce(bank, toCurrency).amount + \
            self.addend.reduce(bank, toCurrency).amount
        return Money(amount, toCurrency)

    def plus(self, addend):
        return Sum(self, addend)
example/bank.py
class Bank():
    def __init__(self):
        self._rates = {}

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

    def add_rate(self, fromCurrency, toCurrency, rate):
        self._rates[(fromCurrency, toCurrency)] = rate

    def rate(self, fromCurrency, toCurrency):
        if fromCurrency == toCurrency:
            return 1
        return self._rates.get((fromCurrency, toCurrency))

(3) テストを実行し、すべて成功することを確認する。-> OK

テストは成功しています。

$ pytest -v

...(snip)
tests/test_money.py::MoneyTest::testCurrency PASSED                      [  9%]
tests/test_money.py::MoneyTest::testEquality PASSED                      [ 18%]
tests/test_money.py::MoneyTest::testIdentityRate PASSED                  [ 27%]
tests/test_money.py::MoneyTest::testMixedAddition PASSED                 [ 36%]
tests/test_money.py::MoneyTest::testMultiplication PASSED                [ 45%]
tests/test_money.py::MoneyTest::testPlusReturnsSum PASSED                [ 54%]
tests/test_money.py::MoneyTest::testReduceMoney PASSED                   [ 63%]
tests/test_money.py::MoneyTest::testReduceMoneyDifferentCurrency PASSED  [ 72%]
tests/test_money.py::MoneyTest::testReduceSum PASSED                     [ 81%]
tests/test_money.py::MoneyTest::testSimpleAddition PASSED                [ 90%]
tests/test_money.py::MoneyTest::testSumPlusMoney PASSED                  [100%]

STEP2: Sumクラスのtimesメソッドを完成させる

Sumクラスのtimesメソッドを完成させます。

(1) まずは、小さいテストを書く。

テストtestSumTimesを追加します。

tests/test_money.py
from testtools import TestCase
from example.money import Money, Sum
from example.bank import Bank

class MoneyTest(TestCase):
    def testMultiplication(self):
        five = Money.dollar(5)
        self.assertEqual(Money.dollar(10), five.times(2))
        self.assertEqual(Money.dollar(15), five.times(3))

    def testEquality(self):
        self.assertTrue(Money.dollar(5) == Money.dollar(5))
        self.assertFalse(Money.dollar(5) == Money.dollar(6))
        self.assertFalse(Money.franc(5) == Money.dollar(5))

    def testCurrency(self):
        self.assertEqual("USD", Money.dollar(1).currency())
        self.assertEqual("CHF", Money.franc(1).currency())

    def testSimpleAddition(self):
        five = Money.dollar(5)
        _sum = five.plus(five)
        bank = Bank()
        reduced = bank.reduce(_sum, "USD")
        self.assertEqual(Money.dollar(10), reduced)

    def testPlusReturnsSum(self):
        five = Money.dollar(5)
        _sum = five.plus(five)
        self.assertEqual(five, _sum.augend)
        self.assertEqual(five, _sum.addend)

    def testReduceSum(self):
        _sum = Sum(Money.dollar(3), Money.dollar(4))
        bank = Bank()
        result = bank.reduce(_sum, "USD")
        self.assertEqual(Money.dollar(7), result)

    def testReduceMoney(self):
        bank = Bank()
        result = bank.reduce(Money.dollar(1), "USD")
        self.assertEqual(Money.dollar(1), result)

    def testReduceMoneyDifferentCurrency(self):
        bank = Bank()
        bank.add_rate("CHF", "USD", 2)
        result = bank.reduce(Money.franc(2), "USD")
        self.assertEqual(Money.dollar(1), result)

    def testIdentityRate(self):
        self.assertEqual(1, Bank().rate("USD", "USD"))

    def testMixedAddition(self):
        fiveBucks = Money.dollar(5)
        tenFrancs = Money.franc(10)
        bank = Bank()
        bank.add_rate("CHF", "USD", 2)
        result = bank.reduce(fiveBucks.plus(tenFrancs), "USD")
        self.assertEqual(Money.dollar(10), result)

    def testSumPlusMoney(self):
        fiveBucks = Money.dollar(5)
        tenFrancs = Money.franc(10)
        bank = Bank()
        bank.add_rate("CHF", "USD", 2)
        _sum = Sum(fiveBucks, tenFrancs).plus(fiveBucks)
        result = bank.reduce(_sum, "USD")
        self.assertEqual(Money.dollar(15), result)

    def testSumTimes(self):
        fiveBucks = Money.dollar(5)
        tenFrancs = Money.franc(10)
        bank = Bank()
        bank.add_rate("CHF", "USD", 2)
        _sum = Sum(fiveBucks, tenFrancs).times(2)
        result = bank.reduce(_sum, "USD")
        self.assertEqual(Money.dollar(20), result)

(2) 小さい変更を行う。

次の変更を行います。

  • Sumオブジェクトの、timesメソッドが呼ばれたとき、Sumオブジェクトを返却する
  • 抽象クラスExpressionに、抽象メソッドtimesを定義して、Moneyクラスおよび、Sumクラスでのtimesメソッド定義を強制させる
example/money.py
from example.expression import Expression

class Money(Expression):
    def __init__(self, amount, currency):  
        self.__amount = amount
        self.__currency = currency

    def __eq__(self, other):
        return (self.__amount == other.__amount
                and self.currency() == other.currency())

    def times(self, multiplier):
        return Money(self.__amount * multiplier, self.__currency)

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

    def reduce(self, bank, toCurrency):
        rate = bank.rate(self.__currency, toCurrency)
        return Money(self.__amount / rate, toCurrency)

    def amount(self):
        return self.__amount

    def currency(self):
        return self.__currency

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

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

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

    def reduce(self, bank, toCurrency):
        amount = self.augend.reduce(bank, toCurrency).amount() + \
            self.addend.reduce(bank, toCurrency).amount()
        return Money(amount, toCurrency)

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

    def times(self, multiplier):
        return Sum(self.augend.times(multiplier), self.addend.times(multiplier))
example/expression.py
from abc import ABCMeta, abstractmethod

class Expression(metaclass=ABCMeta):
    @abstractmethod
    def plus(self, addend):
        pass

    @abstractmethod
    def reduce(self, bank, toCurrency):
        pass

    @abstractmethod
    def times(self, multiplier):
        pass

(3) テストを実行し、すべて成功することを確認する。-> OK

テストは成功していますし、カバレッジも概ね良好です。

$ pytest -v --cov=example

...(snip)
tests/test_money.py::MoneyTest::testCurrency PASSED                      [  8%]
tests/test_money.py::MoneyTest::testEquality PASSED                      [ 16%]
tests/test_money.py::MoneyTest::testIdentityRate PASSED                  [ 25%]
tests/test_money.py::MoneyTest::testMixedAddition PASSED                 [ 33%]
tests/test_money.py::MoneyTest::testMultiplication PASSED                [ 41%]
tests/test_money.py::MoneyTest::testPlusReturnsSum PASSED                [ 50%]
tests/test_money.py::MoneyTest::testReduceMoney PASSED                   [ 58%]
tests/test_money.py::MoneyTest::testReduceMoneyDifferentCurrency PASSED  [ 66%]
tests/test_money.py::MoneyTest::testReduceSum PASSED                     [ 75%]
tests/test_money.py::MoneyTest::testSimpleAddition PASSED                [ 83%]
tests/test_money.py::MoneyTest::testSumPlusMoney PASSED                  [ 91%]
tests/test_money.py::MoneyTest::testSumTimes PASSED                      [100%]

...(snip)
---------- coverage: platform darwin, python 3.8.0-final-0 -----------
Name                    Stmts   Miss  Cover
-------------------------------------------
example/__init__.py         0      0   100%
example/bank.py            13      0   100%
example/expression.py      11      3    73%
example/money.py           35      0   100%
-------------------------------------------
TOTAL                      59      3    95%

...(snip)

以上で、テスト駆動開発を活用したPython版「多国通貨」開発を体験してみました。

■ 終わりに ...

書籍「テスト駆動開発」の第Ⅱ部「xUnit」でも、Pythonで、テスト駆動開発が体験できます。
実際に、体験してみて気が付いたのですが、テスト駆動開発は、私がイメージしていたテストとだいぶ乖離していて、完全に誤解していました。
書籍の第32章「TDDを身につける」からの引用ですが、まさに、これです!!

皮肉なことに、TDD はテスト技法ではない(Cunningham の公案)。 TDD は分析技法であり、設計技法であり、実際には開発のすべてのアク ティビティを構造化する技法なのだ。

あと、付録C「訳者解説:テスト駆動開発の現在」は、TDD/BDDに対する理解を助けるものであり、ものすごく勉強になりました。

■ 参考URL

2
7
0

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
7