こんにちは。leo1109です。
今回も、前回に引き続き、テストの話になります。
記事で利用したコードは、GitHub上にアップロードされています。
※対話IFのコードは除きます。
今回紹介する話
テストを書く話です。patchを利用します。
テストをするためのモジュールを作成する
前回のスクリプトを改良して、以下の機能を追加しました。
- フィボナッチ数を取得
- 連続値数列の総和を取得
- フィボナッチ数、連続地数列の総和の差分を取得
- 現在時刻(UTC)の、入力値での剰余を取った値を取得
また、フィボナッチ数の取得、総和の取得は、定義通りの実装に変更しています。
# python 3.5.2
import time
from functools import wraps
def required_natural_number(func):
@wraps(func)
def wrapper(*args, **kwargs):
k = args[0]
if isinstance(k, bool):
raise TypeError
if not isinstance(k, int) or k < 1:
raise TypeError
return func(*args, **kwargs)
return wrapper
def _f(k):
if k == 1 or k == 2:
return 1
else:
return _f(k-1) + _f(k-2)
def _sum(k):
if k == 1:
return 1
else:
return k + _sum(k-1)
@required_natural_number
def fibonacci(k):
return _f(k)
@required_natural_number
def sum_all(k):
return _sum(k)
@required_natural_number
def delta_of_sum_all_and_fibonacci(k):
x = sum_all(k) - fibonacci(k)
return x if x >= 0 else -x
@required_natural_number
def surplus_time_by(k):
return int(time.time() % k)
早速テストを書く
総和とフィボナッチ数の差を取得するsum_all_minus_fibonacci(x)
のテストを追加します。
テストケースの値は適当に選びました。
# python 3.5.2
from unittest.mock import patch
import pytest
import my_math
class TestFibonacci:
def test(self):
assert my_math.fibonacci(1) == 1
assert my_math.fibonacci(5) == 5
assert my_math.fibonacci(10) == 55
assert my_math.fibonacci(20) == 6765
assert my_math.fibonacci(30) == 832040
assert my_math.fibonacci(35) == 9227465
class TestSumAll:
def test(self):
assert my_math.sum_all(1) == 1
assert my_math.sum_all(5) == 15
assert my_math.sum_all(10) == 55
assert my_math.sum_all(20) == 210
assert my_math.sum_all(30) == 465
assert my_math.sum_all(35) == 630
class TestDeltaOfSumAllAndFibonacci:
def test(self):
assert my_math.delta_of_sum_all_and_fibonacci(1) == 1 - 1
assert my_math.delta_of_sum_all_and_fibonacci(5) == 15 - 5
assert my_math.delta_of_sum_all_and_fibonacci(10) == 55 - 55
assert my_math.delta_of_sum_all_and_fibonacci(20) == -1 * (210 - 6765)
assert my_math.delta_of_sum_all_and_fibonacci(30) == -1 * (465 - 832040)
assert my_math.delta_of_sum_all_and_fibonacci(35) == -1 * (630 - 9227465)
テストを実行してみます。
$ pytest -v my_math_2_test.py
================================================================== test session starts ==================================================================
collected 3 items
my_math_2_test.py::TestFibonacci::test PASSED
my_math_2_test.py::TestSumAll::test PASSED
my_math_2_test.py::TestDeltaOfSumAllAndFibonacci::test PASSED
=============================================================== 3 passed in 21.01 seconds ===============================================================
20秒かかりました。
フィボナッチ数のテスト、そして差分のテストに10秒ずつくらい要しているようです。
原因は、フィボナッチ数を取得するメソッドを再帰で書き換えたことにあります。
(今回の記事の目的はテストなので、計算時間についての説明は割愛しますが、再帰を使った実装は、計算時間量が非常に大きくなるケースがあります)
しかし、このテストは実装の問題以外にも、実は無駄な処理が含まれています。
その点を修正することで、テストの高速化が実現できます。
関数をパッチする
上から貼り付けるイメージです。
遅い関数をパッチする
テストが遅くなった原因は、フィボナッチ数を取得するメソッドなので、なるだけ実行しないようにしたいです。
テスト内容を見ると、fibonacci(x)
のテストと、delta_of_sum_all_and_fibonacci(x)
で、共に同じ引数でfibonacci(x)
が呼ばれていることがわかります。...これはちょっともったいないです。
fibonacci(x)
は単体でテストされているので、delta_of_sum_all_and_fibonacci(x)
をテストする際には、できれば実行したくありません。(時間がかかるからです)
今回は、patch
を利用して、fibonacci(x)
を実行しないようにしてみましょう。
既存のテストをskipした上で、fibonacci(x)
をpatch
したテストを追加しました。
class TestDeltaOfSumAllAndFibonacci:
@pytest.mark.skip
def test_slow(self):
assert my_math.delta_of_sum_all_and_fibonacci(1) == 1 - 1
assert my_math.delta_of_sum_all_and_fibonacci(5) == 15 - 5
assert my_math.delta_of_sum_all_and_fibonacci(10) == 55 - 55
assert my_math.delta_of_sum_all_and_fibonacci(20) \
== -1 * (210 - 6765)
assert my_math.delta_of_sum_all_and_fibonacci(30) \
== -1 * (465 - 832040)
assert my_math.delta_of_sum_all_and_fibonacci(35) \
== -1 * (630 - 9227465)
def test_patch(self):
with patch('my_math.fibonacci') as mock_fib:
mock_fib.return_value = 1
assert my_math.delta_of_sum_all_and_fibonacci(1) == 1 - 1
assert my_math.delta_of_sum_all_and_fibonacci(5) == 15 - 5
assert my_math.delta_of_sum_all_and_fibonacci(10) == 55 - 55
assert my_math.delta_of_sum_all_and_fibonacci(20) \
== -1 * (210 - 6765)
assert my_math.delta_of_sum_all_and_fibonacci(30) \
== -1 * (465 - 832040)
assert my_math.delta_of_sum_all_and_fibonacci(35) \
== -1 * (630 - 9227465)
patchはwith文やデコレータを利用して、指定したモジュールのメソッドの実行内容を書き換えます。
動作確認として、fibonacci(x)
は常に1を返すようにしています。
実行してみましょう。
$ pytest -v my_math_2_test.py::TestDeltaOfSumAllAndFibonacci
================================================================== test session starts ==================================================================
collected 2 items
my_math_2_test.py::TestDeltaOfSumAllAndFibonacci::test_slow SKIPPED
my_math_2_test.py::TestDeltaOfSumAllAndFibonacci::test_patch FAILED
======================================================================= FAILURES ========================================================================
_______________________________________________________ TestDeltaOfSumAllAndFibonacci.test_patch ________________________________________________________
self = <my_math_2_test.TestDeltaOfSumAllAndFibonacci object at 0x103abcf28>
def test_patch(self):
with patch('my_math.fibonacci') as mock_fib:
mock_fib.return_value = 1
assert my_math.delta_of_sum_all_and_fibonacci(1) == 1 - 1
> assert my_math.delta_of_sum_all_and_fibonacci(5) == 15 - 5
E assert 14 == (15 - 5)
E + where 14 = <function delta_of_sum_all_and_fibonacci at 0x103ac0840>(5)
E + where <function delta_of_sum_all_and_fibonacci at 0x103ac0840> = my_math.delta_of_sum_all_and_fibonacci
my_math_2_test.py:45: AssertionError
========================================================== 1 failed, 1 skipped in 0.21 seconds ==========================================================
テストが失敗するようになりました。
delta_of_sum_all_and_fibonacci(5)
のテストケースで、本来5を取得するはずのfibonacci(5)
が、1を返すようになったため、14 == (15 - 5)
としてテストが失敗しています。
想定通りにパッチできていることが確認できたため、正しい値を返すようにテストを改良します。
def test_patch(self):
with patch('my_math.fibonacci') as mock_fib:
mock_fib.return_value = 1
assert my_math.delta_of_sum_all_and_fibonacci(1) == 1 - 1
mock_fib.return_value = 5
assert my_math.delta_of_sum_all_and_fibonacci(5) == 15 - 5
mock_fib.return_value = 55
assert my_math.delta_of_sum_all_and_fibonacci(10) == 55 - 55
mock_fib.return_value = 6765
assert my_math.delta_of_sum_all_and_fibonacci(20) \
== -1 * (210 - 6765)
mock_fib.return_value = 832040
assert my_math.delta_of_sum_all_and_fibonacci(30) \
== -1 * (465 - 832040)
mock_fib.return_value = 9227465
assert my_math.delta_of_sum_all_and_fibonacci(35) \
== -1 * (630 - 9227465)
再度実行します。
$ pytest -v my_math_2_test.py::TestDeltaOfSumAllAndFibonacci
================================================================== test session starts ==================================================================
collected 2 items
my_math_2_test.py::TestDeltaOfSumAllAndFibonacci::test_slow SKIPPED
my_math_2_test.py::TestDeltaOfSumAllAndFibonacci::test_patch PASSED
========================================================== 1 passed, 1 skipped in 0.05 seconds ==========================================================
テストが成功しました。
実行時間は0.05(sec)と、先程に比べるとかなり速くなりました。
もちろん全実行した場合は最初のfibonacci(x)
の実行分だけ時間がかかってしまいますが、それでも20秒->10秒と、50%削減することができました。
戻り値が定まらない関数をパッチする
前項では遅い関数をモック化することで。テストを効率化することに成功しました。
さて、surplus_time_by(k)
は、現在時刻(UTC)のunixtimeを取得して、その余りを返す関数ですが、現在時刻は実行するたびに変化するため、先程のように決め打ちの値を書いたテストができません。
例えば、以下のテストは、time.time()
の値が5の倍数の場合には成功しますが、それ以外の場合は成功しません。
assert my_math.surplus_time_by(5) == 5
こういうケースの場合、time.time()
をパッチすることを検討します。
timeは、Pythonの標準ライブラリですので、すでに十分にテストされていると仮定すると、テストすべきは、剰余の計算の部分になるためです。
さて、my_math
で利用されているtime.time()
をパッチするには、以下のように書きます。
class TestSurplusTimeBy:
def test(self):
with patch('my_math.time') as mock_time:
mock_time.time.return_value = 1000
assert my_math.surplus_time_by(3) == 1
assert my_math.surplus_time_by(5) == 0
mock_time.time.return_value = 1001
assert my_math.surplus_time_by(3) == 2
assert my_math.surplus_time_by(5) == 1
以下のように、time.time
までをpatch
することも可能です。
def test2(self):
with patch('my_math.time.time') as mock_time:
mock_time.return_value = 1000
assert my_math.surplus_time_by(3) == 1
assert my_math.surplus_time_by(5) == 0
mock_time.return_value = 1001
assert my_math.surplus_time_by(3) == 2
assert my_math.surplus_time_by(5) == 1
上記の違いは、どこまでmock化するかという点にあります。
以下の実行結果を見ると、timeをパッチした場合、timeモジュール自体がモックオブジェクトに書き換わっていることがわかります。
そのため、例えばtime
以下のtime()
以外を利用している場合は、time.time
と明示的に指定するか、利用しているメソッドをすべてパッチする必要があります。
# time.timeをパッチ
>>> with patch('my_math.time.time') as m:
... print(my_math.time)
... print(my_math.time.time)
...
<module 'time' (built-in)>
<MagicMock name='time' id='4329989904'>
# timeをパッチ
>>> with patch('my_math.time') as m:
... print(my_math.time)
... print(my_math.time.time)
...
<MagicMock name='time' id='4330034680'>
<MagicMock name='time.time' id='4330019136'>
patchに指定する文字列について
今回の例では、モジュールtime
がimportされているのはmy_math
ですので、my_math.time
と書きます。
そのため、my_math
を利用する別のモジュールmy_module
が現れた場合は、my_module.my_math.time
と書く必要があります。
クラスのインスタンスメソッドをパッチする場合は、インスタンスまで指定する必要があります。
# Python 3.5.2
class MyClass:
def __init__(self, prefix='My'):
self._prefix = prefix
def my_method(self, x):
return '{} {}'.format(self._prefix, x)
# Python 3.5.2
from my_class import MyClass
def main(name):
c = MyClass()
return c.my_method(name)
if __name__ == '__main__':
print(main('Python'))
上記を実行すると、My Python
と表示されます。
ここで、MyClass.my_method
をパッチする場合は、以下のように書けます。
# python 3.5.2
from unittest.mock import patch
import my_main
class TestFunction:
def test(self):
assert my_main.function('Python') == 'My Python'
def test_patch_return_value(self):
with patch('my_class.MyClass.my_method') as mock:
mock.return_value = 'Hi! Perl'
assert my_main.function('Python') == 'Hi! Perl'
def test_patch_side_effect(self):
with patch('my_class.MyClass.my_method') as mock:
mock.side_effect = lambda x: 'OLA! {}'.format(x)
assert my_main.function('Python') == 'OLA! Python'
外部アクセスするメソッドをパッチする
外部アクセスするモジュールをテストする時も、パッチが便利です。
requests
モジュールを利用して、指定したURLをGETした時のステータスコードを返すメソッドを作成しました。
これをテストすることを考えます。
# Python 3.5.2
import requests
def get_status_code(url):
r = requests.get(url)
return r.status_code
テストを書いてみます。
# python 3.5.2
import pytest
import my_http
class TestGetStatusCode:
@pytest.fixture
def url(self):
return 'http://example.com'
def test(self, url):
assert my_http.get_status_code(url) == 200
このテストは問題なくパスします。
しかし、オフラインのときはどうでしょう?
詳細なスタックトレースは割愛しますが、テストは失敗します。
...
> raise ConnectionError(e, request=request)
E requests.exceptions.ConnectionError: HTTPConnectionPool(host='example.com', port=80): Max retries exceeded with url: / (Caused by NewConnectionError('<urllib3.connection.HTTPConnection object at 0x1039a4cf8>: Failed to establish a new connection: [Errno 8] nodename nor servname provided, or not known',))
../../../.pyenv/versions/3.5.2/lib/python3.5/site-packages/requests/adapters.py:504: ConnectionError =============================================================== 1 failed in 1.06 seconds ================================================================
このテストを、オフラインでも通るようにするには、request.getをパッチします。
戻り値はオブジェクトですので、return_valueにMagicMock()
を明示的に指定してあげる必要があります。
# python 3.5.2
from unittest.mock import MagicMock, patch
import pytest
import my_http
class TestGetStatusCode:
@pytest.fixture
def url(self):
return 'http://example.com'
@pytest.mark.skip
def test_online(self, url):
assert my_http.get_status_code(url) == 200
def test_offline(self, url):
with patch('my_http.requests') as mock_requests:
mock_response = MagicMock(status_code=200)
mock_requests.get.return_value = mock_response
assert my_http.get_status_code(url) == 200
上記のテストはオフラインでもパスします。
MagicMockのstatus_codeを400, 500などを指定すれば、URLは同じにしつつ、エラーケースのテストも追加できます。
データベースがない環境でのDBアクセスや、外部サービスからのトークン取得等をモック化する際に活用できるでしょう。
パッチTips
パッチするときのコツです。
複数のメソッドをパッチしたい場合
Python3系では、ExitStack()
が便利です。
>>> with ExitStack() as stack:
... x = stack.enter_context(patch('my_math.fibonacci'))
... y = stack.enter_context(patch('my_math.sum_all'))
... x.return_value = 100
... y.return_value = 200
... z = my_math.delta_of_sum_all_and_fibonacci(99999)
... print(z)
...
100
両方の関数の戻り値が書き換えられているため、my_math.delta_of_sum_all_and_fibonacci(99999)
は、200と100の差分を返します。
例外を返したい
モックを利用していると、例外を返したくなるケースが出てくることがあります。
その場合は、mock.side_effect
を利用します。
>>> with patch('my_math.fibonacci') as m:
... m.side_effect = ValueError
... my_math.fibonacci(1)
...
Traceback (most recent call last):
File "<stdin>", line 3, in <module>
File "/Users/sho/.pyenv/versions/3.5.2/lib/python3.5/unittest/mock.py", line 917, in __call__
return _mock_self._mock_call(*args, **kwargs)
File "/Users/sho/.pyenv/versions/3.5.2/lib/python3.5/unittest/mock.py", line 973, in _mock_call
raise effect
ValueError
モックが実行されたかどうかを確認する
mockオブジェクトにcallオブジェクトが格納されるので、それを確認する方法が便利です。
assert_
で始まるメソッドは、条件を満たさない場合には例外が上がるため、assert
を書く必要はありません。
mock.call_args
にモックが実行された際の引数がタプルで格納されています。
詳細は、やはり公式ドキュメントを参照してください。
代表的なもののみ紹介します。
-
mock.called
実行されたかどうか(bool) -
mock.call_count
実行回数 -
mock.assert_called_with()
引数を伴って実行されたかどうか
def test_called(self):
with patch('my_class.MyClass.my_method') as mock:
my_main.function('Python')
assert mock.called
assert mock.call_count == 1
mock.assert_called_with('Python')
次回は
お盆休み明けまで未定です!