この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
サーモン大好き、横山です。
requestsの拡張クラスを作っていて、実際に動かした際のMockResponseを作成するのが難しいなと思ったことは無いですか?私はあります。 今回はそういうときのためのHTTP Client Mockツール、HTTPrettyを紹介します。
前提の環境
今回は仮想環境に下記のコマンドを叩いてパッケージをインストールした環境です。
$ mkdir -p /path/to/httpretty
$ cd /path/to/httpretty/
$ python3 -mvenv venv
$ . venv/bin/activate
(venv)$ pip install requests pytest pytest-cov httpretty
ファイル準備
今回は実装とテストファイル、テストを実行するbashファイルを用意します。
$ tree -I '__pycache__|venv|htmlcov'
.
├── main.py
├── run_test.bash
└── tests
├── __init__.py
└── test_main.py
main.py
import requests
TOKEN="salmon_daisuki"
class MySession(requests.Session):
def __init__(self):
super().__init__()
self.auth = self.auth_hook
def auth_hook(self, request):
request.headers["Authorization"] = f"Bearer {TOKEN}"
def get_players(session: MySession):
return session.get("http://someone.api.com/players")
def main():
session = MySession()
resp = get_players(session)
# .. respを使ってゴニョゴニョ処理をする
if __name__ == "__main__":
main()
run_test.bash
#!/bin/bash
pytest -s ./tests --cov=. --cov-report=html
tests/__init__.py
# 空ファイル
tests/test_main.py
class TestMain:
def test_any(self):
pass
ここまで準備したら一度テストを動かしてみます。
テスト実行
$ bash run_test.bash
============================= test session starts ==============================
platform darwin -- Python 3.9.6, pytest-6.2.5, py-1.10.0, pluggy-1.0.0
rootdir: /path/to/httpretty
plugins: cov-3.0.0
collected 1 item
tests/test_main.py .
---------- coverage: platform darwin, python 3.9.6-final-0 -----------
Coverage HTML written to dir htmlcov
============================== 1 passed in 0.07s ===============================
テストを実行すると、カバレッジ結果をHTMLで見ることが出来ます。HTMLファイルは、main.py
のカバレッジを確認したい場合は、 htmlcov/main_py.html
を開くと見ることができ、他のファイルのカバレッジを確認したい場合は htmlcov/index.html
を開くとファイルの一覧を見ることが出来ます。
以下の画像は、main.pyのカバレッジでテストを全く書いてないので、カバレッジが真っ赤ですね。こちらは想定した結果です。
テストの目的
今回はmain.pyに MySession
クラスにある auth_hook
が session.get
を行ったときに動作するかをテストで確認したいです。
しかし、実際に http://someone.api.com/players
へアクセスしての動作確認はしたくないです。なので、main.pyに設定したURLへはテスト時はアクセスせずに動作を確認したいです。
session.get
をmockして確認した場合
テスト時はアクセスさせたくないので、単純に get_players
の引数のsession変数をmockに差し替えてテストしてみます。
tests/test_main.py
from unittest.mock import Mock
from main import get_players
class TestMain:
def test_get_players_with_mock(self):
mock_session = Mock()
get_players(mock_session)
こちらのテストを実行したカバレッジがこちら
MySession
のインスタンスを作ったわけでは無いので、 MySession
内の関数は実行されていないことがわかります。get_players
の中身だけ通った感じです。
とはいえ、MySessionのインスタンスを作り、requestsの内部を潜り、関数をmockするのはとても手間がかかると思います。
そこで、HTTPretty を使います。
HTTPrettyとは?
HTTPrettyは、socketとsslモジュールを、HTTPリクエストをTCPコネクションのレベルでmockしてくれるpythonライブラリです。
HTTPretty is a python library that swaps the modules socket and ssl with fake implementations that intercept HTTP requests at the level of a TCP connection.
引用元: https://httpretty.readthedocs.io/en/latest/introduction.html#a-more-technical-description
テストでTCPレベルでmockしたい、httpretty.activate のデコレータをつけます。つけたテストに、 httpretty.register_uri でmockしたいMethod、URL、ResponseBodyを定義します。
tests/test_main.py
from main import TOKEN, MySession, get_players
import httpretty
import json
PLAYERS_DICT=dict(players=[{"name":"clameso"}])
PLAYERS_RESPONSE = json.dumps(PLAYERS_DICT)
class TestMain:
@httpretty.activate
def test_get_players_with_httpretty(self):
# setup
session = MySession()
httpretty.register_uri(
"GET",
"http://someone.api.com/players",
body=PLAYERS_RESPONSE
)
except_header = f"Bearer {TOKEN}"
# exercise
actual = get_players(session)
# verify
assert actual.json() == PLAYERS_DICT
assert actual.request.headers["Authorization"] == except_header
こちらにテストを書き換えてテストを実行して見ると…テストが落ちます。
$ bash run_test.bash
============================================ test session starts =============================================
platform darwin -- Python 3.9.6, pytest-6.2.5, py-1.10.0, pluggy-1.0.0
rootdir: /path/to/httpretty
plugins: cov-3.0.0
collected 1 item
tests/test_main.py F
================================================== FAILURES ==================================================
__________________________________ TestMain.test_get_players_with_httpretty __________________________________
self = <tests.test_main.TestMain object at 0x102e3d850>
@httpretty.activate
def test_get_players_with_httpretty(self):
# setup
session = MySession()
httpretty.register_uri(
"GET",
"http://someone.api.com/players",
body=PLAYERS_RESPONSE
)
except_header = f"Bearer {TOKEN}"
# exercise
> actual = get_players(session)
tests/test_main.py:24:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
main.py:16: in get_players
return session.get("http://someone.api.com/players")
venv/lib/python3.9/site-packages/requests/sessions.py:555: in get
return self.request('GET', url, **kwargs)
venv/lib/python3.9/site-packages/requests/sessions.py:528: in request
prep = self.prepare_request(req)
venv/lib/python3.9/site-packages/requests/sessions.py:456: in prepare_request
p.prepare(
venv/lib/python3.9/site-packages/requests/models.py:320: in prepare
self.prepare_auth(auth, url)
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
self = <PreparedRequest [GET]>
auth = <bound method MySession.auth_hook of <main.MySession object at 0x102e3dd00>>
url = 'http://someone.api.com/players'
def prepare_auth(self, auth, url=''):
"""Prepares the given HTTP auth data."""
# If no Auth is explicitly provided, extract it from the URL first.
if auth is None:
url_auth = get_auth_from_url(self.url)
auth = url_auth if any(url_auth) else None
if auth:
if isinstance(auth, tuple) and len(auth) == 2:
# special-case basic HTTP auth
auth = HTTPBasicAuth(*auth)
# Allow auth to make its changes.
r = auth(self)
# Update self to reflect the auth changes.
> self.__dict__.update(r.__dict__)
E AttributeError: 'NoneType' object has no attribute '__dict__'
venv/lib/python3.9/site-packages/requests/models.py:559: AttributeError
---------- coverage: platform darwin, python 3.9.6-final-0 -----------
Coverage HTML written to dir htmlcov
========================================== short test summary info ===========================================
FAILED tests/test_main.py::TestMain::test_get_players_with_httpretty - AttributeError: 'NoneType' object ha...
============================================= 1 failed in 0.31s ==============================================
エラーとなってる r.__dict__
の r
がNoneTypeで dict なんてないよーと言われているので、main.py側の auth_hook
でrequestを返して無いのが原因です。
ですので、実装側を修正します、diffはこんな感じになります。
diff --git a/main.py b/main.py
index 982ee87..ab6485d 100644
--- a/main.py
+++ b/main.py
@@ -10,6 +10,7 @@ class MySession(requests.Session):
def auth_hook(self, request):
request.headers["Authorization"] = f"Bearer {TOKEN}"
+ return request
改めてテストを流しますと、テストが成功します。
$ bash run_test.bash
===================================== test session starts =====================================
platform darwin -- Python 3.9.6, pytest-6.2.5, py-1.10.0, pluggy-1.0.0
rootdir: /path/to/httpretty
plugins: cov-3.0.0
collected 1 item
tests/test_main.py .
---------- coverage: platform darwin, python 3.9.6-final-0 -----------
Coverage HTML written to dir htmlcov
====================================== 1 passed in 0.24s ======================================
カバレッジはこちら。やりたかった auth_hook
が session.get
を行ったときに動作するかをテストが出来ました。
まとめ
外部サービスを利用したコードをテストをするときは、外部にアクセスさせずに標準のMockを用いたテストで意図したことを確認しづらい場合が出てきます。 でも、単体テストに外部サービス、今回はHTTPサーバーにつないでテストを書きたくない……そうしたときにHTTPrettyみたいな、HTTP Client Mockツール使ってみるのはいかがでしょうか。
この記事がだれかのお役に立てば幸いです。