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

以前、WebTestを使ってWSGIアプリのテストを行いました。
Pythonで、WebTestを使って、WSGIサーバを起動せずにWSGIアプリのテストをする - メモ的な思考的な

 
他にもテストツールがないかを探したところ、wsgi-interceptがありました。
cdent/wsgi-intercept: Intercept socket connection to wsgi applications for testing

 
そこで今回はwsgi-interceptを試してみます。

 
目次

 

環境

 

テスト対象のアプリ

Bootleで掲示板を作成しました。仕様のとおりです。

 
ソースコードです。

# -*- coding: utf-8 -*-
import datetime
import pickle
from pathlib import Path
import bottle
from bottle import Bottle, run, redirect, request, response, jinja2_template


class Message(object):
    def __init__(self, title, handle, message):
        self.title = title
        self.handle = handle
        self.message = message
        self.created_at = datetime.datetime.now().strftime('%Y/%m/%d %H:%M:%S')

# テストコードで扱えるよう、変数appにインスタンスをセット
app = Bottle()

@app.get('/')
def get_form():
    handle = request.cookies.getunicode('handle', default='')
    messages = read_messages()
    return jinja2_template('bbs.html', handle=handle, messages=messages)

@app.post('/foo')
def post_foo():
    return "Hello World!"


@app.post('/')
def post_form():
    response.set_cookie('handle', request.forms.get('handle'))

    message = Message(
        title=request.forms.getunicode('title'),
        handle=request.forms.getunicode('handle'),
        message=request.forms.getunicode('message'),
    )
    messages = read_messages()
    messages.append(message)
    with open('bbs.pickle', mode='wb') as f:
        pickle.dump(messages, f)

    redirect('/')


@app.get('/delete_cookie')
def delete_cookie():
    response.delete_cookie('handle')
    redirect('/')


def read_messages():
    if Path('bbs.pickle').exists():
        with open('bbs.pickle', mode='rb') as f:
            return pickle.load(f)
    return []


if __name__ == "__main__":
    run(app, host="localhost", port=8080, debug=True, reloader=True)

 
テンプレートです。

<html>
    <head>
        <meta charset="UTF-8">
        <title>テスト掲示板 | bottle app</title>
    </head>
    <body>
        <h1>テスト掲示板</h1>
        <form action="/" method="POST">
            <div>
                <label for="title">タイトル</label>
                <input type="text" name="title" size="60">
            </div>
            <div>
                <label for="handle">ハンドル</label>
                <input type="text" name="handle" value="{{handle}}">
            </div>
            <div>
                <label for="message">メッセージ</label>
                <textarea name="message" rows="4" cols="60"></textarea>
            </div>
            <div>
                <input type="submit">
                <a href="/delete_cookie">Cookie削除</a>
            </div>
        </form>

        <hr>

        {% for m in messages %}
            <p>
                「{{ m.title }}」&nbsp;&nbsp;
                {{ m.handle }} さん&nbsp;&nbsp
                {{ m.created_at }}
            </p>
           <p>{{ m.message }}</p>
          <hr>
        {% endfor %}

    </body>
</html>

 

wsgi-interceptを使ったテストコード

wsgi-interceptでは、ライブラリ

  • urllib2
  • urllib.request
  • httplib
  • http.client
  • httplib2
  • requests
  • urllib3

のいずれかを併用してテストコードを書きます。

今回は扱いやすいrequestsを使ってテストコードを書きます。
Requests: HTTP for Humans — Requests 2.13.0 documentation

 

テストの準備
テンプレートディレクトリを認識させる

wsgi-interceptでBottleをテストする場合、デフォルトではテンプレートディレクトリを認識しません。

そのため、

current_dir = os.path.abspath(os.path.dirname(__file__))
template_dir = os.path.join(current_dir, 'bbs_app/views')
bottle.TEMPLATE_PATH.insert(0, template_dir)

のようにして、テンプレートディレクトリを認識させる必要があります。
Frequently Asked Questions — Bottle 0.13-dev documentation

 

Bottleアプリの指定について

wsgi-interceptでは、from bbs_app.bbs import appしてappを利用しようとしてもうまく動作しません。

そのため、

def get_app(self):
    return app

のように、importしたBottleアプリを返す関数やメソッドを用意し、それをwsgi-interceptを使う時に渡してあげる必要があります。

 

GETのテスト

ドキュメントに従い、以下のテストコードを書きます。

with RequestsInterceptor(self.get_app, host='localhost', port=8080) as url:
    actual = requests.get(url)

assert actual.status_code == 200
assert actual.headers['content-type'] == 'text/html; charset=UTF-8'
assert 'テスト掲示板' in actual.text

 
テストを実行すると、wsgi-interceptがrequest.get()をうまいこと処理し、レスポンス(今回はactual)が返ってきます。

あとは、その値を検証すれば良いです。

 

POSTのテスト(add_wsgi_intercept利用版)

上記ではwithを利用していましたが、ドキュメントにもある通り、add_wsgi_intercept()などを使ってもテストできます。

host = 'localhost'
port = 8081
url = f'http://{host}:{port}'

requests_intercept.install()
add_wsgi_intercept(host, port, self.get_app)

form = {
    'title': 'タイトル1',
    'handle': 'ハンドル名1',
    'message': 'メッセージ1',
}
actual = requests.post(url, data=form)

assert actual.status_code == 200
assert actual.headers['content-type'] == 'text/html; charset=UTF-8'
assert 'タイトル1' in actual.text
assert 'ハンドル名1' in actual.text
assert 'メッセージ1' in actual.text

requests_intercept.uninstall()

install()やuninstall()など、色々と手間がかかるため、withの方が良さそうです。

 

POSTとリダイレクトのテスト(RequestsInterceptor利用版)

Webアプリに対してrequestsを使うときと同様の書き方で、POSTやリダイレクトのテスト・検証が行えます。

form = {
    'title': 'タイトル2',
    'handle': 'ハンドル名2',
    'message': 'メッセージ2',
}
with RequestsInterceptor(self.get_app, host='localhost', port=8082) as url:
    # リダイレクト付
    actual = requests.post(url, data=form)

# リダイレクト先の検証
assert actual.status_code == 200
assert actual.headers['content-type'] == 'text/html; charset=UTF-8'
assert 'タイトル2' in actual.text
assert 'ハンドル名2' in actual.text
assert 'メッセージ2' in actual.text

# リダイレクト元の検証
assert len(actual.history) == 1
history = actual.history[0]
assert history.status_code == 302
assert history.headers['content-type'] == 'text/html; charset=UTF-8'
# bottleでredirect()を使った場合、bodyは''になる
assert history.text == ''

 
また、requestsでは、post()メソッドの引数にてallow_redirects=Falseとすれば、リダイレクトしなくなります。
Developer Interface — Requests 2.13.0 documentation

 
リダイレクトなしの場合のテストコードです。

form = {
    'title': 'タイトル3',
    'handle': 'ハンドル名3',
    'message': 'メッセージ3',
}
with RequestsInterceptor(self.get_app, host='localhost', port=8082) as url:
    # リダイレクトさせない
    actual = requests.post(url, data=form, allow_redirects=False)

assert len(actual.history) == 0
assert actual.status_code == 302
assert actual.headers['content-type'] == 'text/html; charset=UTF-8'
# bottleでredirect()を使った場合、bodyは''になる
assert actual.text == ''

 

その他

Bottleアプリではset_cookie()を使っているため、POST時にhandleを設定しないと、TypeError: Secret key missing for non-string Cookieというエラーになります。
Python Bottle - TypeError: Secret key missing for non-string Cookie. | BitmapCake!

 
wsgi-interceptでもエラーを確認できるのですが、エラー出力内容がPython2とPython3で異なりました。

Python2は上記のエラー出力のみですが、Python3では上記のほか、

  • TypeError: getresponse() got an unexpected keyword argument ‘buffering’
  • TypeError: a bytes-like object is required, not ‘str’
  • wsgi_intercept.WSGIAppError: TypeError(“a bytes-like object is required, not ‘str’”,) at path/to/env/bin/bottle.py:972

が混ざっていました。特に、本当のエラーのSecret key missingが途中に混ざっており、エラー解析しづらい印象でした。

 
そのため、こんな感じでcurlも併用して原因の調査をしました。

curl -XPOST --data 'title=bar&handle=baz' http://localhost:8080/

参考:curl コマンド 使い方メモ - Qiita

 

ソースコード

GitHubに上げました。
thinkAmi-sandbox/wsgi_intercept-sample