7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【pytest】をFlask+docker開発で初めて使ってみた

Last updated at Posted at 2020-07-22

Flask + docker-composeでのWebアプリ開発に初めてpytestを使用しました。
その際のテストファイルと、設定内容をザックリまとめました。

親の記事はコチラとなります。
■Flask+Docker+Vue.js+AWS...でゲームWebAppを作ってみた。

■Githubにソースコード公開しています

テスト書くとこんなに良い事ある

今まで、テストプログラムを避けていた人生でした:scream_cat:
偶然ですが、テストを必要としないプロジェクトばかりで使う機会が無かったといえば言い訳ですが、心のなかで「あー、本当はテストとか書いといた方が良いんだろうなー」と思いつつも、重い腰が持ち上がらず。。。
そんな方は、私だけでなく多いと思います!

心機一転でテストを書こうと決心したのは、こんなメリットがあります。
テストコードを書くメリット

  • コード変更した際に、挙動確認が簡単&確実になる。
  • テストを通すことで、チーム内のエンジニア同士で、問題なく動くことのエビデンスとなる。
  • エラーがいつからどこで、おかしくなったのか、把握することが容易になる。
  • チーム内のエンジニア同士の仕様の齟齬を無くすことができる。

などメリットは多くありますが、
「そもそもテストを書いて、開発しながら同時にチェックすれば、デバッグ作業の延長でプチプチ不具合を潰しながら早く確実に開発できるのでは!!」
と思ったのが一番の理由です。(人はそれをテストドリブン開発と呼ぶ)

なんでpytest

  • 読みやすくて、書きやすい
  • プラグインが豊富

この2つが主なpytestを選んだ理由です。
特に読みやすさ、書きやすさの点にですが、誤解を恐れず言い切ってしまえば、
pytestはassert文があっているか、どうかを判定するパッケージ。
判定箇所はassertしか使いません。

assert 1 + 2 == 3

assert文がTrueであれば、PASSED(正常)、FalseであればFAILED(テスト失敗)となります。
シンプルで分かりやすい。

ディレクトリ構成

Flask+dockerのプロジェクトで使ったテストコードのディレクトリ構成を紹介します。
主要なファイル、ディレクトリ以外は割愛しています。

# 概略

.
├── introdon
│  ├── __init__.py
│  ├── models
│  ├── templates
│  └── views
├── pytest.ini
├── server.py
└── tests
  ├── __init__.py
  ├── conftest.py
  ├── func
  │  ├── __init__.py
  │  ├── test_games.py
  │  └── test_users.py
  ├── sql
  │  ├── introdon_games.sql
  │  ├── introdon_logs.sql
  │  ├── introdon_songs.sql
  │  └── introdon_users.sql
  └── unit
    ├── __init__.py
    └── test_units.py

コンテンツルートから、テストコマンドを実行するつもりで構成しています。

introdonディレクトリ servr.pyはFlaskのWebアプリファイルやディレクトリです。
テストを受ける対象になるファイルとなります。

pytest.iniと、testsディレクトリがpytestの関連ファイルです。

pytest.ini

pytestのデフォルトの振る舞いを変更できるようにするメインの構成ファイルです。
初期設定のようなもので、テスト実行するにあたり、ユーザーが細かく設定することができます。
コマンドに毎回書かなくても、テスト範囲を限定したり、オプションを追加するなどできるので便利です。

以下を設定しました。

[pytest]
addopts = --emoji -rsxX -l --strict
norecursedirs = .* *.egg sql
testpaths = tests
markers =
    use_mock: Used mock, Not SQL

addopts

コマンドオプションを追加しています。

  • --emoji: テスト結果に絵文字を追加
  • -rsxX: skipped,xfailed,xpassedになった全てのテストで理由が表示されるようになる。
  • --strict: markの厳密さ、 markersで定義していないものを使うとエラーにする

norecursedirs

テストファイルを検索しないディレクトリ名を指定しています。

testpaths

テストファイルを検索するディレクトリを限定する

pytestが、実行するテストを検索することをテストディスカバリと言います。
testpaths, norecursedirsの指定により、テストディスカバリをかなり限定することで検索時間や負荷を削減しています。

テストディスカバリは、以下の条件で自動でテストコードを検索します。

  • ファイル名がtest_*.py, *_test.py
  • テストメソッド、関数の名前が、test_*という形式
  • テストクラスの名前が、Test*という形式

つまり、設定内容と合わせると、

  1. testsディレクトリの中のみ検索し、
  2. .* *.egg sqlファイル、ディレクトリを無視し、
  3. test_, _testがついたファイルを検索します。

テスト関連のファイルはまとめてtestsディレクトリに集約させています。

markers

使用するmark名の定義と、説明を記述します。

markerとは、その名の通り、テスト実行時にマーキングすることです。
任意の名前を指定してそのマークがついたテストのみを実行する為に使います。

今回は、
CircleCI実行用に、モックのみ使用しているテストにuse_mockをマーキングしています。

それでは、testsディレクトリにある実際のテストファイルについて触れていきます:arrow_down:

__init__.py

ディレクトリにテストファイルを分けるなら、条件反射で作成しましょう。
牛丼についてくる紅生姜のように、ジャイアンについてくるスネ夫のようにです。

pythonはパッケージ管理の際、パッケージディレクトリ内のinit.pyをまず見て、そのファイルに初期設定があれば読み込み、その後パッケージ内の処理を実行します。つまり、ディレクトリ内の初期設定ファイルとなります。
init.pyが何も書いてない場合は、なんの意味もない。。。ってわけではなく、存在するだけで名前解決してくれます。

異なるディレクトリに分けられた名前が同じメソッドを、同一プログラムで呼び出しても、init.pyがあれば、ディレクトリ名(パッケージ)により名前解決されて使用することができます、コイツは助かります!

conftest.py

テストにおける共通内容を書くファイルとなります、便利で大事なファイルです。
影響範囲は保存ディレクトリと、そのサブディレクトリ全てです。

改めて、後ほどfixtureで触れます。

fixtureとは

端的に、
テストをするための、準備+後片付けです。

少し補足すると、

  • setup
    テストの準備:テスト用のデータの追加や、前提条件を調整したりすること
  • teardown
    必要に応じてテストの後処理を行う

この2つを合わせてfixtureと言います。

今回のFlask+dockerのWebアプリでは、テストで使用するデータをfixtureで用意したいので、

  1. テスト用のDBコンテナを立ち上げる
  2. DBの初期化
  3. テスト用のデータを取り込む
  4. テストを実行する
  5. 後片付けとして、取り込んだテスト用データを破棄する

このテスト前後の工程をfixtureにやらせます。

どこにfixtureを書くか

conftest.pyに書く

テストファイルで共通して使いたい場合はconftest.pyに書きます。
簡単にfixtureとして取り込むことができるので、おすすめです。

必ず全テストに共通しなくてもかまいません。
テストが10個あるけど、その内2つのテストファイルだけに適用したい場合も、conftest.pyに書いておいて取り込むかどうかを後で制御するほうが良いです。後日、追加のテストファイルにも適用したいとか、一括でfixtureを変更したいなどよくあることだと思います。

ファイルをまたいでfixtureを使う可能性があるならconftest.pyに書いておきます。

テストコードに直接

そのテストファイル内だけでしか使わないのであれば、コードの見通しや編集しやすさから、テストファイル内に直接書くのもありです。
ただし、別ファイルで使う可能性が少しでも発生したら、面倒くさがらずconftest.pyに引っ越しさせましょう。後々その方が便利で楽です。

conftest.py

テストファイルで共通するfixtureを書きます。
ローカルのプラグインとも言えますね。
適用範囲はそのディレクトリと全てのサブディレクトリで利用可能になります。

コードは以下となります。

tests/conftest.py

import os
import pytest

import introdon
from introdon.scripts.db import InitDB


@pytest.fixture(scope='session')
def client():
    # 環境変数がTESTでないなら、エラー起こして止める
    if os.environ['FLASK_ENV'] != 'TEST':
        raise

    # form.validate_on_submit()解除に必要
    introdon.app.config['WTF_CSRF_ENABLED'] = False
    introdon.app.config['TESTING'] = True

    # データ全消去
    introdon.db.drop_all()
    # Tableの初期化
    InitDB().run()

    # テストDATAのインサート
    for line in open('tests/sql/introdon_users.sql'):
        introdon.db.session.execute(line)

    for line in open('tests/sql/introdon_songs.sql'):
        introdon.db.session.execute(line)

    for line in open('tests/sql/introdon_games.sql'):
        introdon.db.session.execute(line)

    for line in open('tests/sql/introdon_logs.sql'):
        introdon.db.session.execute(line)

    with introdon.app.test_client() as client:
        with introdon.app.app_context():
            yield client

    introdon.db.session.close()

ポイント解説です。

@pytest.fixture(scope='session')
fixtureの宣言はpytest.fixture()で宣言します。
scope='session'はfixtureの有効範囲を設定します。

  • session; テストコマンドを実行してから終わるまで
  • function: デフォルト、何も書かなければコレ。テスト関数ごとに毎回実行される。
  • class: テストクラスごと1回実行される。
if os.environ['FLASK_ENV'] != 'TEST':
    raise

Dockerでflaskのアプリコンテナを起動しているのですが、環境変数FLASK_ENV=TESTの時のみpytestを実行させるようにしています。本番、開発モードで間違ってtestが実行されるのを防ぐためです。

introdon.app.config['WTF_CSRF_ENABLED'] = False
flaskアプリでのフォーム入力で使用しているパッケージflask-wtfのCSRF対策機能をオフにするためにFalseに設定します。


# データ全消去
introdon.db.drop_all()
# Tableの初期化
InitDB().run()

余計なデータがあると、テスト結果が毎回変わってしまう危険性があるので、
テスト用のDBデータを全消去、初期化してキレイなDBテーブルを用意しています。

なお、docker-composeでコンテナを立ち上げる際に、環境変数FLASK_ENV=TESTにすることで、自動で以下の内容に切り替わるようにしています。

  1. テストモードでコンテナを立ち上げる
  2. テスト用にFlaskのモードを変更
  3. テスト用のDBコンテナに接続して使用する。
  4. コンテナが立ち上がったら、テストコマンドが自動で実行され、結果とともにcoverageも表示される

# テストDATAのインサート
for line in open('tests/sql/introdon_users.sql'):
  introdon.db.session.execute(line)

初期化されたテストDBにテストデータをインサートします。
使用するデータは予め用意したsqlで、内容は以下となります。

INSERT INTO introdon_test.logs (id, user_id, game_id, question_num, judge, score, correct_song_id, select_song_id, created_at) VALUES (1, 1, 1, 1, 1, 10, 2, 2, '2020-07-01 08:19:46');

1行ごとにSQL文の羅列なので、for文で行ごとに取り出し、
SQLAlchemyのsession.executeで直接SQL命令でデータをインサートしています。


with introdon.app.test_client() as client:
  with introdon.app.app_context():
    yield client

introdon.db.session.close()

yieldまでが、setupとなり、テスト実行の準備内容となります。
yieldclientインスタンスをテストコードに投げてテストの制御をテストコードに渡します。
このclientを引数で使用したテストコードが、このfixtureを使用するテストコードとなります。
テストコードが終了したら、制御が戻ってきてyield以降の、teardown箇所を実行します。

機能テスト(FuncTest)

ファンクショナルテスト、機能テスト。1つの機能をチェックするテストです。
「記事を投稿する、ユーザーを作成するなど」、一連のひとつの機能がちゃんと動くかのテストを指します。

tests/funcディレクトリにまとめています。
機能確認なので、テストDBから取得したデータを使ってテストしています。

以下は、ポイントを抜粋したコードです。

test_users

tests/func/test_users.py
#抜粋しています

import pytest

class TestUser():

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

    def create_user(self, client, username, password):
        return client.post('/user/new', data=dict(
            username=username,
            password=password
        ), follow_redirects=True)

    invalided_users = (
        ('han', 'solo', '4文字以上10文字以内でお願いします'),
        ('DarthTyranus', 'DarthTyranus', '4文字以上10文字以内でお願いします'),
        ('Qui-Gon', 'Jinn', '半角英数のみでお願いします'),
        ('ダースモール', 'ダースモール', '半角英数のみでお願いします')
    )

    task_ids = ['{}-{}'.format(u[0], u[1]) for u in invalided_users]

    @pytest.mark.parametrize(['name', 'pw', 'comment'], invalided_users, ids=task_ids)
    def test_fail_login2(self, client, name, pw, comment):
        rv = self.login(client, name, pw)
        assert comment.encode() in rv.data

以下、ポイント解説です。

関数: login
条件を変えて頻繁にテストをする内容は、先に関数でまとめてしまってから組み込んだ方が見通しがスッキリします。
clientを引数にすることで、conftestで宣言したfixtureのyieldから制御が渡されます。
clientはflaskのテスト用インスタンスなので、clientを通してflaskアプリの挙動を確認できます。
client.postでそのページにアクセスしたhtmlページ内容を取得します。

タプル: invalided_users
テストで使用するパラメーターをタプルで事前に準備しています。
タプルで切り分けることで、複数のテストで使用できるので便利なのと、コード全体の見通しが良くなります。

task_ids = ['{}-{}'.format(u[0], u[1]) for u in invalided_users]
パラメータを使ってテストをした場合、パラメータの内容を用いてテスト結果の名前が自動で生成されます。
それはそれでいいのですが、日本語が文字化けしたり、すごく長くなったりと、分りづらい場合は、ユーザー指定することをおすすめします。

task_idsで、テスト結果名のフォーマットを定義しています。

@pytest.mark.parametrize(['name', 'pw', 'comment'], invalided_users, ids=task_ids)
    def test_fail_login2(self, client, name, pw, comment):
        rv = self.login(client, name, pw)
        assert comment.encode() in rv.data

この箇所がテスト関数となります。

@pytest.mark.parametarizeデコレーターでパラメータの取り込みを設定。
第1引数で、パラメータを順番にローカル変数名をつけています。
第2引数で、パラメータの内容
第3引数のids=task_idsでtask_idsのフォーマットをid,つまりテスト結果名のフォーマットに指定しています。

次のdef test_fail_login2がテスト関数の本体です。
test_の接頭辞でpytestがテストコードであると認識して実行します。
第2引数のclientでconftestのfixtureが使用できるようにしています。

rv.dataはflaskのテストからレスポンスされた内容で、上記の場合は、ログインの失敗ページになります。

最後で、テスト判定です。
assert comment.encode() in rv.data
それぞれのパラメータでcomment引数にした3番目のテキストをエンコードします。
エンコードした文字内容が、ログインの失敗ページに中に含まれるか否かを判定しています。

test_games

もうひとつの機能テストファイルも抜粋、ポイント解説します。

tests/func/test_games.py
#抜粋しています

import pytest

class TestGame():

  @pytest.fixture()
  def login_1111(self, client):
    return client.post('/', data=dict(
      username=1111,
      password=1111
    ), follow_redirects=True)

  #省略

  # みんなであそぶ:未指定
  @pytest.mark.usefixtures('login_1111')
  def test_start_game_multi(self, client):
    with client.session_transaction() as sess:
        sess['creatable'] = True

    rv = client.post('/game/start_multi', data=dict(
        artist='',
        genre='',
        release_from='1900',
        release_end='2100',
    ), follow_redirects=True)

    assert 'メンバー受付中・・'.encode() in rv.data

テストファイル内で、fixtureを設定しています。
  @pytest.mark.usefixtures('フィクスチャー名')
上記のデコレータを持つことで、テスト関数にfixtureを設定することができます。

テスト関数test_start_game_multiの処理は、

  1. fixtureのlogin_1111によってユーザーログイン
  2. セッション内容のsess['creatable']=Trueに設定
  3. 指定したページに任意のデータをポスト
  4. レスポンス内容をrvで受ける
  5. assertで任意のテキストが、レスポンス内容に含まれるか判定する。

上記のように、fixtureはひとつだけではなく複数設定することが可能です。

ユニットテスト(UnitTest)

関数やクラスなど、コードのごく一部をチェックするテストです。

一部のユニットテストを、ローカル環境のテスト実行だけでなく、CircleCIでテスト実行します。
そのため、テスト用のDBコンテナ利用だけではなく、モック使ってダミーデータを使ってテストします。

以下は、モック使用を抜粋したコードです。

test_units

tests/unit/test_units.py

#抜粋
import pytest

from introdon.models.songs import Song

@pytest.mark.use_mock
def test_add_song(self, mocker):
  # ダミーのレスポンス作成
  responseMock = mocker.Mock()
  responseMock.status_code = 404
  # requests.getの戻り値をpatch
  mock_res = mocker.patch('requests.get')
  mock_res.return_value = responseMock

  term = 'Shmi'
  attribute = ' Skywalker'
  limit = 9
  validate, status_code = Song.add_song(term, attribute, limit)

  assert validate == False
  assert status_code == 404

@pytest.mark.use_mock
マーカーuse_mockを利用しています。

テスト関数は以下です。
def test_add_song(self, mocker):
mockを利用してます。

mockとは仮の作り物の意味で、
pytestで端的に言えば、仮のデータを用意することです。

SQLやAPIでのデータの代わりに用意することが多いです。
pytest-mockパッケージの使用が便利なのでおすすめします。

mockを使ったテスト関数の流れです。

  1. mocker.Mock(): mockインスタンスを宣言(mockデータを入れるインスタンスを用意)
  2. mockインスタンスのアトリビュートにmock内容を入れる(ダミーデータを用意)
  3. mocker.path(requests.get)
    mockでパッチする関数を指定する(ダミーと差し替える関数を指定)
  4. mock_res.return_value = responseMock
    置き換えを指定した関数の返り値をmockインスタンスにする(データを入れ替える)

上記の工程で、mockの準備ができました。
Song.add_song()メソッド内にあるrequests.get()関数の返り値が、mockのデータに差し替えられテストが実行されます。

最初は分かりにくいですが、慣れてしまえば、テストコード書くのがすごく便利になります。

ちなみに、テストのデータとしてmockを使用するためDBは使いません。
conftestのfixtureは不要のため、clientを引数に持つ必要ないです。

pytestの実行コマンド

目的や、環境に応じてpytestコマンドは複数使い分けています。

CIでクラウド環境、ローカルでTDDしながらテスト実行

use-mockがマーカーされているテストコードのみ実行します。

pytest -m use-mock

CircleCI上で実行しています。
マーカーでmock使用のみ切り分けてのユニットテストは、ローカル環境でTDDしながら開発していくのに使い勝手よさそうです。

全体の一括テスト

flask_env.sh
pytest --cov=introdon

コンテンツルートで実行します。

docker-composeで環境変数FLASK_ENV=TESTなら、
コンテナ起動後に、shellファイルで自動で一括テスト実行をしています。

--cov=introdon
coverageの有効範囲を指定しています。
coverageとは、テスト実行がコードの何パーセントを通っているか(確認しているか)の指標です。
pytestでcoverageを使うにはpytest-covをインストールしておきます。
上記オプションは、introdonディレクトリにおいてカバレッジを表示せよの意味です。

intellij で簡単テスト実行

IntelliJだけでなく、他IDEにも似た機能があると思いますので読み替えてください。
Intelliでテスト実行を1度設定すると、その後はクリック1,2回で簡単にテスト実行することができるようになります。楽なので激推しです:point_up_tone2:

設定の方法は、別記事をご参考ください
■Flask Webアプリのサクッと作れる『docker-compose構成』をまとめてみた

テスト実行は、簡単です。実行したいテストファイルを選択したら、実行ボタンを押すだけです。
スクリーンショット 2020-07-21 22.23.55.png

テスト結果はサイドバーで一覧表示されます。
スクリーンショット 2020-07-21 22.26.35.png
失敗したテストは何が原因でどこで失敗したのか。
失敗したテストファイルのリンクが表示されクリックだけで該当箇所にアクセスできます。
失敗したテストだけで再度テスト実行もでき、指定した時間間隔で自動テストを繰り返し実行することもできます。
過去のテスト結果内容も閲覧することも可能です。

さらに、
テストコードでもデバッグ可能です。
実行ボタン横の虫(バグ)ボタンのクリックでデバッグ実行です。
通常のコードと同様、テストコード自体もデバッグモードでコーディングできます。

さらに、さらに、
IntelliJの、coverage機能が見やすくて秀逸です。
バグボタンの更に右「盾ボタン」で実行できます。
スクリーンショット 2020-07-21 22.43.30.png
リストにして各ファイルや、ディレクトリが何パーセントか一覧表示してくれて、クリックで該当のファイルを開くこともできます。

スクリーンショット 2020-07-21 22.44.29.png
コードファイルは、どの箇所がテストが走って確認されていて、どこが未確認なのか色分け表示してくれます。

わかりやすい:eye:慣れてしまったらIntelliJ抜きでcoverageする気が無くなりそうです。

おわりに

正直言いますと、プロジェクトが9割以上完成した段階でテストコードを導入しました。
そのため、全然テスト書けてない。カバー率かなり低いです:skull:

テストコードの真価が発揮されるのは、やはりTDD(テストドリブン開発)だと思います。

  1. まずは仕様をテストコードに落とし込み、
  2. テストコードをクリアしながら、
  3. プロジェクトを進行させる。
  4. 更に新たに必要となったテストコードを追加しつつ、
  5. またテストコードをクリアしつつプロジェクトを進行させる。

TDDは大きなメリットがあると思います。

  • プロジェクトの仕様をテストコードで明確にする。
  • プライオリティを意識しながら開発する。
  • プロジェクトコードの質をテストで随時チェックして、品質を保証して次の実装へと進む。

テスト無しだと、いつ・どこでおかしくなったのか特定が困難になったり、仕様から外れた機能を見当外れの優先度で作ってしまう危険性があります。身に覚えあります。。。

テストコードはプロジェクトのチェック機能として初期から導入するが良いです。
最初から、ガッツリと細かくテストコードを実装するよりも、
使うタイミングに応じて少しずつ慣れていき、増やしていけばいいと思います:raised_hands_tone4:

7
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?