読者です 読者をやめる 読者になる 読者になる

Pythonで、Werkzeug.testを使って、WSGIサーバを起動せずにWSGIアプリのテストをする

Python Werkzeug テスト

以前、WebTestやwsgi-interceptを使ってWSGIアプリのテストをしました。

 
その他のテストツールとして、Werkzeug.testがありましたので、今回試してみます。
Test Utilities — Werkzeug Documentation (0.11)

 
目次

 

環境

  • Mac OS X 10.11.6
  • Python 3.6.1
  • Werkzeug 0.12.1
  • pytest 3.0.7
    • テストランナー

 
また、テスト対象アプリは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.BaseResponsewerkzeug.wrappers.Responseなどが指定できます。何も指定しない場合はタプルでレスポンスが返ってきます。

今回は、BaseResponseに便利なものをMixinしたwerkzeug.wrappers.Responseを指定します。

 
こんな感じでレスポンスを取得します。

sut = Client(app, Response)
actual = sut.get('/')

 
レスポンスはwerkzeug.wrappers.Responseオブジェクトなので、ステータスコード(status_code)・レスポンスヘッダ(headers)・レスポンスボディ(get_data())などの属性やメソッドが使えます。

 
なお、get_data()メソッドの戻り値は、デフォルトではバイト文字列です。Unicodeの文字列で取得するには、引数にas_text=Trueをセットします。

ただし、Content-Typeのcharsetがutf-8以外の場合、以下の通りバグがあり、現時点でもOpenされたままなので注意します。

 
テストコード全体は以下の通りとなり、テストはパスします。

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_jarCookieとして設定されるようです。
https://github.com/pallets/werkzeug/blob/0.12.1/werkzeug/test.py#L648

 
_TestCookieJarは標準モジュールのhttp.cookiejar.CookieJarを継承したオブジェクトでした。

 

Cookieの取り出し方について

CookieJarオブジェクトからCookieオブジェクトを取り出すには、

CookieJar オブジェクトは保管されている Cookie オブジェクトをひとつずつ取り出すための、イテレータ(iterator)・プロトコルをサポートしています。

21.24.1. CookieJar および FileCookieJar オブジェクト | 21.24. http.cookiejar — HTTP クライアント用の Cookie 処理 — Python 3.6.1 ドキュメント

とのことで、イテレートすれば良さそうです。

 
また、Cookieオブジェクトの属性を見ると、namevalueなどがありました。これらを使えば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