PythonでTDD
Javaでのペアプロは何度かやってきましたが、今回始めてpythonのペアプロをする機会がありました。
ある勉強会で、pythonのunittestとdoctestの話をしたのをきっかけに、そのままペアプロをしてみることに。
組んだ相手は、ペアプロもTDDも始めてだったので、どんな感じでtddを進めて行くのかは、自分が流れを教えながら、
pythonでのunittestの書き方は二人で調べながらやりました。
ちょっと完成度は半端ですが、やってみて色々分かったのでメモ。
やったのはお決まりの"FizzBuzz"です。
FizzBuzzの仕様
お題はその場で思いついた"FizzBuzz"を一通り実装して、
その後、お約束の"追加仕様"を適当に考えて実装しました。
最初の仕様は
- numを渡すと1〜numまでのfizzbuzzの入ったリストを返す。
追加の仕様は
という感じです。
unittest
pythonのunittestの実装方法です。
pythonは標準のライブラリにunittestモジュールがあるのでそれを使いました。
流れ
- まずimport unittest
- 初期化処理、終了処理はsetUp(),tearDown()に記述。
- そしてunittest.TestCaseを継承したクラスを作成し、その中に"test"で始まるメソッッドを作成。
- self.assetXXXX(A,B)みたいな形でテストを書く。
- 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の性質を生かしているんじゃないかと思っているんですがいかがでしょう?
環境変更
最初は自分のマシンをベースにやっていたんですが、追加仕様に入る前に、あえて環境を丸ごとパートナーのPCに移してそっちでやってみました。
人の環境でやるのもなかなか勉強になります、同時に環境の差はTDDのネックにもなりやすい。
ただ今回は、二人ともMac-Emacs-英字配列-zshと、かなり近い環境だったので、全くと言っていいほど問題ありませんでした。
逆にこういうペアは恵まれているかもしれません。
ただ一つ、自分はMercurialを使うんですが、パートナーはgitだったので、移したときにリポジトリをどうしようかと思ったんですが、今回は移した時点でgit initしてしまってそこは別でやりました。
なので、最終成果物はmercurialのログが追加仕様開始前から完了後までは飛んでます。
成果物
最終的な成果物は一番最後に載せています。ここにもあります。
追加仕様の二つは、pythonの引数の扱いが柔軟だったため、想像以上に簡単に出来てしまいました。
時間に制限があったため、全体的にリファクタリングの余地はまだまだありますが、それはもう少し回せばキレイになるかな。
KPT
終わった後KPTをするのを忘れたので、振り返りながら一人KTP。
Keep
Problem
Try
- noseを使う。
- 例外処理をもう少しきちんと。
- もっとpythonの良さを生かしたコードで書く。
- もう少し規模の大きいものを作る。
参考サイト
5.2 doctest -- 対話モードを使った使用例の内容をテストする
23.3 unittest -- 単体テストフレームワーク
Python Unit Testing Framework (in Japanese)
2006/05/21 PythonのDocTestでお手軽TDD - 清水川Web
Pythonで簡単な単体テストをはじめよう - doctest - tomoemonの日記
成果物
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. '''
ペアプロは本当にいいですね。