Python で TDD してみる

RSpec の入門とその一歩先へ がとてもよい記事だったので、 Python で写経させてもらいました。 https://github.com/methane/pytest-tut

Ruby コミュニティと Python コミュニティの考え方の違いも見えて面白いと思います。

環境は Python 3.3 で、実行には py.test コマンドを使いましたが、 py.test の機能は特に使っていないので nose でもなんでも大丈夫です。

ファイルの作成

まずは空の実装とテストを作ります。

message_filter.py

class MessageFilter:
    pass

message_filter_test.py


最初のテストを書く

py.test は .should といったメソッドを勝手に生やしたりはしません。普通に assert 文を書きましょう。

--- a/messege_filter_test.py
+++ b/messege_filter_test.py
+from messege_filter import MessageFilter
+
+def test_message_filter():
+    filter = MessageFilter('foo')
+    assert filter.detect("hello from foo"), "should detect message with NG word."

py.test コマンドで実行します

============================= test session starts ==============================
platform darwin -- Python 3.3.0 -- pytest-2.3.4
collected 1 items

messege_filter_test.py F

=================================== FAILURES ===================================
_____________________________ test_message_filter ______________________________

    def test_message_filter():
>       filter = MessageFilter('foo')
E       TypeError: object.__new__() takes no parameters

messege_filter_test.py:4: TypeError
=========================== 1 failed in 0.01 seconds ===========================

コンストラクタが無いので作りましょう

コンストラクタ作成

--- a/messege_filter.py
+++ b/messege_filter.py
@@ -1,2 +1,3 @@
 class MessageFilter:
-    pass
+    def __init__(self, word):
+        self.word = word
============================= test session starts ==============================
platform darwin -- Python 3.3.0 -- pytest-2.3.4
collected 1 items

messege_filter_test.py F

=================================== FAILURES ===================================
_____________________________ test_message_filter ______________________________

    def test_message_filter():
        filter = MessageFilter('foo')
>       assert filter.detect("hello from foo"), "should detect message with NG word."
E       AttributeError: 'MessageFilter' object has no attribute 'detect'

messege_filter_test.py:5: AttributeError
=========================== 1 failed in 0.01 seconds ===========================

つぎは detect メソッドが無いと怒られました。作りましょう。

detectメソッド作成

元記事に習って、まずは空実装をします。当然失敗します。

--- a/messege_filter.py
+++ b/messege_filter.py
@@ -1,3 +1,6 @@
 class MessageFilter:
     def __init__(self, word):
         self.word = word
+
+    def detect(self, text):
+        pass
============================= test session starts ==============================
platform darwin -- Python 3.3.0 -- pytest-2.3.4
collected 1 items

messege_filter_test.py F

=================================== FAILURES ===================================
_____________________________ test_message_filter ______________________________

    def test_message_filter():
        filter = MessageFilter('foo')
>       assert filter.detect("hello from foo"), "should detect message with NG word."
E       AssertionError: should detect message with NG word.

messege_filter_test.py:5: AssertionError
=========================== 1 failed in 0.01 seconds ===========================

仮実装

True を返すだけの仮実装をしてテストが通ることを確認します。

--- a/messege_filter.py
+++ b/messege_filter.py
@@ -3,4 +3,4 @@ class MessageFilter:
         self.word = word
 
     def detect(self, text):
-        pass
+        return True
============================= test session starts ==============================
platform darwin -- Python 3.3.0 -- pytest-2.3.4
collected 1 items

messege_filter_test.py .

=========================== 1 passed in 0.01 seconds ===========================

三角測量

今度は detect が False を返すべきテストケースを書きます。当然失敗するようになりました。

--- a/messege_filter_test.py
+++ b/messege_filter_test.py
@@ -3,3 +3,4 @@ from messege_filter import MessageFilter
 def test_message_filter():
     filter = MessageFilter('foo')
     assert filter.detect("hello from foo"), "should detect message with NG word."
+    assert not filter.detect("hello, world!"), "should not detect message without NG word."
============================= test session starts ==============================
platform darwin -- Python 3.3.0 -- pytest-2.3.4
collected 1 items

messege_filter_test.py F

=================================== FAILURES ===================================
_____________________________ test_message_filter ______________________________

    def test_message_filter():
        filter = MessageFilter('foo')
        assert filter.detect("hello from foo"), "should detect message with NG word."
>       assert not filter.detect("hello, world!"), "should not detect message without NG word."
E       AssertionError: should not detect message without NG word.

messege_filter_test.py:6: AssertionError
=========================== 1 failed in 0.01 seconds ===========================

明白な実装

こんどはちゃんとした実装を書きましょう。 仮実装→三角測量の繰り返しではなくて一気に実装を書くことを明白な実装と呼ぶらしいです。

--- a/messege_filter.py
+++ b/messege_filter.py
@@ -3,4 +3,4 @@ class MessageFilter:
         self.word = word
 
     def detect(self, text):
-        return True
+        return self.word in text
============================= test session starts ==============================
platform darwin -- Python 3.3.0 -- pytest-2.3.4
collected 1 items

messege_filter_test.py .

=========================== 1 passed in 0.01 seconds ===========================

以降、元記事の第一イテレーションで行なっている改良はしません。 py.test に仕様を自然言語のように記述する能力はないので、普通に Python の機能だけでテストを書きましたが、十分に簡潔で読みやすいのでこれで問題ありません。

RSpec との違いは、 Python がDSLを作るのに向かない言語だという理由もありますが、 Python のコミュニティでは簡潔さや可読性(自然言語のように読めるかではなく、コードを理解するのにかかる時間)を大幅に改善できないなら、機能(覚えること)を増やさないという文化があるように思います。

きっと、見た目のカスタマイズのために時間を掛けたくない デフォルト教 の人には Python が向いていると思います。

test関数追加

ということで、 第二イテレーション に行きましょう。

このイテレーションでは、NGワードを複数指定できるようにします。そのためのテストを追加します。

--- a/messege_filter_test.py
+++ b/messege_filter_test.py
@@ -1,6 +1,10 @@
 from messege_filter import MessageFilter
 
-def test_message_filter():
+def test_single_argument():
     filter = MessageFilter('foo')
     assert filter.detect("hello from foo"), "should detect message with NG word."
     assert not filter.detect("hello, world!"), "should not detect message without NG word."
+
+def test_multiple_argument():
+    filter = MessageFilter('foo', 'bar')
+    assert filter.detect('hello from bar')
============================= test session starts ==============================
platform darwin -- Python 3.3.0 -- pytest-2.3.4
collected 2 items

messege_filter_test.py .F

=================================== FAILURES ===================================
____________________________ test_multiple_argument ____________________________

    def test_multiple_argument():
>       filter = MessageFilter('foo', 'bar')
E       TypeError: __init__() takes 2 positional arguments but 3 were given

messege_filter_test.py:9: TypeError
====================== 1 failed, 1 passed in 0.01 seconds ======================

想定通りのエラーがでました。

ベタな実装

可変長引数に対応する実装を一気にしてしまいます。

--- a/messege_filter.py
+++ b/messege_filter.py
@@ -1,6 +1,9 @@
 class MessageFilter:
-    def __init__(self, word):
-        self.word = word
+    def __init__(self, *words):
+        self.words = words
 
     def detect(self, text):
-        return self.word in text
+        for word in self.words:
+            if word in text:
+                return True
+        return False
============================= test session starts ==============================
platform darwin -- Python 3.3.0 -- pytest-2.3.4
collected 2 items

messege_filter_test.py ..

=========================== 2 passed in 0.01 seconds ===========================

テストを追加

2つの引数を取るテスト関数に、1つの引数を取るテスト関数で行なっている assert も追加します。

--- a/messege_filter_test.py
+++ b/messege_filter_test.py
@@ -8,3 +8,5 @@ def test_single_argument():
 def test_multiple_argument():
     filter = MessageFilter('foo', 'bar')
     assert filter.detect('hello from bar')
+    assert filter.detect("hello from foo"), "should detect message with NG word."
+    assert not filter.detect("hello, world!"), "should not detect message without NG word."
============================= test session starts ==============================
platform darwin -- Python 3.3.0 -- pytest-2.3.4
collected 2 items

messege_filter_test.py ..

=========================== 2 passed in 0.01 seconds ===========================

関数を使って重複を排除する

2つのテスト関数に同じテストが重複して存在していますね。

RSpec の share_examples_for を真似するには、テストを関数からクラスにして mix-in することで、 Python の機能だけで十分です。

でも、これくらいならまだクラス化するのは牛刀な気がします。共通する部分を関数化するだけで済ませてしまいましょう。

--- a/messege_filter_test.py
+++ b/messege_filter_test.py
@@ -1,12 +1,14 @@
 from messege_filter import MessageFilter
 
-def test_single_argument():
-    filter = MessageFilter('foo')
+def check_foo(filter):
     assert filter.detect("hello from foo"), "should detect message with NG word."
     assert not filter.detect("hello, world!"), "should not detect message without NG word."
 
+def test_single_argument():
+    filter = MessageFilter('foo')
+    check_foo(filter)
+
 def test_multiple_argument():
     filter = MessageFilter('foo', 'bar')
     assert filter.detect('hello from bar')
-    assert filter.detect("hello from foo"), "should detect message with NG word."
-    assert not filter.detect("hello, world!"), "should not detect message without NG word."
+    check_foo(filter)

元記事の RSpec 的に綺麗なコードにする改良はいらないのでスキップします。

リファクタリング

Ruby の Enumerable#any? の代わりに、 Python の組込み関数 any を使います。

--- a/messege_filter.py
+++ b/messege_filter.py
@@ -3,7 +3,4 @@ class MessageFilter:
         self.words = words
 
     def detect(self, text):
-        for word in self.words:
-            if word in text:
-                return True
-        return False
+        return any(word in text for word in self.words)

テストが通ればOKです。

第二イテレーション終了

これで第二イテレーション終了です。この時点でのコードは次のようになっています。

class MessageFilter:
    def __init__(self, *words):
        self.words = words

    def detect(self, text):
        return any(word in text for word in self.words)
from messege_filter import MessageFilter

def check_foo(filter):
    assert filter.detect("hello from foo"), "should detect message with NG word."
    assert not filter.detect("hello, world!"), "should not detect message without NG word."

def test_single_argument():
    filter = MessageFilter('foo')
    check_foo(filter)

def test_multiple_argument():
    filter = MessageFilter('foo', 'bar')
    assert filter.detect('hello from bar')
    check_foo(filter)

第三イテレーション開始

第三イテレーション の写経を始めます。 このイテレーションでは .ng_words 属性を使ってNGワードにアクセスできるようにします。

まずはテストを書きましょう。 .ng_words が空でないことを、共通のテストである check_foo() で行います。

--- a/messege_filter_test.py
+++ b/messege_filter_test.py
@@ -3,6 +3,7 @@ from messege_filter import MessageFilter
 def check_foo(filter):
     assert filter.detect("hello from foo"), "should detect message with NG word."
     assert not filter.detect("hello, world!"), "should not detect message without NG word."
+    assert filter.ng_words, "should not be empty."
 
 def test_single_argument():
     filter = MessageFilter('foo')
============================= test session starts ==============================
platform darwin -- Python 3.3.0 -- pytest-2.3.4
collected 2 items

messege_filter_test.py FF

=================================== FAILURES ===================================
_____________________________ test_single_argument _____________________________

    def test_single_argument():
        filter = MessageFilter('foo')
>       check_foo(filter)

messege_filter_test.py:10: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

filter = <messege_filter.MessageFilter object at 0x107e09f90>

    def check_foo(filter):
        assert filter.detect("hello from foo"), "should detect message with NG word."
        assert not filter.detect("hello, world!"), "should not detect message without NG word."
>       assert filter.ng_words, "should not be empty."
E       AttributeError: 'MessageFilter' object has no attribute 'ng_words'

messege_filter_test.py:6: AttributeError
____________________________ test_multiple_argument ____________________________

    def test_multiple_argument():
        filter = MessageFilter('foo', 'bar')
        assert filter.detect('hello from bar')
>       check_foo(filter)

messege_filter_test.py:15: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

filter = <messege_filter.MessageFilter object at 0x107e09e90>

    def check_foo(filter):
        assert filter.detect("hello from foo"), "should detect message with NG word."
        assert not filter.detect("hello, world!"), "should not detect message without NG word."
>       assert filter.ng_words, "should not be empty."
E       AttributeError: 'MessageFilter' object has no attribute 'ng_words'

messege_filter_test.py:6: AttributeError
=========================== 2 failed in 0.02 seconds ===========================

明白な実装

一気に実装してしまいます。ついでに ng_words の個数や、ちゃんとNGワードが登録されていることを確認するテストも追加してしまいました。

diff --git a/messege_filter.py b/messege_filter.py
index 50bcfd8..f2d94a5 100644
--- a/messege_filter.py
+++ b/messege_filter.py
@@ -1,6 +1,6 @@
 class MessageFilter:
     def __init__(self, *words):
-        self.words = words
+        self.ng_words = set(words)
 
     def detect(self, text):
-        return any(word in text for word in self.words)
+        return any(word in text for word in self.ng_words)
diff --git a/messege_filter_test.py b/messege_filter_test.py
index fc591dc..6a1b058 100644
--- a/messege_filter_test.py
+++ b/messege_filter_test.py
@@ -4,12 +4,16 @@ def check_foo(filter):
     assert filter.detect("hello from foo"), "should detect message with NG word."
     assert not filter.detect("hello, world!"), "should not detect message without NG word."
     assert filter.ng_words, "should not be empty."
+    assert 'foo' in filter.ng_words
 
 def test_single_argument():
     filter = MessageFilter('foo')
     check_foo(filter)
+    assert len(filter.ng_words) == 1
 
 def test_multiple_argument():
     filter = MessageFilter('foo', 'bar')
     assert filter.detect('hello from bar')
     check_foo(filter)
+    assert len(filter.ng_words) == 2
+    assert 'bar' in filter.ng_words
============================= test session starts ==============================
platform darwin -- Python 3.3.0 -- pytest-2.3.4
collected 2 items

messege_filter_test.py ..

=========================== 2 passed in 0.01 seconds ===========================

元記事ではこのあと RSpec を使って仕様記述する改良が行われていますが、例によって Python ではこれで十分でしょう。

このブログに乗せているコードは引用を除き CC0 1.0 で提供します。