HTTP Client MockツールのHTTPrettyを使って、requestsの処理を拡張したクラスのテストを書こう

2021.10.05

サーモン大好き、横山です。

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_hooksession.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_hooksession.get を行ったときに動作するかをテストが出来ました。


まとめ

外部サービスを利用したコードをテストをするときは、外部にアクセスさせずに標準のMockを用いたテストで意図したことを確認しづらい場合が出てきます。 でも、単体テストに外部サービス、今回はHTTPサーバーにつないでテストを書きたくない……そうしたときにHTTPrettyみたいな、HTTP Client Mockツール使ってみるのはいかがでしょうか。

この記事がだれかのお役に立てば幸いです。