2012-01-15

mock で外部依存なくしてユニットテストする例

mock を使ったニットテストの、シンプルなシーンをまとめました。例として「Twitter アカウントのスクリーンネームを指定し、そのアカウントの最新のツイートの内容を取得する」という機能を実装する場合を考えます。

外部に依存しない機能のテスト

まず、簡単にユニットテストを書けるような場合から考えます。create_user_timeline_url() 関数を定義し、Twitter アカウントの screen_name を渡すと、そのアカウントのつぶやきを取得するために URL が返される、としましょう。たとえば Twitter アカウントのスクリーンネーム wozozo を渡すと、 http://twitter.com/statuses/user_timeline/wozozo.json が返ってくることを確認します。

実装とテストは以下のようになります。URL のテンプレートを定数にしたほうがいいとかありますが、そこは、やっつけです。

# tw.py
def create_user_timeline_url(screen_name):
    """return URL to get screen_name user's timeline"""
    base = "http://twitter.com/statuses/user_timeline/%s.json"
    return base % screen_name
# tests.py
import unittest
import tw

class CreateUserTimelineUrlTest(unittest.TestCase):
    def test(self):
        result = tw.create_user_timeline_url('wozozo')
        expected = 'http://twitter.com/statuses/user_timeline/wozozo.json'
        self.assertEqual(result, expected)

ユニットテストは「外部に依存させずに、既知の入力に対して、期待する出力が得られることを確認する」ことを確認する、ということなので、上記でユニットテストができています。

外部に依存する機能のテスト

続いて get_latest_tweet_text(screen_name) という関数を作ります。これは screen_name を与えると、そのアカウントの最新のツイートのテキストを返してくれる関数です。たとえばユーザ wozozo が、最後に「show!」とツイッターで発言していれば、この関数は文字列「show!」を返します。機能の実装と、テストはこんな感じでしょうか。

# tw.py
import urllib
import json

def get_latest_tweet_text(screen_name):
    """return latest tweet message by screen_name user"""
    url = create_user_timeline_url(screen_name)
    fin = urllib.urlopen(url)
    content = fin.read()
    content_obj= json.loads(content)
    return content_obj[0]['text']

...
# tests.py
import unittests

class GetLatestTweetTextTest(unittest.TestCase):
    def test(self):
        text = tw.get_latest_tweet_text('wozozo')
        self.assertEqual(text, 'show!')

いろいろと問題があります。そもそも wozozo ユーザが最後に show! とつぶやいていないと、確実に破綻します。なので、テストが想定している状況を作り出す必要があります。

だからと言って setUp でいちいち「show!」とつぶやいてからテストするのは、あまりに外部に依存しすぎです。Twitter が落ちていたらテストできないからです。また、テスト項目が大量にあったら、ものすごい勢いで API をコールするわけで、IP アドレスからのアクセスの制限に引っかかります。OAuth が必要な機能を使っていたらアプリやアカウント停止されられるかもです。ですので外部に依存しないようにしたい。

(1件もつぶやいていない場合はエラーがでますが、別の問題なので、今は依存にだけ集中します)

get_latest_tweet_text の依存先は、

  • tw.create_user_timeline_url() 関数
  • urllib.urlopen() および、そこで発生する通信
  • json.loads() 関数

の、3つです。

2つ目の urlopen() は、すでに述べた理由から、ユニットテストでの依存を断ち切りたい箇所です。

3つ目の json.loads() は標準ライブラリの関数です。バグがないという保証ありませんが、Python のパッケージのテストを通っていることを考えると確率としては非常に低いでしょう。この部分は、正常に動作する Python の機能だと考えます。

問題は1 つめです。正直なところ、こういう関数への依存を切り離すかどうか迷っています。比較的シンプルな関数なので、いちいちモックとか作るのかよ、という気分になります。なりますが、ここはユニットテストでは依存させない箇所として扱います。よりどころにしているのは、 Daniel Arbuckle の「Python Testing Beginner's Guide」です。

class testable:
    […]
    def method3(self, number):
        return self.method1(number) * self.method2(number)
    def method4(self):
        return 1.713 * self.method3(id(self))

[…]
Consider method4. Its result depends on all of the other methods working correctly. On top of that, it depends on something that changes from one test run to another, the unique ID of the self object. Is it even possible to treat method4 as a unit in a self-contained test? If we could change anything except method4, what would we have to change to enable method4 to run in a self-contained test and produce a predictable result?
(超訳: method4 の結果の正しさは、他のメソッドがすべて正しく動作することに依存している。それに加えて、self のユニークな ID を取得するなどという、テストごとに値が変わるようなものにも依存している。そんな状況で method4 を自己完結しているものとしてテストできるだろうか? method4 以外に変更を加えたときに、method4 のテストがとおり、かつ、予想できる結果を得られるようにするには、どうしたらいい?)

create_user_timeline_url() の処理はシンプルですが、標準ライブラリよりはバグ率が高いでしょう。また Twitter の都合で URL が変わるかも知れません。そう考えると、create_user_timeline_url を本当に呼び出さずに、この関数の戻り値が既知の入力であるかのようにテストをしたくなってきます。

というわけで create_user_timeline_url と urllib.urlopen をモックにします。

# tests.py
import unittest
import mock
import tw

class GetLatestTweetTextTest(unittest.TestCase):
    @mock.patch('urllib.urlopen')
    @mock.patch('tw.create_user_timeline_url')
    def test(self, create, urlopen):
        # mock create_user_timeline_url
        url = 'http://example.com/wozozo'
        create.return_value = url
        # mock URL reader
        fin = mock.Mock()
        fin.read.return_value = '[{"text":"show!"}, {"text":"tenga"}]'
        # and set it to urlopen's return value
        urlopen.return_value = fin
        # call UUT function
        text = tw.get_latest_tweet_text('wozozo')
        # assert returned text
        self.assertEqual(text, 'show!')
        # assert 'bucho' is passed to create func once
        self.assertEqual(create.call_count, 1)
        self.assertEqual(create.call_args, (('wozozo',), {}))
        # assert URL from create func is passed to urlopen once
        self.assertEqual(urlopen.call_count, 1)
        self.assertEqual(urlopen.call_args, ((url,), {}))
        # assert urlopen().read() is called once w/o args
        self.assertEqual(fin.read.call_count, 1)
        self.assertEqual(fin.read.call_args, ((), {}))

...

順番に見ていきま show。

class GetLatestTweetTextTest(unittest.TestCase):
    @mock.patch('urllib.urlopen')
    @mock.patch('tw.create_user_timeline_url')
    def test(self, create, urlopen):

tw.create_user_timeline_url, urllib.urlopen を mock.Mock オブジェクトと入れ替えます。そして、このtest メソッドの中では、それぞれ create、 url_open という名前でモックにアクセスできます。

        # mock create_user_timeline_url
        url = 'http://example.com/wozozo'
        create.return_value = url

create_user_timeline_url 関数の戻り値を定数で指定しておきます。

        # mock URL reader
        fin = mock.Mock()
        fin.read.return_value = '[{"text":"show!"}, {"text":"tenga"}]'

urlopen() は本来 file-like オブジェクトを返し、これが裏側で通信します。通信させたくないので、Mock を作ります。そして、read() メソッドの戻り値を指定しておきます。本当は、もうちょっと本物のレスポンスに似せたほうがいいかも知れません。

        # and set it to urlopen's return value
        urlopen.return_value = fin

それを urlopen() の戻り値で返されるようにします。これで、urllib.urlopen() は、上で定義した fin が返るようになります。

        # call UUT function
        text = tw.get_latest_tweet_text('wozozo')

で、テスト対象の関数を呼び出します。以下、入出力の確認です。

        # assert returned text
        self.assertEqual(text, 'show!')

まず、戻り値の確認。

        # assert 'bucho' is passed to create func once
        self.assertEqual(create.call_count, 1)
        self.assertEqual(create.call_args, (('wozozo',), {}))

create_user_timeline_url('wozozo') が1度だけ実行されていることを確認します。そして、このときの戻り値が...

        # assert URL from create func is passed to urlopen once
        self.assertEqual(urlopen.call_count, 1)
        self.assertEqual(urlopen.call_args, ((url,), {}))

urlopen() の引数に使われていることを確認。

        # assert urlopen().read() is called once w/o args
        self.assertEqual(fin.read.call_count, 1)
        self.assertEqual(fin.read.call_args, ((), {}))

最後に read() メソッドが引数なしで呼ばれていることを確認して、おわり。

urlopen() など依存先の関数/メソッド呼び出しというのはテスト対象関数からの「出力」なので assert でテストしています。そして、それらの戻り値というのは、テスト対象関数への「入力」なのでモックで既知の値を渡しています。こうすれば「外部に依存させずに、既知の入力に対して、期待する出力が得られることを確認する」というユニットテストができることになります。

まとめ

以上、外部依存する部分を mock で入れ替えるシーンを、書いてみました。シーンとしてはシンプルなんですが、煩雑に思えるかも知れません。どこまでを外部とするのか、というのは未だに課題です。通信するとか、開発中の比較的大きな別モジュールとかは、外部扱いにしています。

2012-01-07

Google App Engine の開発サーバで pdb.set_trace() を使う

Google App Engine で開発していて、標準デバッガの pdb を使おうとして困ったのでその顛末のメモです。

import pdb; pdb.set_trace() 

と書いても、期待通りに動作しません。stdin と stdout の向き先が変わっているので、画面への表示とキーボードからの入力ができないのだと思います。

有用なブログのコメントを見つけましたので、そのままパクり。

gaedb.py というファイルを作っておきます。

# gaedb.py
def set_trace():
    import pdb, sys
    debugger = pdb.Pdb(stdin=sys.__stdin__,
        stdout=sys.__stdout__)
    debugger.set_trace(sys._getframe().f_back)

で、アプリケーションコード内で、

import gaedb; gaedb.set_trace()

と書けば、期待通りの pdb.set_trace() 動作します。ちゃんちゃん。

2012-01-01

Mercurial の Case Folding Collision を修復する

調子にのって Makefile を書いていたら、平行しているブランチで makefile と Makefile を作るという失態です。ほんとうに、あけましておめでとうございます。

結論から言うと、How to fix Mercurial Case Folding Collisionで解決しました。以下、個人的なメモです。

問題の再現方法

ベースになるリポジトリを作る。

$ hg init foo
$ cd foo
$ touch foo.c
$ hg add
adding foo.c
$ hg commit -m "foo.c"

小文字の「makefile」を持つブランチを作る。

$ hg branch low
marked working directory as branch low
$ touch makefile
$ hg add
adding makefile
$ hg commit -m "added makefile"

default ブランチで「Makefile」を作る。

$ hg up default
0 files updated, 0 files merged, 1 files removed, 0 files unresolved
$ touch Makefile
$ hg add
adding Makefile
$ hg commit -m "added Makefile"

ブランチをマージする。

$ hg up default
0 files updated, 0 files merged, 0 files removed, 0 files unresolved
$ hg merge low
1 files updated, 0 files merged, 0 files removed, 0 files unresolved
(branch merge, don't forget to commit)
$ hg st
M makefile
$ hg commit -m "merged"
$ hg up low
0 files updated, 0 files merged, 1 files removed, 0 files unresolved
$ hg up default
abort: case-folding collision between makefile and Makefile

修正する

ブランチを眺めましょう。

$ hg glog
o    changeset:   3:94ffa654c8c1
|\   tag:         tip
| |  parent:      2:65bd31a51e91
| |  parent:      1:67211aceefe3
| |  user:        furukawa
| |  date:        Sun Jan 01 16:39:17 2012 +0900
| |  summary:     merged
| |
| o  changeset:   2:65bd31a51e91
| |  parent:      0:21c6ffb22524
| |  user:        furukawa
| |  date:        Sun Jan 01 16:39:16 2012 +0900
| |  summary:     added Makefile
| |
@ |  changeset:   1:67211aceefe3
|/   branch:      low
|    user:        furukawa
|    date:        Sun Jan 01 16:39:15 2012 +0900
|    summary:     added makefile
|
o  changeset:   0:21c6ffb22524
   user:        furukawa
   date:        Sun Jan 01 16:39:14 2012 +0900
   summary:     foo.c

そんなわけで、具体的な作業にはいります。changeset 1 で、makefile を追加したのがそもそもの元凶である、と仮定します。このリビジョンを修正します。

$ hg debugsetparents 1
$ hg debugrebuildstate

上の操作で changeset 1 を親とするリビジョンにしています(たぶん)。で、makefile をなかった事にします。

$ hg rm -A -f makefile
$ hg forget makefile
$ hg commit -m "fixed case problem"
$ hg up -C tip
0 files updated, 0 files merged, 0 files removed, 0 files unresolved
$ hg glog
@  changeset:   4:b636a1636454
|  branch:      low
|  tag:         tip
|  parent:      1:e8c081d89471
|  user:        furukawa
|  date:        Sun Jan 01 16:47:06 2012 +0900
|  summary:     fixed case problem
|
| o  changeset:   3:20205d0bcf9d
|/|  parent:      2:d1de5468f0be
| |  parent:      1:e8c081d89471
| |  user:        furukawa
| |  date:        Sun Jan 01 16:41:58 2012 +0900
| |  summary:     merged
| |
| o  changeset:   2:d1de5468f0be
| |  parent:      0:43bc62047652
| |  user:        furukawa
| |  date:        Sun Jan 01 16:41:56 2012 +0900
| |  summary:     added Makefile
| |
o |  changeset:   1:e8c081d89471
|/   branch:      low
|    user:        furukawa
|    date:        Sun Jan 01 16:41:56 2012 +0900
|    summary:     added makefile
|
o  changeset:   0:43bc62047652
   user:        furukawa
   date:        Sun Jan 01 16:41:55 2012 +0900
   summary:     foo

default ブランチに、これを取り込んで終了

$ hg up 2
1 files updated, 0 files merged, 0 files removed, 0 files unresolved
$ hg merge low
0 files updated, 0 files merged, 0 files removed, 0 files unresolved
(branch merge, don't forget to commit)
$ hg commit
created new head

まとめ

こういう操作で修復できました。が、実はよくわかっていません。とくに、debugsetparents と debugrebuildstate が危ういです。