TDD Boot Camp 名古屋 1 日目でやったことを Python で復習する その 2

今日は TDD Boot Camp 名古屋 1 日目のペアプロ体験の後半でやった仕様変更の復習をするよ!

前回のものに仕様変更を加えていく.

仕様変更 1

一度にいっぱい set できる set_multi と一度にいっぱい get できる get_multi を実装する仕様変更.

正確仕様はちょっとうろ覚えだけど,だいたいこんな感じの仕様.

>>> store = FileStore()
>>> store.set_multi({'foo': 'hoge', '': 'toto', 'bar': 'fuga'})
>>> store.dump()
'foo:hoge\nbar:'fuga\n'
>>> store.get_multi(['bar', 'foo', None])
['fuga', 'foo']
まず set_multi のテストから
class TestFileStore(unittest.TestCase):

    def setUp(self):
        self.store = FileStore()
        self.store.set('foo', 'hoge')

    def test_set_multi(self):
        self.assertEqual(self.sotre.dump(),
                         'foo:hoge\nbar:fuga\nbuzz:piyo\n')

assert から書くのを忘れない(*'ω')b

次に,この assert が成功する用に set_multi する.

class TestFileStore(unittest.TestCase):

    def setUp(self):
        self.store = FileStore()
        self.store.set('foo', 'hoge')

    def test_set_multi(self):
        self.store.set_multi({'bar': 'fuga', 'buzz':'piyo'})
        self.assertEqual(self.store.dump(),
                         'foo:hoge\nbar:fuga\nbuzz:piyo\n')

ここまでできたら set_multi を実装する.

set_multi の実装

テストができたから set_multi を実装する.

明白な実装なのでそのまま実装しちゃう.

実装はこんな感じ

class FileStore(object):

    def __init__(self):
        self._store = OrderedDict()

    def set(self, key, value):
        if key is None or key is '':
            return
        if key in self._store:
            self._store.pop(key)
        self._store[key] = value

    def set_multi(self, pairs):
        for k, v in pairs.items():
            self.set(k, v)

    ...

テストもばっちり成功!!

get_multi のテストと実装

set_multi と同様に get_multi も行います.

1. テスト作成
2. テスト失敗
3. 実装
4. テスト成功

は変わりません.

うんで,出来上がるテストと実装はこんな感じ.

class FileStore(object):

    ...

    def get(self, key):
        return self._store.get(key, None)

    def get_multi(self, keys):
        return [self.get(k) for k in keys if k in self._store]
        
    ...


import unittest

class TestFileStore(unittest.TestCase):

    def setUp(self):
        self.store = FileStore()
        self.store.set('foo', 'hoge')

    ...

    def test_get_multi(self):
        self.store.set_multi({'bar': 'fuga', 'buzz':'piyo'})
        self.assertEqual(
            self.store.get_multi(['buzz', 'toto', '', 'bar']),
            ['piyo', 'fuga'])

set_multiget_multi も既存のメソッドに影響を与えないので,わりと簡単に追加できた.

次の仕様変更はちょっと大変だよ!

仕様変更 2

メインディッシュきた!

次の仕様変更はこんな感じ.

>>> store = FileStore()
>>> store.set('foo', '${now}') # 現在時刻が登録される
>>> store.get('foo')
2010-07-16T21:57:48
テストがかけない.

残念ながらこのままじゃテストを書けない (>_<)

現在時刻とかをテストで扱うのはぶっちゃけ無理.

テストが書けない場合,テストができない原因になっているところだけ取り出して,それ以外のところでテストすればいいと思う.

なので開き直って現在時刻をテストするのはしない.

ここではテストするのは set が "${now}" を何かしら展開しよするかどうかをテストする.

そんなテストはこんな感じに書いた.

class FakeExpander(object):
    def expand(self, value):
        if value == '${now}':
            return '%s is expanded' % value
        return value


class TestFileStore(unittest.TestCase):

    def setUp(self):
        self.store = FileStore()
        self.store.set('foo', 'hoge')

    def test_set(self):
        fake_expander = FakeExpander()
        self.store.set_expander(self.fake_expander)
        self.store.set('bar', '${now}')
        self.assertEqual(self.store.dump(), 'foo:hoge\nbar:${now} is expanded\n')

    ...

このテストがクリアされるように set を修正し,こっそり増えた set_expander を追加する.

class Expander(object):
    def expand(self, value):
        if value == '${now}':
            return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        return value

class FileStore(object):

    def __init__(self):
        self._store = OrderedDict()
        self._expander = Expander()

    def set_expander(self, expander):
        self._expander = expander

    def expand(self, value):
        return self._expander.expand(value)

    def set(self, key, value):
        if key is None or key is '':
            return
        if key in self._store:
            self._store.pop(key)
        self._store[key] = self.expand(value)

    ...

さらにこっそり,expand も追加されてるけど気にしない.
というのも set_expander にしろ,expand にしろそこに不安はないのでテストはない.

もう一点,Expander っていうクラスを追加した.

ここがまさにテストの出来ない部分で,ここについてのテストは書かないことにした.

テストが出来ない部分はテストが必要ないくらい単純なものにしてやれば,開き直ってテストなしでいいと思う.


さらに Expander クラスを導入したことで,"${now}" 以外に "${name}" とか増やしたくなった場合でも,FileStore にテストを追加する必要はなくて,Expander の方をごにょごにょしてやればよくなってます.

まとめ

TDD Boot Camp 名古屋の 1 日目でやったことの復習をした.

とりあえず,仕様変更 2 までやった.

仕様変更 3 (データの生存期間指定機能の追加) は力尽きたので省略しちゃう (ごめんなさい (>_<) )

仕様変更 3 も仕様変更 2 のときと同じように,テストができない部分は最小化してモックオブジェクトとか作ってテストすればいいと思う.

仕様変更 3 の場合だったら,CachedFileStore クラスを導入してデータの保持とかは FileStore に委譲.
CachedFileStore はデータの生存,消滅の管理やらせて,消滅させるかどうかの判断だけを行うクラスを導入.

仕様変更 3 でテストできないのが時間関連の判断だけなので,そこをちっちゃいクラスに閉じ込めてしまえば他の部分は全部テストできるようになるっていう思惑.

責任の分割,超大切.


TDD では,とにかくテストから書く.

テストの中では assert から書く.

普通のプログラムはメソッドの上から書くけど,テスト プログラムはメソッドの下から書く.

テストもメンテナンスする.


最期に最終的なコードを.

#!/usr/bin/env python3
# -*- utf-8 -*-

from datetime import datetime
from collections import OrderedDict

class Expander(object):
    def expand(self, value):
        if value == '${now}':
            return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        return value

    
class FileStore(object):

    def __init__(self):
        self._store = OrderedDict()
        self._expander = Expander()

    def set_expander(self, expander):
        self._expander = expander

    def expand(self, value):
        return self._expander.expand(value)

    def set(self, key, value):
        if key is None or key is '':
            return
        if key in self._store:
            self._store.pop(key)
        self._store[key] = self.expand(value)

    def set_multi(self, pairs):
        for k, v in pairs.items():
            self.set(k, v)

    def get(self, key):
        return self._store.get(key, None)

    def get_multi(self, keys):
        return [self.get(k) for k in keys if k in self._store]
        
    def dump(self):
        return '\n'.join(
            [':'.join([k, v]) for k, v in self._store.items()]) + '\n'



import unittest


class FakeExpander(object):
    def expand(self, value):
        if value == '${now}':
            return '%s is expanded' % value
        return value


class TestFileStore(unittest.TestCase):

    def setUp(self):
        self.store = FileStore()
        self.store.set('foo', 'hoge')

    def test_set(self):
        fake_expander = FakeExpander()
        self.store.set_expander(fake_expander)
        self.store.set('bar', '${now}')
        self.assertEqual(self.store.dump(),
                         'foo:hoge\nbar:${now} is expanded\n')

    def test_get1(self):
        self.assertEqual(self.store.get('foo'), 'hoge')

    def test_get_miss_hit(self):
        self.assertEqual(self.store.get('toto'), None)

    def test_set_invalid_key1(self):
        self.store.set('bar', 'fuga')
        self.store.set(None, 'momo')
        self.assertEqual(self.store.dump(), 'foo:hoge\nbar:fuga\n')

    def test_set_invalid_key2(self):
        self.store.set('bar', 'fuga')
        self.store.set('', 'momo')
        self.assertEqual(self.store.dump(), 'foo:hoge\nbar:fuga\n')

    def test_set_overwrite(self):
        self.store.set('bar', 'fuga')
        self.store.set('foo', 'buzz')
        self.assertEqual(self.store.dump(), 'bar:fuga\nfoo:buzz\n')

    def test_dump1(self):
        self.assertEqual(self.store.dump(), 'foo:hoge\n')

    def test_dump3(self):
        self.store.set('bar', 'fuga')
        self.assertEqual(self.store.dump(), 'foo:hoge\nbar:fuga\n')
        
    def test_set_multi(self):
        self.store.set_multi({'bar': 'fuga', 'buzz':'piyo'})
        self.assertEqual(self.store.dump(),
                         'foo:hoge\nbar:fuga\nbuzz:piyo\n')

    def test_get_multi(self):
        self.store.set_multi({'bar': 'fuga', 'buzz':'piyo'})
        self.assertEqual(
            self.store.get_multi(['buzz', 'toto', '', 'bar']),
            ['piyo', 'fuga'])


if __name__ == '__main__':
    unittest.main()