Pythonでユニットテストを実施するに当たり、よく使われる機能を網羅してみました。
下記の悩みを持っている方にも、参考となる内容になっています。
- 例外テストしたいけど、どうやるんだっけ?
- テストのためだけに、一時フォルダを作るって面倒
- テストをするために仲間のコードが必要なんだけど、まだ仲間のコードが完成していないけど・・・
- 仮想環境でコードを作っているんだけど、デプロイ時にきちんと動作してくれるか心配
- UIのテストって大変そう。
- DBのテスト、面倒くさい・・
本記事では、pytestを使用しています。
「pytest」は、標準で使用できる「unittest」の上位互換です。
「unittest」のコードも、「pytest」から実行可能です。
目的別に、使い方をまとめました。
記事自体は長くなっているので、目次より、必要箇所だけ見て頂ければ幸いです。
pytestが初めての方は、下記記事で、インストール方法から基本的な使い方まで紹介しております。
併せて、参考にして頂ければと思います。
(本記事は網羅的に内容をご紹介しているため、上記記事と重複した内容も含まれます。)
インストール方法
pipでインストール可能です。
pip install pytest
目的別、各テスト方法のご紹介
基本的なテストコード
クラスを使った方法をご紹介します。(こちらの方が応用が効くと思います。)
add()という自作関数を作ったという前提で、この関数をテストする場合を考えてみます。
class TestAdd(object):
def test_add(self):
assert add(10, 5) == 15
# ⬆ テストしたい関数
クラス名は「Test〇〇」がお約束です。
〇〇は任意名です。
メソッド名は、「test_△△」がお約束です。△△も何でもOKです。
それぞれのテスト前後で、決まったコードを実行したい。
class TestCalculation(object):
# 各テストの開始前に実行
def setup_method(self, method):
print('テスト開始前')
# 各テストの開始前に実行
def teardown_method(self, method):
print('テスト開始後')
def test_1(self):
print('test1')
def test_2(self):
print('test2')
上記コードのsetup_method()と、teardown_method()になります。上記コードを実行すると、下記の結果が得られます。
テスト開始前
PASSED [ 50%]test1
テスト開始後
テスト開始前
PASSED [100%]test2
テスト開始後
test1とtest2の前後で、実行されている事が分かります。
テスト全体で開始前、開始後に1度だけ実行されるコードの紹介
「setup_class」が全体開始前、「teardown_class」が全体終了時に実行されます。
class TestCalculation(object):
@classmethod
def setup_class(cls):
print('開始前に実行')
@classmethod
def teardown_class(cls):
print('終了時に実行')
def test1(self):
assert add(10, 5) == 15
def test2(self):
assert add(10, -1) == 9
例外テスト
決まった例外が発生するかを検証するためのコードです。
下記コードは、「ValueError」例外が発生する事を確認するコードです。
コード中の「something.exist()」が、テストしたいメソッドになっています。
import pytest
class TestCalculation(object):
def test_extest(self):
with pytest.raises(ValueError):
something.exist()
# ⬆ テストしたいメソッド
テスト時、一時的にフォルダを作成して検証したい(fixture)
fixtureという機能を使用します。
fixutreというのは、pytestがデフォルトで持っている、様々な機能だよ。
(外部設定ファイルを読み込むとか、キャッシュが利用できる等)
具体的なコードで説明致します。
class TestCalculation(object):
def test(self, tmpdir):
print(tmpdir)
上記コ-ド中のtest(self, tmpdir)の第2引数がfixutreになります。
この第2引数の名前により、機能が変わります。
- tmpdir → 一時フォルダを作成して、そのパス情報が入っている。テスト後、消去
- cache → キャッシュを扱う事ができる。
- 他にも様々な機能有り → 公式サイトをご参照下さい。
fixtureの独自作成する事も可能です。
上記のテストコードを実行すると、下記の結果が得られます。(一時フォルダが作成されている事が分かります。)
test_calculation.py::TestCalculation::test PASSED [ 100%] /private/var/folders/sl/jqcs_sbs3hl486v4mshtk40h0000gn/T/pytest-of-*****/pytest-0/test0
あるテストをスキップしたい。
デコレーションを使用します。
class TestCalculation(object):
@pytest.mark.skip(reason='理由を書く')
def test_1(self):
print('test1')
@pytest.mark.skipif(is_skip == True, reason='理由を書く')
def test_2(self):
print('test2')
上記コードのtest1は無条件でスキップされます。
test2の@pytest.mark.skipifは、第1引数がTrueの時、skipされます。
カバレッジの確認
まずは下記をインストールします。(サードパーティ製です。)
pip install pytest-cov pytest-xdist
実行(コマンドから)
pytset test_calculation.py --cov
# ⬆実行結果(例)
#Name Stmts Miss Cover
#-----------------------------------------
#calculation.py 10 0 100%
#test_calculation.py 18 1 94%
#-----------------------------------------
#TOTAL 28 1 96%
# ミスしたときに、どこがミスしたかを詳細表示する
pytset test_calculation.py --cov --cov-report term-missing
# ⬆実行結果
#Name Stmts Miss Cover Missing
#---------------------------------------------------
#calculation.py 10 0 100%
#test_calculation.py 18 1 94% 24
#---------------------------------------------------
#TOTAL 28 1 96%
カバレッジは必ずしも100%にする必要はありません。
明らかに検証する必要がないコードがあるからです。(コンストラクタとか)
またif文等、どこの階層までテストするのか、ルールを決めるのがいいと思います。(第一階層までは少なくとも実施しているように思えます。)
setuptoolsを使った方法
事前に必要なインストール関係をまとめます。
pip install setuptools
pip install pytest-runner
(setuptoolsは、distutils.core.setupの上位互換です。)
以下の操作は、setup.pyを既に作っている事を前提とします。
PyCharmの場合、メニューの「Tools」→「Create setup.py」で簡単に作ることが可能です。
上記操作にて作成した結果、setup.pyファイルが作成され以下の中身(各設定内容は仮です。)になっていると思います。
from setuptools import setup
setup(
name='unittest_for_blog',
version='1',
packages=[''],
url='test@example.com',
license='MIT',
author='Zero__Cheese',
author_email='test',
description='something'
)
コード中のsetup関数の中に、テストフォルダを作成します。
下記フォルダのような構成が個人的にはいいと思います。t
testsの中に、「unittests」と「integrationtests」フォルダを作成し、「unittests」フォルダには、実際のコードの構成と同じようにするのがお勧めです。(ここでは、直下にファイルをおいていますが・・)
そして、setup()関数に以下を追加します。
setup(
# その他は省略
tests_require=['pytest']
)
#(参考)unittestの場合は以下の通りです。
setup(
# その他は省略
test_suits='tests'
)
次に、setupファイルのcofigファイル(setup.cfg)を作ります。(setup.pyと同じ階層に)
[aliases]
test=pytest
# ⬆ testが呼ばれたら、pytest を実行するという維持
[tool:pytest]
python_files = tests/*
# ⬆ testするファイルを指定
以上で設定は完了です。
下記コマンドにて、テストが実行できます。
python setup.py test
これで問題ないことを確認してから、以下のコマンドでパッケージを作って、それを配布するのがいいと思います。(この場合、テストファイルも一緒に配布されます。)
python setup.py sdist
仮想環境を新規作成、その中でテストをしたい(tox)
この機能は、仮想環境(virtualenv)を作ってくれて、テストを実行してくれるものになります。
下記のニーズを満たしてくれます。
- 自分のPCのPythonバージョンが異なる
- テストしたいライブラリが、自分のPCに入っていない
- 本番環境と同じ環境でテストしたい。
- 自分のPC環境を壊したくない
pipインストールが必要です。
pip install tox
No pyproject.toml or setup.py file foun
その後に、tox.iniを作ります。(setup.pyと同じ階層に作ります。)
tox.iniの内容は、下記のように記述します。
[tox]
# Python3.9を使用
envlist = py39
# python3.9に対する設定
[testenv:py39]
# virtuealenvに入れたいライブラリ
deps = pytest
# requirement.txtを読込みたい場合は
# deps = -rrequirements.txt
# 実行したいコマンド
commands = pytest -s tests
実行は下記のコマンドです。(ターミナル(Windowsの場合は、コマンドプロンプト)より)
tox
# 以下、2回目以降実行する時
# 以前作った環境を再使用する場合
tox
# 既に作った環境を使ってテストしたい場合
# py39 が作られていたとします。
tox -e py39
# 以前に作った、仮想環境を削除してから実行
tox --recreate
実行結果 virtualenvを作ってくれて、pytestを実行しているのが分かると思います。
setup() の中の、install_requires に書いたものも併せてインストールしてくれます。
seleniumでのUI自動テスト
selenimuはwebサイトの画面を自動でテストしてくれるものです。
pipインストールが必要です。
pip install selenium
ドライバのインストールが別途、必要になります。
Macの場合
brew install geckodriver
Windowsの場合
geckodriverをDLしてきて、実行ファイルと同じ階層に入れる必要があります。
DLリンク先 → こちら
例えば、私のHPで、検索画面に「電動ガン」と打込みクリック、遷移ページで「電動ガンをプログラミングして自動発射させて遊ぶ」が表示されている事を確認するテストをしてみます。(下図参照)
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
class TestMyHp(object):
def setup_method(self, method):
self.driver = webdriver.Firefox()
def teardown_method(self, method):
self.driver.close()
def test_python_org(self):
# どこのHPに行くか?
self.driver.get('https://zero-cheese.com/')
# 検索部分のセレクターを取得、文字入力後、キーボードのリターンを入力
elem1 = self.driver.find_element_by_css_selector('#s')
elem1.clear()
elem1.send_keys('電動ガン')
elem1.send_keys(Keys.RETURN)
# 遷移した画面で、該当する文字が表示されているかを確認する
# 遷移には時間がかかるので、下記コードの場合、10sec待つという設定。
# もしくは、指定したXPATHが取得できるまで待つ というコードです。
elem2 = WebDriverWait(self.driver, 10).until(
EC.presence_of_element_located(
(By.XPATH, '//*[@id="main-contents"]/section/div/div/article/a/div/div/h2'))
)
assert elem2.text == '電動ガンをプログラミングして自動発射させて遊ぶ'
呼出し先が外部 or できていない場合(Mock)
外部サイトからDLして来るなど、テストを実行したいが、相手先がまだできていない時に使用します。
例えばコード中に下記コードがあり、requests..get先のHPがない場合を想定します。
def call_api(self, month):
response = requests.get('https://zero-cheese.com/')
if response.ok:
return response.text
else:
return 'No Site'
上記コードのrequests.get部分を呼び出すと、返ってくるものを設定できるのが、mock使用の一例です。
具体的にコードを見ていきます。
import pytest
from unittest.mock import patch
from main import call_api
# ⬆ 自作コードの呼出し
class TestPatchTest(object):
def test_call_api(self):
# どこにパッチを指定するかを記述
# 下記の場合、mainモジュールのrequests.getメソッドに設定
with patch('main.requests.get') as mocked_get:
# 上記requests.getの返り値の「OK」値を設定
mocked_get.return_value.ok = True
# 上記requests.getの返り値の「text」値を設定
mocked_get.return_value.text = 'success'
# patchで設定した(この場合、request.get)の引数をチェック
# 下記で指定した引数でなければ、NGを表示
mocked_get.assert_called_with('https://zero-cheese.com/')
# 自作関数を実行した結果、戻り値テスト
assert call_api() == 'success'
# 新たに値を設定する際は、値をリセットする
mocked_get.reset_mock()
mocked_get.return_value.ok = False
mocked_get.return_value.text = 'No Site'
assert call_api() == 'No Site'
FlaskでのDBテスト
こちらの内容は、FlaskとDB、DB操作のためのORMの知識が必要になります。(本コードのみ、unittestで作っております。)
必要に応じて、読んで頂ければと思います。
Flaksの単体テストには、以下のモジュールが必要です。
pip install Flask-Testing
例えば、ログインサービス作っており、Flaskの設定で下記のようなコードがあったとします。
import os
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
# Flask-Loginライブラリとアプリケーションをつなぐ
login_manager = LoginManager()
# ログインの関数
login_manager.login_view = 'app.login'
# ログインにリダイレクトした際のメッセージ
login_manager.login_message = 'ログインしてください'
basedir = os.path.abspath(os.path.dirname(__name__))
db = SQLAlchemy()
migrate = Migrate()
def create_app():
app = Flask(__name__)
app.config['SECRET_KEY'] = '複雑な文字列が好ましい'
app.config['SQLALCHEMY_DATABASE_URI'] = \
'sqlite:///' + os.path.join(basedir, 'data.sqlite')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db.init_app(app)
migrate.init_app(app, db)
login_manager.init_app(app)
return app
次にテストコードです。
単体テストのクラス作成には、「flask_testing.TestCase」の継承が必要です。またクラス内に、create_app(self)メソッドを定義して、テストの設定を定義します。
その場合のテストが下記になります。
import os
import unittest
from flask import url_for
from flask_testing import TestCase
# ⬇ 自作モジュール
# 上記コードの読込み
from flaskr import create_app, db
# DBモデルで下記を作っていたとする。
from flaskr.models import User
app = create_app()
class TestSomethig(TestCase):
# テスト開始前に実行される。
def create_app(self):
app.config['TESTING'] = True
# ⬇CSFRのプロテクションを無効にする。(単体テストをしやすくする。)
app.config['WTF_CSRF_ENABLED'] = False
# DB SQLiteを使用する場合
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///path/test.db'
return app
# 各テスト毎に呼ばれる(開始前)
def setUp(self):
db.create_all() # テーブル全作成
# 各テスト毎に呼ばれる(終了後)
def tearDown(self):
db.session.remove() # セッション削除
db.drop_all() # テーブル全削除
def test_register(self):
# self.clientを使用すると、作ったアプリと通信ができる。(get, post等)
with self.client as client:
# DBにUser登録をして、正しく登録されているかをチェックする。
# Userクラスで、DBモデルを作ったとする。
self.assertEqual(User.query.count(), 0)
response = client.post(url_for('app.register'),
data={
'username': 'test',
'password': 'password',
}
)
# get送信の場合は、client.get(url_for('app.logout')) のように使う。
self.assertEqual(User.query.count(), 1)
self.assert_status(response, 302) # ステータスコード(redirectの場合302)
self.assert_redirects(response, url_for('app.login')) # リダイレクト先の確認
if __name__ == '__main__':
unittest.main()
上記コードの「create_app(self)」の中身を外に出す事ができます。
# 外部に設定を記述
class TestConfig:
TESTING = True
WTF_CSRF_ENABLED = False
basedir = os.path.abspath(os.path.dirname(__name__))
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'test.db')
class TestSomethig(TestCase):
# テスト開始前に実行される。
def create_app(self):
# 外部設定を読込む
# TestConfigを記述したモジュールが、test の場合
app.config.from_object('test.TestConfig')
return app
まとめ
本編では、ユニットテストの実践という事で、様々なものを紹介しました。
Pythonのテスト手法はたくさんありますが、その中でも比較的使用すると思った手法をご紹介致しました。
参考になれば幸いです。