以前、WebTestやwsgi-interceptを使ってWSGIアプリのテストをしました。
- Pythonで、WebTestを使って、WSGIサーバを起動せずにWSGIアプリのテストをする - メモ的な思考的な
- Pythonで、wsgi-interceptを使って、WSGIサーバを起動せずにWSGIアプリのテストをする - メモ的な思考的な
その他のテストツールとして、Werkzeug.test
がありましたので、今回試してみます。
Test Utilities — Werkzeug Documentation (0.14)
目次
環境
また、テスト対象アプリはwsgi-interceptで使った、Bottleの掲示板アプリを流用します。
Werkzeugとは
Werkzeugは「ヴェルクツォイク」と読むようです。
Werkzeugの読み方 - Qiita
WerkzeugはWSGIユーティリティライブラリで、Flaskなどで使われています。作者はFlaskやJinja2と同じArmin Ronacherさんです。
Welcome | Werkzeug (The Python WSGI Utility Library)
また、Werkzeug.testでは、werkzeug.Client
を使って、GETやPOSTなどのテストを書くようです。
Test Utilities — Werkzeug Documentation (0.11)
GETのテスト
werkzeug.Clientのコンストラクタではテスト対象のWSGIアプリを、オプションresponse_wrapper
でレスポンスの型を指定します。
response_wrapperにはwerkzeug.wrappers.BaseResponse
やwerkzeug.wrappers.Response
などが指定できます。何も指定しない場合はタプルでレスポンスが返ってきます。
今回は、BaseResponseに便利なものをMixinしたwerkzeug.wrappers.Response
を指定します。
- werkzeug.wrappers.BaseResponse | Request / Response Objects — Werkzeug Documentation (0.11)
- werkzeug.wrappers.Response | Request / Response Objects — Werkzeug Documentation (0.11)
こんな感じでレスポンスを取得します。
sut = Client(app, Response)
actual = sut.get('/')
レスポンスはwerkzeug.wrappers.Response
オブジェクトなので、ステータスコード(status_code
)・レスポンスヘッダ(headers
)・レスポンスボディ(get_data()
)などの属性やメソッドが使えます。
- status_code | Request / Response Objects — Werkzeug Documentation (0.11)
- headers | Request / Response Objects — Werkzeug Documentation (0.11)
- get_data() | Request / Response Objects — Werkzeug Documentation (0.11)
data
属性はそのうちdeprecatedになるとのことです- data | Request / Response Objects — Werkzeug Documentation (0.11)
なお、get_data()メソッドの戻り値は、デフォルトではバイト文字列です。Unicodeの文字列で取得するには、引数にas_text=True
をセットします。
ただし、Content-Typeのcharsetがutf-8
以外の場合、以下の通りバグがあり、現時点でもOpenされたままなので注意します。
- Python: (今のところ) Flask で Request#get_data(as_text=True) は使わない方が良い - CUBE SUGAR CONTAINER
- Request#get_data(as_text=True) does not work with Content-Type/charset · Issue #947 · pallets/werkzeug
テストコード全体は以下の通りとなり、テストはパスします。
def test_get(self): # 戻り値をwerkzeug.wrappers.Response型で取得するようインスタンス化 sut = Client(app, Response) # `/`へGETリクエスト actual = sut.get('/') # ステータスコードの確認 assert actual.status_code == 200 # Content-Typeの確認 assert actual.headers.get('Content-Type') == 'text/html; charset=UTF-8' # レスポンスボディの確認 body = actual.get_data(as_text=True) assert 'テスト掲示板' in body
POSTのテスト
werkzeug.Client
にはpost()
メソッドもあるため、POSTのテストも行えます。
ソースコードを読むと、post()メソッドはopen()
メソッドのラッパーのようです。
https://github.com/pallets/werkzeug/blob/0.12.1/werkzeug/test.py#L798
そのため、open()メソッドの引数が使えそうでした。
open() | Test Utilities — Werkzeug Documentation (0.11)
また、POSTするフォームのデータは引数data
で指定します。Werkzeugのドキュメントでは見当たりませんでしたが、Flaskのドキュメントにありました。
Testing Flask Applications — Flask Documentation (0.12)
POST時のリダイレクトの挙動については、リダイレクトを許可する引数follow_redirects
は、デフォルトだとFalse
(許可しない)でした。
https://github.com/pallets/werkzeug/blob/0.12.1/werkzeug/test.py#L750
以下がリダイレクトありのPOSTのテストコードです。
def test_post_with_redirect(self): form = { 'title': 'タイトル2', 'handle': 'ハンドル名2', 'message': 'メッセージ2', } sut = Client(app, Response) actual = sut.post('/', data=form, follow_redirects=True) assert actual.status_code == 200 assert actual.headers['content-type'] == 'text/html; charset=UTF-8' body = actual.get_data(as_text=True) assert 'テスト掲示板' in body assert 'タイトル2' in body assert 'ハンドル名2' in body assert 'メッセージ2' in body
Cookieのテスト
POSTのテストでは確認しなかったため、Cookieもテストしてみます。
POST後のCookieのセット先について
まずは、Cookieはどこにセットされるのかを見てみます。
コンストラクタの引数でuse_cookies=True
(デフォルト値)の場合、_TestCookieJar
オブジェクトがClient.cookie_jar
にCookieとして設定されるようです。
https://github.com/pallets/werkzeug/blob/0.12.1/werkzeug/test.py#L648
_TestCookieJar
は標準モジュールのhttp.cookiejar.CookieJar
を継承したオブジェクトでした。
- https://github.com/pallets/werkzeug/blob/0.12.1/werkzeug/test.py#L168
- https://github.com/pallets/werkzeug/blob/0.12.1/werkzeug/test.py#L23
Cookieの取り出し方について
CookieJarオブジェクトからCookieオブジェクトを取り出すには、
CookieJar オブジェクトは保管されている Cookie オブジェクトをひとつずつ取り出すための、イテレータ(iterator)・プロトコルをサポートしています。
とのことで、イテレートすれば良さそうです。
また、Cookieオブジェクトの属性を見ると、name
やvalue
などがありました。これらを使えばCookieの値を取り出せそうです。
21.24.5. Cookieオブジェクト | 21.24. http.cookiejar — HTTP クライアント用の Cookie 処理 — Python 3.6.1 ドキュメント
ここまでをまとめると、Cookie値の取り出し方は以下となりました。
# cookie_jarをイテレートして、Cookieオブジェクトを取り出す for c in client.cookie_jar: # Cookieオブジェクトのname属性が、Cookieのキーと一致した場合、取り出し処理を行う if c.name == name: # Cookieの取り出し処理 return None
これでもいいのですが、Pythonっぽく辞書のように条件を指定してイテレータから値を取り出す方法がないかを探したところ、stackoverflowに情報がありました。
python - find first list item that matches criteria - Stack Overflow
組込関数のnext()
を使えば良いようです。
next() | 2. 組み込み関数 — Python 3.6.1 ドキュメント
cookie = next(c for c in client.cookie_jar if c.name == name, default=None) if cookie: # Cookieの取り出し処理 return None
なお、Cookie名が重複してセットされている場合は、上記のforやnextを使ったロジックだと不適切かもしれません。
ただ、RFC6265を見ると、
サーバは、同じ応答内に同じ cookie-name の複数の Set-Cookie ヘッダを内包するべきでない。 ( UA がこの場合をどのように扱うかについては、 5.2 節 を見よ。†)
【† と記されているが、この場合の取り扱いは,明快な形では述べられていない。 UA が該当するクッキーを 受信した (あるいは,ヘッダが現れる)順番通りに処理し, かつ そのそれぞれを無視しないと見なすならば、あたかも,そのそれぞれが別々の応答で受信されたかのように処理することになると考えられる。 (例えば name が同じでも, path が異なれば、保管の際には別々に扱われる。) しかしながら、 5.2 節には “UA は Set-Cookie ヘッダを無視してもよい” とも記されているので、この種の重複に際し,最初のものだけが有効にされる実装も考えられなくはない。 】
4.1.1. 構文 | RFC 6265 — HTTP State Management Mechanism (日本語訳)
とあったので、Cookie名は重複しないものとして、今回は話を進めます。
Cookie値の変換について
次にCookie値を見てみると、
print(cookie.value) #=> "\343\203\217\343\203\263\343\203\211\343\203\253\345\220\2153"
そのため、以前見た通り、ast.literal_eval()
などでUnicode文字へと変換します。
Python3で、リテラルのエスケープシーケンスに見える非リテラルの文字列を、Unicode文字へと変換する - メモ的な思考的な
# Cookie値として、リテラルのエスケープシーケンスに見える文字列がセットされている print(cookie.value) #=> "\343\203\217\343\203\263\343\203\211\343\203\253\345\220\2153" # ast.literal_eval()でUnicode文字化するが、文字コードが合っていないっぽい after_literal_eval = ast.literal_eval(cookie.value) print(after_literal_eval) #=> ãã³ãã« # 以前見たように、Latin-1でエンコードしてバイト文字列にする encoded = after_literal_eval.encode('latin1') print(encoded) #=> b'\xe3\x83\x8f\xe3\x83\xb3\xe3\x83\x89\xe3\x83\xab\xe5\x90\x8d3' # バイト文字列をデコードしてUnicode文字列にする decoded = encoded.decode('utf-8') print(decoded) #=> ハンドル名3
これでUnicode文字になったたため、後は
assert actual_cookie == 'ハンドル名3'
として検証したところ、テストがパスしました。
ソースコード
GitHubに上げました。e.g._werkzeug_test
が今回のコードです。
thinkAmi-sandbox/python_werkzeug-sample