Python: 標準ライブラリ unittest.mock を使ったテストダブル
ユニットテストを書くとき、テスト対象が何か別のコンポーネントに依存していると、テストが書きにくいことがある。 例えば、その依存しているコンポーネントがまだ開発の途中で無いとか、あるいは異常系などテストしたい状況を作りにくかったり、実行にすごく時間がかかるなどなど。 そんなときは、依存しているコンポーネントをテストダブルと呼ばれる代替品で置き換えてやると良い。 Python にはバージョン 3.3 から標準ライブラリにテストダブル用のパッケージ unittest.mock が取り込まれた。
使っている Python のバージョンが 3.3 未満のときは PyPI から mock をインストールする。 これは基本的にバージョン 3.3 で導入された unittest.mock と同じもの。
まずはテストを書くための題材だけど、このブログのスクレイピングにする。 テストダブルが必要になる場面として、テストが外部のシステムに依存しているというのはよくあるパターンだ。 スクリプトではスクレイパーに BeautifulSoup4 を使っているので別途インストールしておこう。
$ pip install beautifulsoup4
以下がサンプルコードで、ブログ投稿からタイトルと本文、投稿時間を取り出すためのクラス BlogSpider がある。 BlogSpider#get() メソッドにブログの投稿 ID を渡すと HTTP 経由で内容を取得し、HTML を解析して前述する要素を取り出して辞書で返すようになっている。 HTTP の通信部分には urllib.request を使っているが、この標準ライブラリのパッケージは Python 2 と 3 で名前が変わっているので注意が必要だ。 このパッケージは Python 2 の時代には urllib2 と呼ばれていた。 ファイルは blogspider.py という名前で保存しておこう。
#!/usr/bin/env python # -*- coding: utf-8 -*- try: # Python 3 from urllib import request except ImportError: # Python 2 import urllib2 as request from bs4 import BeautifulSoup class BlogSpider(object): def get(self, post_id): ''' ブログ記事の内容を取得するメソッド ''' # ブログから Content-Body を取得する content_body = self._request(post_id) # 取得した HTML をパースする result = self._parse(content_body) return result def _request(self, post_id): # HTTP でブログ記事の内容を取ってくる url = 'http://momijiame.tumblr.com/{post_id}'.format(post_id=post_id) response = request.urlopen(url) content_body = response.read() return content_body def _parse(self, content_body): # タイトルと本文、投稿時間を取り出す soup = BeautifulSoup(content_body) title = soup.title.text content = soup.find('div', {'class': 'body'}).text timestamp = soup.find('a', {'class': 'permalink'}).text # 結果は辞書に入れて返す return { 'title': title, 'content': content, 'timestamp': timestamp, }
まずは、テストダブルを使わずに実際に通信を発生させる形でテストコードを書いてみる。 ファイルは先ほどの blogspider.py と同じディレクトリに置く。
#!/usr/bin/env python # -*- coding: utf-8 -*- import unittest import blogspider class Test_BlogSpider(unittest.TestCase): def test(self): spider = blogspider.BlogSpider() # 実際に HTTP の通信が発生する d = spider.get(115846369516) # 結果を確認する self.assertEqual(d['timestamp'], u'4月 8, 2015 9:27 pm') if __name__ == "__main__": unittest.main()
実行結果は次の通り。 テストはパスしているが、実際に外部のシステムとの間に通信が発生するので時間がかかっている。 そして突然だけど、このブログをホストしている Tumblr がダウンしていればテストは失敗することになる。 ユニットテストという観点で考えると、こうした外部との依存はなるべく取り除くことが望ましいだろう。
. ---------------------------------------------------------------------- Ran 1 test in 4.719s OK
では次に unittest.mock で通信部分を置き換えてみよう。 まずは、BlogSpider クラスの中で通信を行っている _request() メソッドを置き換える作戦でいってみることにする。 テストダブルには MagicMock という非常に柔軟な特性を持ったクラスのインスタンスを使用する。 このクラスはどんな名前のメソッドや変数の呼び出しに対しても例外を出さない。 更に、各呼び出しは内部に記録されており、後からその内容を検証することができる。 もちろん、メソッドや変数の呼び出し時にどういった振る舞いを取るのか細かく設定することができる。 例外を出したり、どんな返り値にするかなども自由自在だ。 以下のテストでは、本来 BlogSpider#_request() メソッドが返すはずだった内容を、置き換えられた MagicMock が代わりにそれっぽいダミーの内容で返している。 BlogSpider はダミーの内容を元に後続の処理が何事もなく実行され、無事に処理を完了することになる。 そして、テスト後には MagicMock のインスタンスに記録された呼び出し内容を検証している。
#!/usr/bin/env python # -*- coding: utf-8 -*- import unittest try: # Python 3 from unittest import mock except ImportError: # Python 2 import mock import blogspider class Test_BlogSpider(unittest.TestCase): def test(self): spider = blogspider.BlogSpider() # HTTP を使った処理部分を Mock に置き換える content_body = ''' <title>Hello, World!</title> <div class="body">Dummy</div> <a class="permalink">4月 8, 2015 9:27 pm</a> ''' method_mock = mock.MagicMock(return_value=content_body) spider._request = method_mock # HTTP を使った処理部分が置き換えられているので通信は発生しない d = spider.get(115846369516) # 結果を確認する self.assertEqual(d['timestamp'], u'4月 8, 2015 9:27 pm') # 想定した引数で呼び出されたことを確認する method_mock.assert_called_with(115846369516) if __name__ == "__main__": unittest.main()
実行結果は次の通り。 外部との通信が発生しないので先ほどとは実行時間に雲泥の差がある。
. ---------------------------------------------------------------------- Ran 1 test in 0.001s OK
上記では、テスト対象の一部をテストダブルに置き換える作戦を取った。 しかし、一部とはいえテスト対象を書き換えることはあまり良い策とは言えない。 まず、書き換えた部分にバグが潜んでいる可能性がある。 更に、テストコードが実装内容にバインドするので、些細なテスト対象の修正にもテストが失敗し、修正が必要な事態に陥りやすい。 なるべくテスト対象自体には手を加えたくないものだ。 そこで、次のサンプルコードでは、標準ライブラリのパッケージにある関数自体を置き換える作戦を取ってみよう。 mock.patch() 関数をコンテキストマネージャとして使うと、そのブロックだけオブジェクトをテストダブルに置き換えることができる。 HTTP の通信を司る urllib.request.urlopen() 関数を MagicMock で置き換えてしまおう。
#!/usr/bin/env python # -*- coding: utf-8 -*- import sys import unittest try: # Python 3 from unittest import mock except ImportError: # Python 2 import mock import blogspider class Test_BlogSpider(unittest.TestCase): def test_(self): spider = blogspider.BlogSpider() # Mock に置き換えるオブジェクトの名前 urlopen_path = ( 'urllib.request.urlopen' if sys.version_info >= (3, 0, 0) else 'urllib2.urlopen' ) # 一時的にオブジェクトを Mock に置き換える with mock.patch(urlopen_path) as urlopen_mock: # urlopen() が呼び出されたときに Mock を返す request_mock = urlopen_mock.return_value # 返された Mock の read() メソッドが呼ばれたときに HTML を返すように設定する content_body = ''' <title>Hello, World!</title> <div class="body">Dummy</div> <a class="permalink">4月 8, 2015 9:27 pm</a> ''' request_mock.read.return_value = content_body # 標準ライブラリの関数が Mock に置き換わっているので通信は発生しない d = spider.get(115846369516) # 結果を確認する self.assertEqual(d['timestamp'], u'4月 8, 2015 9:27 pm') # 想定した引数で呼び出されたことを確認する request_mock.read.assert_called_with() # コンテキストマネージャを抜けた後はパッチされていたオブジェクトが元に戻る if __name__ == "__main__": unittest.main()
実行結果は次の通り。 テスト対象のコードには一切手を加えていないことには安心感がある。
. ---------------------------------------------------------------------- Ran 1 test in 0.002s OK
上記のテストコードでは mock.patch() 関数をコンテキストマネージャとして使っていたが、これだとインデントのネストが深くなる。 実はこの関数はデコレータとしても使うことができる。 デコレータを使った場合、修飾した関数またはメソッドのスコープ内でのみオブジェクトをテストダブルに置き換えることができる。 これならテストコードのインデントが深くなる心配もない。
#!/usr/bin/env python # -*- coding: utf-8 -*- import sys import unittest try: # Python 3 from unittest import mock except ImportError: # Python 2 import mock import blogspider class Test_BlogSpider(unittest.TestCase): URLOPEN_PATH = ( 'urllib.request.urlopen' if sys.version_info >= (3, 0, 0) else 'urllib2.urlopen' ) @mock.patch(URLOPEN_PATH) def test_(self, urlopen_mock): # urlopen() が呼び出されたときに Mock を返す request_mock = urlopen_mock.return_value # 返された Mock の read() メソッドが呼ばれたときに HTML を返すように設定する content_body = ''' <title>Hello, World!</title> <div class="body">Dummy</div> <a class="permalink">4月 8, 2015 9:27 pm</a> ''' request_mock.read.return_value = content_body spider = blogspider.BlogSpider() # 標準ライブラリの関数が Mock に置き換わっているので通信は発生しない d = spider.get(115846369516) # 結果を確認する self.assertEqual(d['timestamp'], u'4月 8, 2015 9:27 pm') # 想定した引数で呼び出されたことを確認する request_mock.read.assert_called_with() # テストメソッドを抜けた後はパッチされていたオブジェクトが元に戻る if __name__ == "__main__": unittest.main()
. ---------------------------------------------------------------------- Ran 1 test in 0.002s OK
テストダブルは知っていると知らないとではテストを書く難易度が大きく変わってくるので、是非とも身につけた方が良いテクニックだと思う。 めでたしめでたし。