ちょっと使い方を調べた内容です。
pytest-mockの仕様まで踏み込んでいません。
公式のGithubのREADME見ただけでもっと機能があるからちゃんと調べないといけないと思ってます…。
Contents
pytestとは
Pythonでテストコードを書く際に利用するテストモジュールです。
組み込みのunittestもありますが、テストに失敗したときの結果の見やすさが人気です。
詳しい話は多くの方が記事を書かれているので、探してみてください。
本記事では、私がpytestでmockの使い方を調べていた時に2通りの書き方を見つけたので、それぞれどのように動くのかを調べました。
pytest-mock
今回使用する単体テストモジュールです。
https://github.com/pytest-dev/pytest-mock/
pip install pytest-mock
でインストール可能です。本記事の内容ではimport文は不要です。
今回のmockの想定利用シーン
想定するメソッドと実施したいテストは以下の条件とします。
- 指定されたURLにHTTPリクエスト(今回はGET)をしてコンテンツを呼び出し元に返すメソッド
- このメソッドはstatus codeに応じて例外を発生させる
- status codeが200以外の場合(404や500)のテストを行いたい
- 実際にHTTP通信をしなくてもテストを行いたいケース
「正常系(200)は問題ないけど、異常系(404,500など)のテストをしたい。」です。
実施環境
OS: Windows10 Home 64bit
WSL2 Ubuntu20.04
Python: 3.6.8 (pyenv, pipenv使用)
pytest-6.1.1
mock-3.3.1
色々省いていますが、ディレクトリ構成。
$ tree
.
├ ─ ─ src
│ ├ ─ ─ OreoreRequests.py
└ ─ ─ test
├ ─ ─ test_OreoreRequests.py
リクエスト先として、リクエストされたことがすぐにわかるようにWebサーバを立ち上げておきます。以下で8000番で立ち上がります。
python -m http.server
テスト対象コード
import requests
class OreoreRequests:
def __init__(self, url):
self.url = url
def get(self):
print("DEBUGDEBUGDEBUG")
res = requests.get(self.url)
if res.status_code != 200:
raise Exception("status code is not 200.")
result = {
"statuscode": res.status_code,
"text": res.text
}
return result
簡単のためにメソッドの中に入ったかどうかを確認するためのprint文を入れています。後でmockを使ったときにメソッドに入ったかどうかを確認するための記述です。
HTTPリクエストを行って、HTTPレスポンスのステータスコードに応じて例外を発生させます。
ちゃんと書くときは独自例外クラスを使うなどして適切な例外を発生させてくださいね。
上記メソッドではHTTPレスポンスのステータスコードが200以外だったら例外を発生させます。ガバガバですねw
メソッドの戻り値はステータスコードとbodyを辞書形式で返しています。ステータスコードいる?
テストコード
分割して書きます。
import文はこんな感じになりました。
import pytest
import os
import sys
sys.path.append(os.path.join(os.path.dirname(__file__), ".."))
from src.OreoreRequests import OreoreRequests
テスト実行時のコマンド
実際のテストコードを紹介する前に実行コマンドを記載しておきます。これから先の実行は全てこのコマンドで行います。
以下のコマンドで実行します。-s
オプションで標準出力をコンソールに表示します。
pytest test/test_OreoreRequests.py -v -s
# pipenv 使用時はこちら。
pipenv run pytest test/test_OreoreRequests.py -v -s
正常系テスト
class TestOreoreRequests:
def test_get_200(self):
ore = OreoreRequests("http://localhost:8000/")
result = ore.get()
assert result["statuscode"] == 200
テストを実行したコンソールにPASSED
と出ると思います。
また起動したHTTPサーバのログに"GET / HTTP/1.1" 200 -
と出ているかと思います。
想定通りWebサーバにアクセスしていることがわかります。
mock使用の異常系テストその1
以下のmockの書き方では、requests.get()
をmockにしています。
メソッド全体ではなくrequets.get()
だけをmockにしています。
テスト内容は、404だったらExceptionが発生することを期待しています。
また、HTTPリクエストをmockにしたので、Webサーバにリクエストが飛ばないことも期待しています。これはコンソールで確認します。
class TestOreoreRequests:
def test_get_404(self, mocker):
ore = OreoreRequests("http://localhost:8000/")
mock = mocker.Mock()
mock.status_code = 404
mock.text = "Not Found"
mocker.patch("requests.get").return_value = mock
with pytest.raises(Exception):
ore.get()
このテストを実行したコンソールにPASSED
と出ます。つまりちゃんと例外が発生しています。
確認のためにwith句のException
をValueError
などと置き換えて再度テストを実行すると、この箇所で失敗するのがわかります。
コンソールを確認するとリクエストを受け付けていないことも確認できます。
HTTPリクエストを行う処理だけがmockに置き換えられています。想定通りの動きですね。
mock使用の異常系テストその2
以下の書き方では、OreoreRequests
クラスのget
メソッドをmockにしています。メソッドそのものです。つまりOreoreRequests.get
メソッドの中は通りません。
テストを実行しても標準出力に`DEBUGDEBUGDEBUG`は表示されません。
本来なら以下のテストは失敗します。が、with句でExceptionを期待しているため成功します。
コメントにも書きましたがwith句の中の`ore.get()`はTypeErrorを発生させます。
class TestOreoreRequests:
def test_get_500(self, mocker):
ore = OreoreRequests("http://localhost:8000/")
result_mock = {
"statuscode": 500,
"text": "Internal Server Error"
}
mocker.patch.object(
OreoreRequests,
"get",
result_mock
)
with pytest.raises(Exception):
result = ore.get() # TypeError: 'dict' object is not callable
result = ore.get
assert result["statuscode"] == 500 # # test passed.
print(ore.get is result_mock)
実行結果から、mocker.patch.object
メソッドは、指定したクラスのメソッド(関数オブジェクト)をresult_mock
というdictオブジェクトに置き換える
と、考えます。(仕様として正しいかどうかは要調査です。)
この考察を確認するために文末のprint文でore.get
とresult_mock
が同じオブジェクトかどうかを確認する文を入れたところ、テストの実行結果にはTrueが出力されました。
コンソールを確認するとリクエストを受け付けていないことも確認できます。
まとめ
今回のテストケースは、HTTPステータスコードによる処理の確認なので、mock使用の異常系テストその1 の使用が妥当だと思われます。
今回のケースでは mock使用の異常系テストその2 は目的と一致しませんでしたが、スタブとしては使えそうですね。(スタブとモックとドライバーと、ちゃんとした言葉の定義がいつも覚えられない。)