ユニットテストは大変なものですが、これらの Python モジュールを使えば楽になります。
このチュートリアルでは、HTTPリクエストを実行するコードをユニットテストする方法を学びます。言い換えると、Python で API をユニットテストする技術を見ることになります。
単体テストは、単一の振る舞いのテストです。テストの経験則としてよく知られているのは、外部依存を必要とするコードを分離することです。
たとえば、HTTP リクエストを実行するコードをテストする場合、テスト中に本物の HTTP リクエストを偽の HTTP リクエストに置き換えることを推奨します。この場合、テストを実行するたびに、実際の HTTP リクエストを実行せずにユニットテストを行うことができます。
問題は
それが、このブログ記事でお答えしたい質問です!このブログでは、その質問にお答えしたいと思います。
- Python 3.8
- パイテストモック
- requests
- flask
- responses
気象条件REST APIを使用したデモアプリケーション
この問題をよりよく解決するために、気象条件アプリケーションを作成しているとしましょう。このアプリケーションは、サードパーティの気象条件REST APIを使用して、ある都市の気象情報を取得します。要件の1つは、下のイメージのようなシンプルなHTMLページを生成することです:
天気に関する情報を得るためには、どこかに探しに行かなければなりません。幸いなことに、 OpenWeatherMap REST APIサービスを使えば、必要なものはすべて手に入ります。
にGETリクエストを送れば、必要なものをすべて手に入れることができますhttps://..//./?q={_me}&;id={_ey}&;=icこのチュートリアルでは、都市名をパラメータとして設定し、メートル単位を使用するようにします。
データの取得
気象データを取得するには、requests モジュールを使用します。都市名をパラメータとして受け取り、JSON を返す関数を作成できます。 JSON には、気温、天候の説明、日の出と日の入りの時刻などのデータが含まれます。
次の例はそのような関数を示しています:
def find_weather_for(city: str) -> dict:"""Queries the weather API and returns the weather data for a particular city."""url = API.format(city_name=city, api_key=API_KEY)resp = requests.get(url)return resp.json()
このURLは2つのグローバル変数で構成されています:
BASE_URL = "https://..//./er"API = BASE_URL + '?q={city_name}&appid={api_key}&units=metric';
APIはこの形式のJSONを返します:
"coord": {"lon": -0.13,"lat": 51.51},"weather": [{"id": 800,"main": "Clear","description": "clear sky","icon": "01d"}],"base": "stations","main": {"temp": 16.53,"feels_like": 15.52,"temp_min": 15,"temp_max": 17.78,"pressure": 1023,"humidity": 72},"visibility": 10000,"wind": {"speed": 2.1,"deg": 40},"clouds": {"all": 0},"dt": ,"sys": {"type": 1,"id": 1414,"country": "GB","sunrise": ,"sunset":},"timezone": 3600,"id": ,"name": "London","cod": 002
resp.json() が呼び出されると、データは Python 辞書として返されます。すべての詳細をカプセル化するために、データクラスとして表現することができます。このクラスには、辞書を受け取り、WeatherInfo インスタンスを返すファクトリーメソッドがあります。
この方法は、この表現を安定に保つことができるため、優れています。たとえば、APIがJSONの構造を変更した場合、ロジックは同じ場所で変更できます。残りのコードは影響を受けません。また、さまざまなソースから情報を取得し、それらをすべてfrom_dictメソッドに統合することもできます。
@dataclassclass WeatherInfo:temp: floatsunset: strsunrise: strtemp_min: floattemp_max: floatdesc: str@classmethoddef from_dict(cls, data: dict) -> "WeatherInfo":return cls(temp=data["main"]["temp"],temp_min=data["main"]["temp_min"],temp_max=data["main"]["temp_max"],desc=data["weather"][0]["main"],sunset=format_date(data["sys"]["sunset"]),sunrise=format_date(data["sys"]["sunrise"]),)
次に retrieve_weather という関数を作成します。この関数を使用して API を呼び出し、WeatherInfo を返します。
def retrieve_weather(city: str) -> WeatherInfo:"""Finds the weather for a city and returns a WeatherInfo instance."""data = find_weather_for(city)return WeatherInfo.from_dict(data)
素晴らしい、アプリの基礎ができました。次に進む前に、これらの関数をユニットテストしましょう。
APIはモックを使ってテストされます。
ウィキペディアによると、モック(mock)とは、実際のオブジェクトの振る舞いを模倣してシミュレートするオブジェクトのことです。Python では、標準ライブラリの unittest.mock ライブラリを使えば、どんなオブジェクトでもモックできます。retrieve_weather関数をテストするには、requests.getをモックして静的データを返します。
パイテストモック
このチュートリアルでは、テストフレームワークとして pytest を使用します。pytestライブラリはプラグインによって非常に拡張可能です。このプラグインはunittest.mockの多くの設定を抽象化し、コードをより簡潔にします。もし興味があれば、 別のブログ記事で 詳しく説明します。
ここにretrieve_weather関数の完全なテストケースがあります。このテストは2つのフィクスチャを使います: pytest-mockプラグインによって提供されるモッカーフィクスチャと独自のフィクスチャです。これはリクエストから保存された静的データです。
@pytest.fixture()def fake_weather_info():"""Fixture that returns a static weather data."""with open("tests/resources/weather.json") as f:return json.load(f)
def test_retrieve_weather_using_mocks(mocker, fake_weather_info):"""Given a city name, test that a HTML report about the weather is generatedcorrectly."""# Creates a fake requests response objectfake_resp = mocker.Mock()# Mock the json method to return the static weather datafake_resp.json = mocker.Mock(return_value=fake_weather_info)# Mock the status codefake_resp.status_code = HTTPS.OKmocker.patch("weather_app.requests.get", return_value=fake_resp)weather_info = retrieve_weather(city="London")assert weather_info == WeatherInfo.from_dict(fake_weather_info)
このテストを実行すると、次のような出力が得られます:
============================= test session starts ==============================...[omitted]...tests/test_weather_app.py::test_retrieve_weather_using_mocks PASSED [100%]============================== 1 passed in 0.20s ===============================Process finished with exit code 0
テストは合格!でも人生は順風満帆ではありません。このテストには賛否両論あります。では、ご覧ください。
長所
さて、議論されている利点の1つは、APIの戻り値をシミュレートすることでテストが容易になることです。APIから通信を分離することで、テストが予測可能になります。これは常に必要なものを返します。
短所
欠点としては、requestsをもう使いたくないので標準ライブラリのurllibに戻そうと思ったときにどうするかが問題です。find_weather_forのコードを変更するたびに、適応テストに行かなければなりません。良いテストとは、コードの実装を変更しても変更する必要がないものです。ですから、モッキングを使うと、テストと実装を結合してしまうことになります。
そして、もう一つの悪い点は、呼び出し関数で多くのセットアップを行う必要があることです。
# Creates a fake requests response objectfake_resp = mocker.Mock()# Mock the json method to return the static weather datafake_resp.json = mocker.Mock(return_value=fake_weather_info)# Mock the status codefake_resp.status_code = HTTPS.OK
はい。これから少し改良してみます。
回答の利用
モッカー関数でリクエストをシミュレートする場合の問題は、設定がたくさんあるということです。この問題を回避する良い方法は、リクエストの呼び出しをインターセプトして教えてくれるライブラリを使うことです。これができるライブラリは複数ありますが、私にとって一番簡単なのは responses です。
@responses.activatedef test_retrieve_weather_using_responses(fake_weather_info):"""Given a city name, test that a HTML report about the weather is generatedcorrectly."""api_uri = API.format(city_name="London", api_key=API_KEY)responses.add(responses.GET, api_uri, json=fake_weather_info, status=HTTPS.OK)weather_info = retrieve_weather(city="London")assert weather_info == WeatherInfo.from_dict(fake_weather_info)
この関数もまた ============================= test session starts ============================== フィクスチャーを使います。
それからテストを実行してください:
============================= test session starts ==============================tests/test_weather_app.py::test_retrieve_weather_using_responses PASSED [100%]============================== 1 passed in 0.19s ===============================
とても良い!テストも合格。でも...それほど素晴らしいものではありません。
長所
responseのようなライブラリを使うことのいいところは、 リクエストを与える必要がないことです。この抽象化レイヤーをライブラリに渡すことで、セットアップの一部を削減することができます。しかし、気づいていないかもしれませんが、まだいくつかの問題があります。
短所
unittest.mock 同じように、テストと実装は再び結合します。リクエストを置き換えると、テストは動作しません。
APIはアダプターを使ってテストされます。
次のようなシナリオを想像してみてください: リクエストが使えなくなり、Python に付属している urllib で置き換えなければならなくなったとしましょう。それだけでなく、あなたはテストコードと実装を一緒にしないことを学びました。あなたは urllib を置き換えたいのであって、テストを書き換えたいわけではありません。
GETリクエストを実行するコードを抽象化できることがわかりました。
を使って抽象化することができます。アダプタは、他のクラスのインターフェイスをカプセル化し、新しいインターフェイスとして公開するためのデザインパターンです。こうすることで、コードを修正することなくアダプタを変更することができます。たとえば find_weather_for 関数では、リクエストの詳細をすべてカプセル化し、 その部分を URL だけを受け付ける関数に公開します。
それで、これです:
def find_weather_for(city: str) -> dict:"""Queries the weather API and returns the weather data for a particular city."""url = API.format(city_name=city, api_key=API_KEY)resp = requests.get(url)return resp.json()
こうなりました:
def find_weather_for(city: str) -> dict:"""Queries the weather API and returns the weather data for a particular city."""url = API.format(city_name=city, api_key=API_KEY)return adapter(url)
そうすると、アダプターはこうなります:
def requests_adapter(url: str) -> dict:resp = requests.get(url)return resp.json()
今度は BASE_URL = "https://..//./er" 関数をリファクタリングしましょう:
def retrieve_weather(city: str) -> WeatherInfo:"""Finds the weather for a city and returns a WeatherInfo instance."""data = find_weather_for(city, adapter=requests_adapter)return WeatherInfo.from_dict(data)
そのため、代わりにurllib実装を使うことにした場合は、アダプタを変更するだけです:
def urllib_adapter(url: str) -> dict:"""An adapter that encapsulates urllib.urlopen"""with urllib.request.urlopen(url) as response:resp = response.read()return json.loads(resp)
def retrieve_weather(city: str) -> WeatherInfo:"""Finds the weather for a city and returns a WeatherInfo instance."""data = find_weather_for(city, adapter=urllib_adapter)return WeatherInfo.from_dict(data)
@pytest.fixture()テストするには、テスト中に使用するダミーのアダプターを作成するだけです:
@responses.activatedef test_retrieve_weather_using_adapter(fake_weather_info,def fake_adapter(url: str):return fake_weather_infoweather_info = retrieve_weather(city="London", adapter=fake_adapter)assert weather_info == WeatherInfo.from_dict(fake_weather_info)
テストを実行すると、こうなります:
============================= test session starts ==============================tests/test_weather_app.py::test_retrieve_weather_using_adapter PASSED [100%]============================== 1 passed in 0.22s ===============================
長所
この方法の利点は、テストと実装をうまく切り離せることです。テスト中に偽のアダプタを注入します。また、実行時などいつでもアダプタを変更することができます。いずれも、挙動を変更するものではありません。
短所
欠点は、テストで偽のアダプタを使用しているために、 実装でアダプタにバグを取り込んでもテストがそれを捕捉できないということです。たとえば、リクエストにおかしなパラメータを渡すような場合です:
def requests_adapter(url: str) -> dict:resp = requests.get(url, headers=<some broken headers>)return resp.json()
実運用環境では、アダプターには問題がある可能性があり、ユニットテストではそれを見つける方法がありません。しかし実際には、メソッドにも同じ問題があります。ユニットテストだけでなく、常に統合することが重要なのはそのためです。言い換えれば、別の選択肢を考えてください。
APIはVCR.pyを使ってテストされています。
さて、いよいよ最後の選択肢についてです。正直なところ、私はこれを最近見つけたばかりで、長い間使ってきましたが、いつもいくつかの問題を抱えていました。vcr.pyは多くのHTTPリクエストのテストを簡単にするライブラリです。
リクエストとレスポンスの両方がシリアライズされます。テストが2回目に実行されるとき、VCT.pyはリクエストへの呼び出しをインターセプトし、レスポンスを返します。
VCR.py を使って BASE_URL = "https://..//./er"テストする方法を以下に示します:
@vcr.use_cassette()def test_retrieve_weather_using_vcr(fake_weather_info):weather_info = retrieve_weather(city="London")assert weather_info == WeatherInfo.from_dict(fake_weather_info)
はい、これだけです!VCRに呼び出しを傍受し、カセットファイルを保存するよう指示するpytestアノテーションがあるだけです。
いい質問ですね。このファイルにはたくさんのものがあります。それは、VCRがインタラクションの詳細をすべて保存しているからです。
interactions:- request:body: nullheaders:Accept:- '*/*'Accept-Encoding:- gzip, deflateConnection:- keep-aliveUser-Agent:- python-requests/2.24.0method: GETuri: https://..//./?=&;id=&; API KEY HERE>&units=metricresponse:body:string: '{"coord":{"lon":-0.13,"lat":51.51},"weather":[{"id":800,"main":"Clear","description":"clearsky","icon":"01d"}],"base":"stations","main":{"temp":16.53,"feels_like":15.52,"temp_min":15,"temp_max":17.78,"pressure":1023,"humidity":72},"visibility":10000,"wind":{"speed":2.1,"deg":40},"clouds":{"all":0},"dt":,"sys":{"type":1,"id":1414,"country":"GB","sunrise":,"sunset":},"timezone":3600,"id":,"name":"London","cod":200}'headers:Access-Control-Allow-Credentials:- 'true'Access-Control-Allow-Methods:- GET, POSTAccess-Control-Allow-Origin:- '*'Connection:- keep-aliveContent-Length:- '454'Content-Type:- application/json; charset=utf-8Date:- Fri, 18 Sep :25 GMTServer:- openrestyX-Cache-Key:- /data/2.5/weather?q=london&units=metricstatus:code: 002message: OKversion: 1
vcr.pyが全部やってくれます!vcr.pyがあなたのために全てやってくれます。
長所
長所を挙げるとすれば、少なくとも5つ:
- 決まったコードはありません。
- テストはまだ別々なので、すぐに終わります。
- テストはOKです。
- リクエストを変更すると、例えば間違ったヘッダを付けると、テストは失敗します。
- コード実装との結合がないので、アダプタを変更してもテストは通ります。重要なのは、リクエストが同じでなければならないということだけです。
短所
シミュレーションと比較すると、エラーを避けることとは別に、まだいくつかの問題があります。
APIプロバイダが何らかの理由でデータフォーマットを変更したとしても、テストはパスします。幸いなことに、このようなことはあまり起こりませんし、APIプロバイダーは通常、このような大きな変更に対して異なるバージョンのAPIを提供します。
もう一つ考慮すべきことはテストです。これらのテストは、サーバーが実行されるたびに呼び出されます。その名前が示すように、これはより広範で遅いテストです。ユニットテストよりも広範囲に及びます。実際、すべてのプロジェクトがこれらを使う必要はありません。ですから、私の知る限り、VCR.pyはほとんどの人のニーズに対して十分すぎるほどです。
要約すると
以上です。今日は何か役に立つことを学んでいただけたでしょうか?APIクライアント・アプリケーションのテストは、少々気後れするものです。しかし、正しいツールと知識で武装すれば、野獣を手なずけることができます。
この申請書一式は.





