Block Rockin’ Codes

back with another one of those block rockin' codes

PythonでTDD

Javaでのペアプロは何度かやってきましたが、今回始めてpythonペアプロをする機会がありました。
ある勉強会で、pythonのunittestとdoctestの話をしたのをきっかけに、そのままペアプロをしてみることに。


組んだ相手は、ペアプロもTDDも始めてだったので、どんな感じでtddを進めて行くのかは、自分が流れを教えながら、
pythonでのunittestの書き方は二人で調べながらやりました。


ちょっと完成度は半端ですが、やってみて色々分かったのでメモ。
やったのはお決まりの"FizzBuzz"です。

FizzBuzzの仕様

お題はその場で思いついた"FizzBuzz"を一通り実装して、
その後、お約束の"追加仕様"を適当に考えて実装しました。


最初の仕様は

  • numを渡すと1〜numまでのfizzbuzzの入ったリストを返す。


追加の仕様は

  • "fizz"と"buzz"の文字をオプションで変更できるようにしたい。
  • "start"と"end"に数値を渡したらstart〜endまでのfizzbuzzを返してほしい。

という感じです。

unittest

pythonのunittestの実装方法です。
pythonは標準のライブラリにunittestモジュールがあるのでそれを使いました。

流れ
  1. まずimport unittest
  2. 初期化処理、終了処理はsetUp(),tearDown()に記述。
  3. そしてunittest.TestCaseを継承したクラスを作成し、その中に"test"で始まるメソッッドを作成。
  4. self.assetXXXX(A,B)みたいな形でテストを書く。
  5. unittest.main()で実行。

これだけです。

以下が基本形

# -*- coding:utf-8 -*-
import unittest
from fizzbuzz import FizzBuzz #テスト対象

class TestFizzBuzz(unittest.TestCase):  

    def setUp(self):
     #初期化処理
        pass

    def tearDown(self):
        #終了処理
        pass

    def testNewClass(self):
        #テストを書いて行く
        self.assertNotEqual(FizzBuzz(), None)


class TestRandom(unittest.TestCase):
    pass

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


しかし、最後のランナーの記述はこの場合

...
----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK

と簡素なものになります。

最後のランナー(unittest.main()の部分)の記述を以下のようにすると

    suite = unittest.TestLoader().loadTestsFromTestCase(TestRandom)
    unittest.TextTestRunner(verbosity=2).run(suite)


出力が少し変わります。

testchoice (__main__.TestRandom) ... ok
testsample (__main__.TestRandom) ... ok
testshuffle (__main__.TestRandom) ... ok

----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK

自分は後者の方が好きです。

doctest

pythonにはもう一つdoctestという機能があります。
unittestと少し違い、ソース中のコメント内にテストが書けるというものです。


以下のように、インタプリタで実行した結果をコピーして貼付けたような形で記述します。

そしてdoctest.testmod()を実行すると、実行時にdoctestに記述した内容が正しく実行されるかをチェックしてくれます。
また-vをつけて実行すれば詳細も確認できます。

def foo(n):
    """
    doctestのサンプルです。

    >>> foo(10)
    20

    >>> foo(0)
    0
    
    >>> foo(-10)
    -20
    """
    return n*2

def _test():
    import doctest
    doctest.testmod()
 
if __name__ == '__main__':
    _test()


このプログラムを、-vを付けて実行すると、

% python docTestSample.py -v 
Trying:
    foo(10)
Expecting:
    20
ok
Trying:
    foo(0)
Expecting:
    0
ok
Trying:
    foo(-10)
Expecting:
    -20
ok
2 items had no tests:
    __main__
    __main__._test
1 items passed all tests:
   3 tests in __main__.foo
3 tests in 3 items.
3 passed and 0 failed.
Test passed.


このようにどのテストが実行され、どれが失敗したかなどのログが出力されます。


また、ドキュメンテーション文字列内に記述されていることによって、モジュールやメソッド自体の使用方法や振る舞いの説明にもなるので、
dcotestは文字通り、testとdocumentの両方の役割をもった、とても面白い仕組みです。

Python TDDの進め方

今回、自分たちが実行したTDDの流れです。
相手は始めてだったので、出来る範囲でやりました。

バージョン管理

まず何よりもリポジトリを作ります。
今回はmercurialを使って管理し、テストを記述してGreen(ok)になる度に、テストメソッド名をメッセージにしてコミットすることにしました。

doctest

次にdoctestに最終的な動作結果を記述します。
doctestは実際の動きを記述するので、「これから作るものはどう動くべきか」を書くことで、目標地点がはっきりしますし、二人の認識も共通になります。
そして最終的にそのdoctestが通ることで完成となり、それはそのままドキュメントの役割も果たせます。


doctestだけでTDDをやることも出来なくはありませ。
しかし、その性質上あまり書きすぎるのはドキュメントを読みにくくするだけですし、なによりも規模が大きいと書くのも大変で修正もしにくい。
(doctestはコンソールの出力と文字列が完全一致しないと通らないので、例えばスペースの数等も厳密に書かないとエラー扱いになります。ex.[1,2,3]≠[1, 2, 3])
という理由から、今回のような使い方が割とdoctestの性質を生かしているんじゃないかと思っているんですがいかがでしょう?

unittest

そして、いよいよunittestを書きながらTDDで実装して行きます。
作成するfizzbuzz.pyに対してtestfizzbuzz.pyを作成し、testで始まる名前のテストを記述します。
ここからは基本的なxUnitの使い方と同じ流れで進みます。
今回はコマンドラインベースでやったので、err -> ok -> refuctoringの三角を回して行きます。


環境変更

最初は自分のマシンをベースにやっていたんですが、追加仕様に入る前に、あえて環境を丸ごとパートナーのPCに移してそっちでやってみました。
人の環境でやるのもなかなか勉強になります、同時に環境の差はTDDのネックにもなりやすい。
ただ今回は、二人ともMac-Emacs-英字配列-zshと、かなり近い環境だったので、全くと言っていいほど問題ありませんでした。
逆にこういうペアは恵まれているかもしれません。
ただ一つ、自分はMercurialを使うんですが、パートナーはgitだったので、移したときにリポジトリをどうしようかと思ったんですが、今回は移した時点でgit initしてしまってそこは別でやりました。
なので、最終成果物はmercurialのログが追加仕様開始前から完了後までは飛んでます。

成果物

最終的な成果物は一番最後に載せています。ここにもあります。
追加仕様の二つは、pythonの引数の扱いが柔軟だったため、想像以上に簡単に出来てしまいました。
時間に制限があったため、全体的にリファクタリングの余地はまだまだありますが、それはもう少し回せばキレイになるかな。

KPT

終わった後KPTをするのを忘れたので、振り返りながら一人KTP。

Keep
  • doctestに仕様(目標)を書くのは良かった。
  • テストもそれなりに書けた。
  • 「振る舞い」に対するテストを意識したので、仕様変更時にテストのリファクタリングはなしで出来た。
  • テストメソッド名でコミットは、メッセージ考えるタイムロスが無いわりに、どこでのコミットか分かりやすいのでこの場合はいいと思う。なにかあった時だけちゃんと書く。
  • 二人の環境が近かったので、その点恵まれてた、またお互い知らない使い方を共有できた。(Mac-英字配列-Emacs-zsh)
Problem
  • 例外処理(aeertRaises)が上手く使えないまま、JUnit3のようなfailを使った書き方をした。ここは調べる。
  • なんかあまりPythonの良さを生かしたコードにならなかった、色々取り入れた時の挙動を考えるより、if-forでの挙動を考える方がテストしやすいから自然とそっちに流れたんだと思う。
  • 実はKPTをやり忘れて、このKPTは書きながら一人でやっている。終わったその場で二人でやるべきだった。
  • ちょいちょいコミットし忘れた。
Try
  • noseを使う。
  • 例外処理をもう少しきちんと。
  • もっとpythonの良さを生かしたコードで書く。
  • もう少し規模の大きいものを作る。

成果物

testFizzBuzz.py
# -*- Coding:utf-8 -*-
import unittest
from fizzbuzz import FizzBuzz

class TestFizzBuzz(unittest.TestCase):  

    def setUp(self):
        self.num=100
        self.FizzBuzz=FizzBuzz()
        self.tmp=FizzBuzz().fizzbuzz(self.num)

    def tearDown(self):
        pass

    def testNewClass(self):
        self.assertNotEqual(FizzBuzz(), None)
        #self.assertEqual(xxxx,xxxxx)

    def testCallfizzbuzz(self):
        self.assertNotEqual(FizzBuzz().fizzbuzz(1),None)

    def testSimpleImput(self):
        self.assertEqual(FizzBuzz().fizzbuzz(1), [1])

    def testFirstFizz(self):
        self.assertEqual(self.tmp[2],'Fizz')

    def testFirstBuzz(self):
        self.assertEqual(self.tmp[4],'Buzz')

    def testFirstFizzBuzz(self):
        self.assertEqual(self.tmp[14],'FizzBuzz')

    def testFull(self):
        ans = [1, 2, 'Fizz', 4, 'Buzz', 'Fizz', 7, 8, 'Fizz', 'Buzz']
        self.assertEqual(self.tmp[0:10], ans)

    def testAllFizz(self):
        for i in range(self.num):
            if (i % 3 == 0 and i % 15 != 0):
                self.assertEqual(self.tmp[i-1], 'Fizz')

    def testAllBuzz(self):
        for i in range(self.num):
            if (i % 5 == 0 and i % 15 != 0):
                self.assertEqual(self.tmp[i-1], 'Buzz')

    def testAllFizzBuzz(self):
        for i in range(self.num):
            if (i % 15 == 0 and i != 0):
                self.assertEqual((i,self.tmp[i-1]), (i,'FizzBuzz'))

    def testMinusInput(self):
        self.assertEqual(self.FizzBuzz.fizzbuzz(-1), "入力値が不正です")

    def testZeroInput(self):
        self.assertEqual(self.FizzBuzz.fizzbuzz(0), "入力値が不正です")

    def testInputIsNotNumber(self):
        try:
            self.FizzBuzz.fizzbuzz('NaN')
        except TypeError:
            pass
        else:
            self.fail()

    def testInputFirstWord(self):
        tmp=FizzBuzz().fizzbuzz(self.num,first='kousei')
        for i in range(self.num):
            if (i % 3 == 0 and i % 15 != 0):
                self.assertEqual(tmp[i-1], 'kousei')

    def testInputBothWord(self):
        tmp=FizzBuzz().fizzbuzz(10,second='moriyama',first='kosei')
        self.assertEqual(tmp, [1, 2, 'kosei', 4, 'moriyama', 'kosei', 7, 8, 'kosei', 'moriyama'])

    def testInputEnd(self):
        tmp=FizzBuzz().fizzbuzz(end=10, second='moriyama',first='kosei')
        self.assertEqual(tmp, [1, 2, 'kosei', 4, 'moriyama', 'kosei', 7, 8, 'kosei', 'moriyama'])

    def testInputStartEnd(self):
        tmp=FizzBuzz().fizzbuzz(start=0, end=10, second='moriyama',first='kosei')
        self.assertEqual(tmp, [1, 2, 'kosei', 4, 'moriyama', 'kosei', 7, 8, 'kosei', 'moriyama'])

    def testInputAntherStart(self):
        tmp=FizzBuzz().fizzbuzz(start=5, end=10, second='moriyama',first='kosei')
        self.assertEqual(tmp, ['moriyama', 'kosei', 7, 8, 'kosei', 'moriyama'])

if __name__ == '__main__':
#    unittest.main()
    suite = unittest.TestLoader().loadTestsFromTestCase(TestFizzBuzz)
    unittest.TextTestRunner(verbosity=2).run(suite)
fizzbuzz.py
# coding:utf-8

class FizzBuzz:
    '''
    FizzBuzzを出力するプログラムです。
    
    整数Nを1つ渡すと、1〜Nまでのfizzbuzzをリストで返します。
    >>> FizzBuzz().fizzbuzz(10)
    [1, 2, 'Fizz', 4, 'Buzz', 'Fizz', 7, 8, 'Fizz', 'Buzz']

    first,socondに文字列を渡すと、fizzとbuzzを好きな文字に置き換えることが出来ます。
    >>> FizzBuzz().fizzbuzz(10, first='foo', second='bar')
    [1, 2, 'foo', 4, 'bar', 'foo', 7, 8, 'foo', 'bar']

    start,endに整数を渡すと、startの値〜endの値までのfizzbuzzをリストで返します。
    >>> FizzBuzz().fizzbuzz(start=10, end=20, first='foo', second='bar')
    ['bar', 11, 'foo', 13, 14, 'foobar', 16, 17, 'foo', 19, 'bar']

    '''

    def fizzbuzz(self, end, start = 0, first = 'Fizz', second = 'Buzz'):
        ans = []
        third = first + second

        if(not isinstance(end,int)):
            raise TypeError

        if (end <= 0):
            return "入力値が不正です"

        start = start - 1 if start != 0 else 0

        for i in range(start, end):
            n = i + 1
            if (n == 1):
                ans.append(n)
            elif (n % 15 == 0):
                ans.append(third)
            elif (n % 3 == 0):
                ans.append(first)
            elif (n % 5 == 0):
                ans.append(second)

            else:
                ans.append(n)
        return ans


def _test():
    import doctest
    doctest.testmod()
 
if __name__ == '__main__':
    _test()

'''
# Make and return TestSuite for this module.
def test_suite():
    import unittest
    from doctest import DocTestSuite
    return unittest.TestSuite(( # make test suite
        DocTestSuite('fizzbuzz'), # register myself as test
        ))

# This if statement is executed only when run this module as main module.
if __name__ == '__main__':
    import unittest
    unittest.main(defaultTest='test_suite') # execute test.
'''

ペアプロは本当にいいですね。