Top

(ΦωΦ)ノ やあこんにちは。これはPython Advent Calendar 2015の15日めの記事です。昨日は@TakesxiSximadaさんでした
今年はプロコンのAdvent Calendarに参加しようと思って参加したんですけど、
もうひとつぐらい参加しとこうかと思ってPythonにしました。

---
Pythonに関する内容ということで、単体テストを選んでみました。
プログラムを書く以上は思った通りに動いてほしいわけで、それを確認する手段としてテストを書きたくなることがあります。
いつ書いたらいいのか、というのはあたし的には割とどうでもよくて、
最終的に合ってれば「テストファースト」だろうが「後からテスト」だろうが何でもいいじゃないかという気分です。

Pythonにはテスティングフレームワークが標準で入ってて、行儀がいいPythonらしいなと思うわけです。
標準で入ってるというのは「Pythonをインストールしたら、なんか知らんけどテスティングフレームワークも入ってる」ということです。
通常は
import unittest
    
をすると思うんですけど、これをしてもideoneで動くわけです。
JavaでJUnitとかだと、こうはいきません。
まあ、ideoneで動いたからどうやねんという気もしますが、
デファクトな標準じゃなくて、ちゃんと標準な感じが行儀良いですね。

---
ちょっと前の話になりますが、CodeIQにこんな記事が書かれました。
超スーパー素晴らしい記事だと思うのですが、Pythonの例がない!!!>(;ω;)

これを読んだ当時のあたしは、この問題を解いてみたわけです。それがこちら。たぶん3月29日にやったんでしょう。

(ΦωΦ)<greet.py
#!/usr/bin/python3
# -*- coding: utf-8 -*-
from datetime import datetime

class Greeter:
    def greet(self, now=datetime.now()):
        hour = now.hour
        if 5 <= hour < 12:
            return 'おはようございます'
        elif 12 <= hour < 18:
            return 'こんにちは'
        else:
            return 'こんばんは'
    

(ΦωΦ)<test.py
#!/usr/bin/python3
# -*- coding: utf-8 -*-
import pytz
import unittest
from collections import namedtuple
from datetime import datetime
from greet import Greeter

T = namedtuple('T', 'test_id, in_data, expected')

test_cases = [
    T('01', datetime(2015,  3, 29,  7, 49, 22,         tzinfo=pytz.timezone('Asia/Tokyo')), 'おはようございます'),
    T('02', datetime(2015,  3, 29,  0,  0,  0,         tzinfo=pytz.timezone('Asia/Tokyo')), 'こんばんは'),
    T('03', datetime(2015,  3, 29,  4, 59, 59,         tzinfo=pytz.timezone('Asia/Tokyo')), 'こんばんは'),
    T('04', datetime(2015,  3, 29,  5,  0,  0,         tzinfo=pytz.timezone('Asia/Tokyo')), 'おはようございます'),
    T('05', datetime(2015,  3, 29, 11, 59, 59,         tzinfo=pytz.timezone('Asia/Tokyo')), 'おはようございます'),
    T('06', datetime(2015,  3, 29, 12,  0,  0,         tzinfo=pytz.timezone('Asia/Tokyo')), 'こんにちは'),
    T('07', datetime(2015,  3, 29, 17, 59, 59,         tzinfo=pytz.timezone('Asia/Tokyo')), 'こんにちは'),
    T('08', datetime(2015,  3, 29, 18,  0,  0,         tzinfo=pytz.timezone('Asia/Tokyo')), 'こんばんは'),
    T('09', datetime(2015,  3, 29, 23, 59, 59,         tzinfo=pytz.timezone('Asia/Tokyo')), 'こんばんは'),
    T('10', datetime(2015,  3, 29, 23, 59, 59, 999999, tzinfo=pytz.timezone('Asia/Tokyo')), 'こんばんは'),
    T('11', datetime(2015,  3, 29,  4, 59, 59, 999999, tzinfo=pytz.timezone('Asia/Tokyo')), 'こんばんは'),
    T('12', datetime(2015,  3, 29, 11, 59, 59, 999999, tzinfo=pytz.timezone('Asia/Tokyo')), 'おはようございます'),
    T('13', datetime(2015,  3, 29, 17, 59, 59, 999999, tzinfo=pytz.timezone('Asia/Tokyo')), 'こんにちは'),
    T('14', datetime(2015,  3, 29,  0,  0,  0,      0, tzinfo=pytz.timezone('Asia/Tokyo')), 'こんばんは'),
    T('15', datetime(2015,  3, 29,  5,  0,  0,      0, tzinfo=pytz.timezone('Asia/Tokyo')), 'おはようございます'),
    T('16', datetime(2015,  3, 29, 12,  0,  0,      0, tzinfo=pytz.timezone('Asia/Tokyo')), 'こんにちは'),
    T('17', datetime(2015,  3, 29, 18,  0,  0,      0, tzinfo=pytz.timezone('Asia/Tokyo')), 'こんばんは'),
]

class TestSequence(unittest.TestCase):
    pass

def test_generator(in_data, expected):
    def _test(self):
        greeter = Greeter()
        self.assertEqual(greeter.greet(in_data), expected)
    return _test

for case in test_cases:
    name = 'test-{0}'.format(case.test_id)
    test = test_generator(case.in_data, case.expected)
    setattr(TestSequence, name, test)
suite = unittest.TestLoader().loadTestsFromTestCase(TestSequence)
testresult = unittest.TextTestRunner(verbosity=0).run(suite)
assert testresult.wasSuccessful()
    

---
ちょっとコードを見ときましょう。test.pyのほうです。

11行目から29行目でテストを定義してます。
'01'から'17'というのはテストを一意に決める名前です。
コケたときにどれがダメだったのかを分かりやすくするためのものです。
テストメソッドの名前に使われるようにしてる(l.41)ので、もっと分かりやすく日本語にしたりしてもいいんじゃないですかね。

残りの2つの値は「入力値」と「正解の値」です。
「入力値」の値でメソッドgreet()を呼んだ結果が「正解の値」と等しいかどうかをテストしてます(l.37)。

実際にテストするのはunittest.TestCaseの子供のTestSequenceというクラスなんですけど、
そのクラスにsetattr()でメソッド名と実際のメソッドをどんどん追加してます(l.43)。LLっぽいですね。

---
実際に動かしてみましょう。

$ python3 test.py 
----------------------------------------------------------------------
Ran 17 tests in 0.001s

OK
$ 
    

素気ないですね。あえて間違えてみましょう。greet.py 10行目の「18未満」というのを「17未満」とかにしてみましょうか。

$ python3 test.py
======================================================================
FAIL: test-07 (__main__.TestSequence)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test.py", line 37, in _test
    self.assertEqual(greeter.greet(in_data), expected)
AssertionError: 'こんばんは' != 'こんにちは'
- こんばんは
+ こんにちは


======================================================================
FAIL: test-13 (__main__.TestSequence)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test.py", line 37, in _test
    self.assertEqual(greeter.greet(in_data), expected)
AssertionError: 'こんばんは' != 'こんにちは'
- こんばんは
+ こんにちは


----------------------------------------------------------------------
Ran 17 tests in 0.002s

FAILED (failures=2)
Traceback (most recent call last):
  File "test.py", line 46, in <module>
    assert testresult.wasSuccessful()
AssertionError
$ 
    

17個全部やったけど、番号'07'と番号'13'でダメでした、というのが分かるかと思います。
前掲の記事のAssertion Rouletteにはなってないはずです。

---
実際にはいろんな関数をテストすると思うので、namedtupleの引数に関数そのものを与えて、
テストメソッドを作る(下のll.19-21)ときにその関数の名前を入れる、とかになると思います。こんな感じ。

(ΦωΦ)<あたし的テンプレート
from collections import namedtuple
import unittest

T = namedtuple('T', 'func, in_data, expected')

test_cases = [
#    T(fibo, 100, 354224848179261915075), # (ΦωΦ)<こんな感じで
]

class TestSequence(unittest.TestCase):
    pass

def test_generator(func, in_data, expected):
    def _test(self):
        self.assertEqual(func(in_data), expected)
    return _test

for case in test_cases:
    name = 'test-{}-{}'.format(case.func.__name__, case.in_data)
    test = test_generator(*case)
    setattr(TestSequence, name, test)
suite = unittest.TestLoader().loadTestsFromTestCase(TestSequence)
testresult = unittest.TextTestRunner().run(suite)
assert testresult.wasSuccessful()
    

あたしは、エディタでこのテンプレートをスニペット的に呼べるようにしといて、
test_casesのリストにテストを書く、という感じでやってます。

さらっと書いて、さらっとテストできるので良いんじゃないでしょうか。

---
ではでは、また来年。お会いすることがあれば。
あしたは@_yukinoiさんです。お楽しみに!

(ΦωΦ)<おしまい。カレンダーに戻るよ!