pytestでflaskのテストを実施

## 概要
「Pythonでシフト作成ツールの作成」の連載の第２項です。

シフト作成ツールを作りつつ、pythonでのツール作成を学んでいきます。

１：[flaskでhello world的なことを](https://qiita.com/epire/items/380cbad18faab1410078)
２：pytestでflaskのテストを実施

flaskにて作成したソースのテストをpytestを学びながら書いていきます。

## 前提条件
以下のソースのテストを考えます。

``` __init__.py
    @app.route('/', methods=['GET', 'POST'])
    def login():
        if request.method == 'POST':
            session.clear()
            if request.form['login_id'] != 'admin' and request.form['password'] != 'pass':
                flash('Not logged in')
                return render_template('login.html')
            session['logged_in'] = True
            flash('You were logged in')
            return redirect(url_for('show_menu'))
        return render_template('login.html')
```

`app`はflaskのインスタンスです。
flaskで擬似ログイン的なことをしています。

連載物ということもあり、テスト対象には第１項で作成したソースを使用しています。
第１項ではflaskの基本に関して書いてあります。必要に応じて[第１項](https://qiita.com/epire/items/380cbad18faab1410078)もご参照ください。
ただ、テストを作成するに従って多少リファクタリングしています。

第１項から第２項を実施するに従ってのリファクタリングに関しては末尾の[第１項からのリファクタリング点](#第１項からのリファクタリング点)を参照ください。

## 工程
今回のテストではログイン処理が成功しているかを確認します。

テスト用にtest_login.pyを作成します。
pytestはtest_\*.pyのファイルを自動的に拾って実行してくれます。

### fixtureの作成
まずはfixtureを作っていきます。
fixtureはpytestの機能で、テストの下地を作ってくれてテスト前・テスト後の処理の一元化などに使用できます。

fixtureはconftest.py内に作られることが多いです。

このconftest.pyはpytestにより認識され、
pytest → conftest → test_\*.py
の順で読み込まれます。

ただ、今回はまだテストは１ファイルのみなので同一ファイル内に記載していきます。

fixtureの作り方は関数を作成して`@pytest.fixture`でデコレートするだけです。

``` test_login.py
import pytest
from workscheduler import create_app


@pytest.fixture
def app():
    app = create_app({'TESTING': True})
    # 必要なら前処理を
    # yield app
    return app
    # 必要なら後処理を


@pytest.fixture
def client(app):
    return app.test_client()
```

`app()`の方ではflaskのインスタンスを作成して返しています。
コメントにも書いてありますが通常ではテスト前・テスト後の処理をそこで行う際には`yield`で返して処理をします。

`client()`では単純なリクエストを処理できるアプリケーションを作成してます。

fixtureはテスト実行時に宣言した関数名をテストの引数として指定しておけば自動で読み込まれます。

### テストの作成
fixtureは作成できましたので、テストを作成していきます。

pytestではtest_\*で宣言した関数をテストと見做して実行してくれますので今回は`test_login`を作成します。
ちなみにクラスの際にはTest\*でテストクラスと見做されます。

``` test_login.py
...

def login(client, login_id, password):
    return client.post('/', data=dict(login_id=login_id, password=password), follow_redirects=True)


def test_login(client):
    with client:
        # login success test
        rv = login(client, 'admin', 'password')
        assert b'Menu' in rv.data
        assert session['logged_in']
        # login error test
        rv = login(client, 'none', 'none')
        assert b'Not logged in' in rv.data
        assert 'logged_in' not in session
```

`test_login`では先に宣言したfixtureのclientを引数として受け取ります。

#### テストリクエストを投げる
ログイン処理は / にPOSTリクエストを投げることで処理を実行していました。

POSTリクエストは`post`で、GETリクエストは`get`で実行できます。

今回は`post`リクエストを投げ、レスポンスを取得してその内容でログインの成否を見ます。
値は連想配列にして渡します。

#### テストでsession利用
テスト内でclientをwithで用いていますが、これはsessionにアクセスするためです。

sessionはリクエストの内部でのみ有効なため、テストで使用するにはwithでclientを括って置く必要があります。
また、sessionの値をテスト側で変更するためには通常のように変更することは出来ません。

clientのsession_transactionを実行してsessionを取得し、それで変更する必要があります。

#### テスト結果の確認
`get`や`post`を実行するとレスポンスが帰ってきます。

１回目のログイン認証の際には成功する情報を送っているのでmenu.htmlがレスポンスとして帰ってくることを確認します。
Menuは私がmenu.htmlの中に書き込んだ文字なのでそれが見つかることを成功条件としてます。
また、他にはsessionのlogged_inにTrueを入れていたのでそれも確認してます。

反対に２回目のログイン認証では失敗する情報を送ってるのでレスポンスの中にNot logged inを探しています。
これは認証処理の際に失敗したらflashして画面に表示している内容なので、レスポンスの中に含まれている情報です。
また、sessionは初期化されており、logged_inも設定されていないはずなのでそれも確認しています。

これで簡単なcontrollerのテストができました。

## 次項

認証に用いているユーザ情報をベタ書きからDbを使ったものに変更します。

## 第１項からのリファクタリング点
第１項では最後の認証成功後にmenu.htmlを返そうとしていましたが存在しないのでエラーを吐いていました。
まずはそれに対応してmenu.htmlを追加します。

内容はまだ拘るところでは無いので、layout.htmlを拡張して中にMenuとだけ書いておきます。

``` menu.html
{% extends "layout.html" %}
{% block body %}
<div>
Menu
</div>
{% endblock %}
```

次に、\_\_init\_\_.pyを変更します。
元々はこの中でflaskのインスタンスを作成してアプリケーションを起動していました。
テストの際にはテスト用の起動[^1]をしたいのでインスタンス作成とアプリケーション起動を分離します。

``` __init__.py
from flask import Flask, render_template, request, flash, session, redirect, url_for


def create_app(test_config=None):
    app = Flask(__name__, instance_relative_config=True)
    app.config.from_mapping(
        SECRET_KEY='test-secret'
    )

    if test_config is None:
        app.config.from_pyfile('config.py', silent=True)
    else:
        app.config.update(test_config)

    @app.route('/', methods=['GET', 'POST'])
    def login():
        if request.method == 'POST':
            session.clear()
            if request.form['login_id'] != 'admin' and request.form['password'] != 'pass':
                flash('Not logged in')
                return render_template('login.html')
            session['logged_in'] = True
            flash('You were logged in')
            return redirect(url_for('show_menu'))
        return render_template('login.html')

    @app.route('/menu')
    def show_menu():
        return render_template('menu.html')

    return app
```

分離の際に元々の処理に加え、テスト情報を受け取り、それに基づいて構成情報を更新する用に変更してます。
さらにログイン処理の度にsessionを初期化するのと、`show_menu`も追加してログイン成功時にメニューを表示する様に変更してます。

アプリケーションを起動してた部分を削除したので、以下のファイルを作成してそれを実行することでアプリケーションを起動する様にします。
このファイルはプロジェクトのルートディレクトリに置いておけばいいかと思います。

``` runserver.py
from workscheduler import create_app

app = create_app()
app.run(debug=True)
```

今更ですが、プロジェクトはworkschedulerという名称で作ってます。

[^1]: runとtest_clientの違いについて後日記載予定。