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

Pythonで、「WSGIサーバを起動せずにWSGIアプリをテストする」方法を探してみたところ、ライブラリWebTestがありました。
Pylons/webtest: Wraps any WSGI application and makes it easy to send test requests to that application, without starting up an HTTP server.

そこで、以下を参考にして、WebTestを使ったテストコードを書いてみました。

 
目次

 

環境

  • Mac OS X 10.11.6
  • Python 3.6.0
  • WebTest 2.0.27
    • 依存パッケージ
      • BeautifulSoup4 4.5.3
      • WebOb 1.7.2
  • pytest 3.0.7
    • テストランナー

 

Hello world的なWSGIアプリのテスト

Hello worldを表示するWSGIアプリを作成しました。

def application(environ, start_response):
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b"Hello, world."]

 
次にテストコードを書きます。

WebTestではTestAppを使うことで、擬似的なHTTPリクエスト・レスポンスをテストできます。

GETのテストコードを書いてみたところ、テストをパスしました。

from webtest import TestApp
import simple_wsgi_app

class Test_simple_wsgi_app(object):
    def test_get(self):
        # TestAppにテスト対象のアプリケーションを渡す
        sut = TestApp(simple_wsgi_app.application)
        # getリクエストを送信
        actual = sut.get('/')

        # ステータスコード・content-type、ボディのテスト
        assert actual.status_code == 200
        assert actual.content_type == 'text/plain'
        assert actual.body == b'Hello, world.'

 

GETやPOSTでjinja2テンプレートを返すWSGIアプリのテスト

続いて、以前作成したWSGIアプリを使って、GETやPOSTのテストを書いてみます。bbs_wsgi_app.pyが今回使うアプリです。
wsgi_application-sample/bbs_wsgi_app.py

 
このアプリは、

  • WSGIフレームワークは使っていない
  • GETでjinja2テンプレートを返す
  • POSTでリダイレクトし、jinja2テンプレートに値を埋めて返す

というモノです。

import datetime
import cgi
import io
from jinja2 import Environment, FileSystemLoader

class Message(object):
    def __init__(self, title, handle, message):
        self.title = title
        self.handle = handle
        self.message = message
        self.created_at = datetime.datetime.now()


class MyWSGIApplication(object):
    def __init__(self):
        self.messages = []

    def __call__(self, environ, start_response):
        if environ['REQUEST_METHOD'].upper() == "POST":
            decoded = environ['wsgi.input'].read().decode('utf-8')
            header_body_list = decoded.split('\r\n')
            body = header_body_list[-1]
            encoded_body = body.encode('utf-8')
            with io.BytesIO(encoded_body) as bytes_body:
                fs = cgi.FieldStorage(
                    fp=bytes_body,
                    environ=environ,
                    keep_blank_values=True,
                )
            self.messages.append(Message(
                title=fs['title'].value,
                handle=fs['handle'].value,
                message=fs['message'].value,
            ))
            location = "{scheme}://{name}:{port}/".format(
                scheme = environ['wsgi.url_scheme'],
                name = environ['SERVER_NAME'],
                port = environ['SERVER_PORT'],
            )
            start_response(
                '301 Moved Permanently',
                [('Location', location), ('Content-Type', 'text/plain')])
            # 適当な値を返しておく
            return [b'1']

        else:
            jinja2_env = Environment(loader=FileSystemLoader('./templates', encoding='utf8'))
            template = jinja2_env.get_template('bbs.html')
            html = template.render({'messages': self.messages})
            start_response('200 OK', [('Content-Type', 'text/html')])
            return [html.encode('utf-8')]


app = MyWSGIApplication()

 

GETのテスト

TestAppを使ってGETのテストを書いてみます。

レスポンスボディの取得方法は2つあったため、それぞれ試してみます。

  • bodyで、バイト文字列のレスポンスボディを取得
  • textで、ユニコード文字列のレスポンスボディを取得

 

class Test_simple_wsgi_app(object):
    def test_get(self):
        """GETのテスト"""
        sut = TestApp(get_post_app.app)
        actual = sut.get('/')

        assert actual.status_code == 200
        assert actual.content_type == 'text/html'
        # bodyは、レスポンスボディをバイト文字列で取得
        assert 'テスト掲示板'.encode('utf-8') in actual.body
        # textは、レスポンスボディをユニコード文字列で取得
        assert 'テスト掲示板' in actual.text

 

直接POSTするテスト

続いて、直接POSTリクエストを送信するテストを書いてみます。

なお、このアプリではPOSTの後にリダイレクトしています。

リダイレクトに追随するには、follow()を使ってリダイレクト先のレスポンスを取得します。
follow(**kw) | webtest API — WebTest 2.0.28.dev0 documentation

def test_post(self):
    """直接POSTのテスト"""
    sut = TestApp(get_post_app.app)
    actual = sut.post(
        '/',
        {'title': 'ハム', 'handle': 'スパム', 'message': 'メッセージ'})

    assert actual.status_code == 301
    assert actual.content_type == 'text/plain'
    assert actual.location == 'http://localhost:80/'
    assert actual.body == b'1'

    # redirectの検証には、follow()を使う
    redirect_response = actual.follow()
    assert 'ハム' in redirect_response.text
    assert 'スパム さん' in redirect_response.text
    assert 'メッセージ' in redirect_response.text

 

フォームのsubmitを使ってPOSTするテスト

WebTestではフォームのsubmitボタンを押すこともできるため、

  • GETでフォームを取得
  • フォームに入力し、submitボタンを押してPOSTする

というテストも行えます。

def test_form_post(self):
    """GETして、formに入力し、submitボタンを押すテスト"""
    sut = TestApp(get_post_app.app)
    # 属性formを使って、フォームの中身をセット
    form = sut.get('/').form
    form['title'] = 'ハム'
    form['handle'] = 'スパム'
    form['message'] = 'メッセージ'
    # submit()を使って、フォームデータをPOST
    actual = form.submit()

    assert actual.status_code == 301
    assert actual.content_type == 'text/plain'
    assert actual.location == 'http://localhost:80/'
    assert actual.body == b'1'

    redirect_response = actual.follow()
    assert 'ハム' in redirect_response.text
    assert 'スパム さん' in redirect_response.text
    assert 'メッセージ' in redirect_response.text

 

BeautifulSoupを使ったPOSTの検証

WebTestではBeautifulSoupを使った値取得もできます。

 
BeautifulSoupを使ったテストコードを書いてみます。

def test_post_with_beautifulsoup(self):
    """BeautifulSoupを使って検証する"""
    sut = TestApp(get_post_app.app)
    response = sut.post(
        '/',
        {'title': 'ハム', 'handle': 'スパム', 'message': 'メッセージ'})
    redirect_respose = response.follow()

    # response.htmlで、BeautifulSoupオブジェクトを取得できる
    actual = redirect_respose.html

    title = actual.find('span', class_='title')
    # BeautifulSoupのget_text()で出力してみると、文字化けしていた
    print(title.get_text())  #=> ������������

    assert '「ハム」' == actual.find('span', class_='title').get_text()

get_text()の結果が文字化けしたことにより、テストは失敗しました。

 
試しにレスポンスの内容を出力してみたところ、

def test_print_respose_object(self):
    """レスポンスオブジェクトを表示してみる"""
    sut = TestApp(get_post_app.app)
    actual = sut.get('/')
    print(actual)
    assert False
    """
    Response: 200 OK
    Content-Type: text/html
    <html>
        <head>
            <meta charset="UTF-8">
            <title>���������������������</title>
        </head>
        <body>
            <h1>������������������</h1>
            <form action="/" method="POST">
                ��������������� <input type="text" name="title" size="60"><br>
                ������������������ <input type="text" name="handle"><br>
                <textarea name="message" rows="4" cols="60"></textarea>
                <input type="submit">
            </form>
            <hr>
        </body>
    </html>
    """

日本語を表示する部分が文字化けしていました。

これが原因のようですが、今回は深く追求しません。

 

Bottleアプリのテスト

今まではWebフレームワークを使わないWSGIアプリをテストしました。

今度は、WebフレームワークであるBottleを使ってWSGIアプリを作成し、WebTestを使ってテストコードを書いてみます。

Bottleの公式ページにも、WebTestを使ってテストを書いている例がありました。
FUNCTIONAL TESTING BOTTLE APPLICATIONS | Recipes — Bottle 0.13-dev documentation

アプリコードは以下の通りです。

なお、フォームの入力値を取得する場合、request.forms.ge()だと日本語が文字化けします。そのため、request.forms.getunicode()を使います。

from bottle import Bottle, get, post, run, request, HTTPResponse
from bottle import TEMPLATE_PATH, jinja2_template
import datetime
import json


class Message(object):
    """Bottleのjinja2テンプレートへ値を引き渡すためのクラス"""
    def __init__(self, title, handle, message):
        self.title = title
        self.handle = handle
        self.message = message
        self.created_at = datetime.datetime.now()

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

@app.get('/')
def get_top():
    return jinja2_template('bbs', message=None)

@app.post('/')
def post_top():
    print(request.forms.get('handle'))
    message = Message(
        # get()だと文字化けするため、getunicode()を使う
        title=request.forms.getunicode('title'),
        handle=request.forms.getunicode('handle'),
        message=request.forms.getunicode('message'),
    )
    return jinja2_template('bbs', message=message)

@app.post('/json')
def post_json():
    json_body = request.json
    print(json_body)

    body = json.dumps({
        'title': json_body.get('title'),
        'message': json_body.get('message'),
        'remarks': '備考'})
    r = HTTPResponse(status=200, body=body)
    r.set_header('Content-Type', 'application/json')
    return r


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

 
また、HTMLテンプレートとしてjinja2を使います。

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>テストタイトル</title>
    </head>
    <body>
        <h1>テスト掲示板</h1>
        <form action="/" method="POST">
            タイトル: <input type="text" name="title" size="60"><br>
            ハンドル名: <input type="text" name="handle"><br>
            <textarea name="message" rows="4" cols="60"></textarea><br>
            <input type="submit">
        </form>
        <hr>

        {% if message %}
            <p>
                <span class="title">「{{ message.title }}」</span>&nbsp;&nbsp;
                <span class="handle">{{ message.handle }} さん</span>&nbsp;&nbsp
                <span class="created_at">{{ message.created_at }}</span>
            </p>
            <p class="message">{{ message.message }}</p>
            <hr>
        {% endif %}
    </body>
</html>

 

フォームからPOSTするテスト

GETでフォームを取得し、フォームにデータをセットして、submitボタンでデータをPOSTするテストを作成します。

class Test_bottle_app(object):
    @pytest.mark.xfail
    def test_form_submit(self):
        """GETして、formに入力し、submitボタンを押すテスト"""
        sut = TestApp(bottle_app.app)
        response = sut.get('/')
        form = response.form
        form['title'] = u'ハム'.encode('utf-8').decode('utf-8')
        form['handle'] = b'\xe3\x81\x82' #あ
        form['message'] = 'メッセージ'
        actual = form.submit()

        assert actual.status_code == 200
        assert actual.content_type == 'text/html'

        assert 'ハム' in actual.text
        assert 'あ さん' in actual.text
        assert 'メッセージ' in actual.text

実行したところ、テストはパスしました。

また、直接POSTするコードもパスしました。

 

JSONをPOSTするテスト

続いてJSONをPOSTするテストを書いてみます。

フォームのときと同じだろうかと心配しましたが、最近クローズされたissueにてJSONに関する修正が入っていました。
Decoding issue for non-ASCII characters in JSON response · Issue #177 · Pylons/webtest

そこで、どうなるのかを試してみました。

 

def test_post_json(self):
    sut = TestApp(bottle_app.app)
    actual = sut.post_json('/json', dict(title='タイトル', message='メッセージ'))

    assert actual.status_code == 200
    assert actual.content_type == 'application/json'

    assert actual.json.get('title') == 'タイトル'
    assert actual.json.get('message') == 'メッセージ'
    assert actual.json.get('remarks') == '備考'

実行したところ、テストはパスしました。

 

ソースコード

GitHubにあげました。
thinkAmi-sandbox/wsgi_webtest-sample