Pythonのテストフレームワークpytestを使ってみよう
https://gyazo.com/803d9f441c601b51e0d2ce3f9d4d83c0
pytestについて
Pythonを使ってテストコードを書くことができるオープンソースのテストフレームワークです。シンプルな単体テストから複雑な機能テストまで、様々なテストを書くことができます。
pytest には次のような特徴があります。
unittest テストケースの実行をサポート
組み込みの assert 文をサポート。特別なアサーション・メソッド(self.assert*())は不要
テストケースのフィルタリングをサポート
最後に失敗したテストから再実行する機能
並行でのテスト実行をサポート
機能を拡張するための何百ものプラグインからなるエコシステム
オープンソース
pytest は Python 標準の unittest モジュールに代わるボイラープレート不要のモジュールで、unittest テストケースをそのまま実行することができます。pytest はフル機能を備えた拡張可能なテストツールであるにもかかわらず、シンプルな構文テストスイートを作成作成することができ、いくつかの関数を持つモジュールを書くのと同じくらい簡単です。
ボイラープレート(boilerplate):
プログラミング言語での意味は、仕様上省略不能で、 かつほとんど変更を加えることなく、
多くの場所に組み込む必要があるソースコードのことを言います。
余談
2004年、Holger Krekel氏は彼が開発したパッケージ std の名前を、Pythonに同梱されている標準ライブラリの名前とよく混同されていたため、「py」という名前に変更しました。このパッケージにはいくつかのサブパッケージが含まれていて、その中のひとつが 「py.test」でした。現在ではほぼ完全に 「pytest」として知られています。
表記としては、「PyTest」も使用されることもあります。
pytest は Python でのテストフレームワークの新しい標準を確立し、今日では多くの開発者に人気があります。
インストール
pytest のインストールは pip コマンドで行います。
code: bash
$ pip install pytest
インストールが成功すると pytest コマンドを実行できるようになります。まず、次のように確かめてみましょう。
code: bash
% pytest -h
positional arguments:
file_or_dir
(以下略)
オプション -h はヘルプメッセージを表示するものです。
使用方法
pytest の アサート・イントロスペクション(Assert Introspection) は、 assert 式の中間値をインテリジェントに報告してくれるので、unittest で数多くあるアサーション・メソッドの名前と用法を覚える必要がありません。
code: test_01_sample.py
def func(x):
return x + 1
def test_answer():
assert func(3) == 5
これを実行すると、次のような出力になります。
code: bash
pytest test_01_sample.py
============================== test session starts ==============================
platform darwin -- Python 3.9.7, pytest-6.2.5, py-1.10.0, pluggy-1.0.0
rootdir: /Users/goichiiisaka/Projects/Python.Osaka/Tutorial.Testing/03_Pytest
plugins: cov-2.12.1, typeguard-2.12.1
collected 1 item
=================================== FAILURES ====================================
__________________________________ test_answer __________________________________
def test_answer():
assert func(3) == 5
E assert 4 == 5
E + where 4 = func(3)
test_01_sample.py:5: AssertionError
============================ short test summary info ============================
コンソール画面には。次のようにカラー表示されてわかりやすくなっています。
https://gyazo.com/6d46d088417d7afe9ac3722bd66cf86b
複数のテストを1つのクラスにまとめる
テストの数が増えてくると、 テストを論理的にまとめてクラスやモジュールにすることが必要になってきます。2 つのテストを含むクラスを書いてみましょう。
code: s02_group.py
class TestClass:
def test_one(self):
x = "Hello"
assert 'H' in x
def test_two(self):
x = "Python"
assert hasattr(x, 'size')
pytest を実行する代わりに、Python のオプション指定 -m pytest を使って実行することもできます。
code: bash
% python -m pytest test_02_group.py
============================== test session starts ==============================
platform darwin -- Python 3.9.7, pytest-6.2.5, py-1.10.0, pluggy-1.0.0
rootdir: /Users/goichiiisaka/Projects/Python.Osaka/Tutorial.Testing/03_Pytest
plugins: cov-2.12.1, typeguard-2.12.1
collected 2 items
=================================== FAILURES ====================================
______________________________ TestClass.test_two _______________________________
self = <test_02_group.TestClass object at 0x10c140f40>
def test_two(self):
x = "Python"
assert hasattr(x, 'size')
E AssertionError: assert False
E + where False = hasattr('Python', 'size')
test_02_group.py:9: AssertionError
============================ short test summary info ============================
FAILED test_02_group.py::TestClass::test_two - AssertionError: assert False
========================== 1 failed, 1 passed in 0.19s ==========================
ここで、test_02_group.py .Fと表示されています。このドット(.)はテストに成功したことを、Fはテストに失敗(Failure) ことを表しています。失敗のセクション(FAILURES)では、失敗したメソッドと失敗した行を見ることが
pytestのアサーション
pytestのアサーションは、与えた式をチェックして、True または False のどちらかのステータスを返します。pytest は、テストメソッドでアサーションが失敗すると、そのメソッドの実行はそこで停止し、そのテストメソッドの残りのコードは実行されません。pytest は次のテストメソッドに進みます。
pytestのテストファイルとテストメソッドの識別方法
デフォルトでは、pytest はファイル名がtest_*.py もしくは、*_test.py のglobパターンにマッチするファイルをテストファイルとして識別します。
test_01.py - OK
01_tesst.py - OK
test_01 - NG
test01.py - NG
01test.py - NG
01_test - NG
pytest にファイル名を引数として与えた場合は、ファイル命名規則は*.pyが適用されます。
pytest のコマンドラインで引数を省略すると、カレントディレクトリで認識したテストファイルをすべて実行しようとします。
pytest では、テストメソッドの名前は、testで始まる必要があります。それ以外のメソッド名は、たとえ明示的に実行を指示したとしても無視されます。
def test_func(): - OK
def testfunc(): - OK
def func_test(): - NG
def functest(): - NG
pytestでテストのサブセットを実行する
場合によっては、テストスイート全体を実行したくないことがあります。pytestでは特定のテストだけを実行することができます。これには2つの方法があります。
部分一致によるテスト名のグループ化
マーカーを使ったテストをグループ化
部分一致したテストを実行
カレントディレクトリ以下のテストファイルの、名前にsum1 を含むすべてのテストを実行するには、-kオプションを使用します。
-k EXPRESSION
指定された部分文字列に一致するテストのみを実行します。
テスト名とその親クラスにマッチするテストのみを実行します。
例:
-k 'test_other' は、テスト名に 'test_other' が含まれるテストにマッチし
-k 'not test_method' はテスト名に 'test_method' を含まないものにマッチします。
確認のために、次の2つのファイルを追加作成します。簡単にするために、2つのファイルの違いは、関数の名前が違っているだけです。
code: test_03_sum.py
def test_group1_sum1():
assert sum(1,2,3) == 6, "Expecting 6" def test_group1_sum2():
assert sum(0,1,2) == 6, "Expecting 6" def test_group1_sum3():
assert sum((1,2,3)) == 6, "Expecting 6"
code: test_04_sum.py
def test_group2_sum1():
assert sum(1,2,3) == 6, "Expecting 6" def test_group2_sum2():
assert sum(0,1,2) == 6, "Expecting 6" def test_group2_sum3():
assert sum((1,2,3)) == 6, "Expecting 6"
文字列sum1を含むテストを実行してみましょう。
code: bash
% pytest -k sum1 -v
============================== test session starts ==============================
platform darwin -- Python 3.9.7, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 -- /Users/goichiiisaka/anaconda3/envs/tutorials/bin/python3.9
cachedir: .pytest_cache
rootdir: /Users/goichiiisaka/Projects/Python.Osaka/Tutorial.Testing/03_Pytest
plugins: cov-2.12.1, typeguard-2.12.1
collected 17 items / 15 deselected / 2 selected
test_03_sum.py::test_group1_sum1 PASSED 50% test_04_sum.py::test_group2_sum1 PASSED 100% ======================= 2 passed, 15 deselected in 0.20s ========================
カレントディレクトリ以下にあるテストファイルが対象となることに注意してください。
マーカーを使ったテストを実行
pytest では、@pytest.mark デコレータで、テストメソッドに異なるマーカー名を適用し、マーカー名に基づいて特定のテストを実行することができます。テストファイルで@pytest.markを使用するには、テストファイルで pytest をインポートする必要があります。
組み込みのマーカーのいくつかを紹介します。
usefixtures: テスト関数やクラスにフィクスチャを使用する。
filterwarnings: テスト関数の特定の警告をフィルタリングします。
skip: 常にテスト関数をスキップします。
skipif: 特定の条件が満たされた場合、テスト関数をスキップします。
xfail: 特定の条件が満たされた場合に「期待される失敗」の結果を生成する
parametrize: 同じテスト関数を複数回呼び出す。
独自のマーカーを作成したり、テストクラスやモジュール全体にマーカーを適用したりすることは簡単です。
pytest でカスタムマーカーを使用する場合は、事前に登録しておく必要があります。
マークの登録
カスタムマークの登録は、pytest.iniファイルで以下のように行います。
code: pytest.ini
markers =
sum01: marks tests as sum01 (deselect with '-m "not slow"')
sum02
sum03
どんなマーカーが登録されているかは、--markersオプションで知ることができます。
code: bash
$ pytest --markers | grep sum
@pytest.mark.sum01: marks tests as sum01 (deselect with '-m "not slow"')
@pytest.mark.sum02:
@pytest.mark.sum03:
マーク名の後のコロン記号(:)以降は、オプションの説明文であることに注意してください。
プロジェクトの pyproject.tomlに登録する場合は、次のようにします。
code: pyproject.toml
markers = [
"sum01: marks tests as sum01 (deselect with '-m "not slow"')",
"sum02",
"sum03",
]
また、pytest_configure フックでプログラム的に新しいマーカーを登録することもできます。
code: pytohn
def pytest_configure(config):
config.addinivalue_line(
"markers", "sum01: marks tests as sum01 (deselect with '-m "not slow"')"
)
config.addinivalue_line("markers", "sum02")
config.addinivalue_line("markers", "sum03")
各テスト名にマーカーを定義するには、次のようにします。
code: test_05_sum_mark.py
import pytest
@pytest.mark.sum01
def test_group1_sum01():
assert sum(1,2,3) == 6, "Expecting 6" @pytest.mark.sum02
def test_group1_sum02():
assert sum(0,1,2) == 6, "Expecting 6" @pytest.mark.sum03
def test_group1_sum03():
assert sum((1,2,3)) == 6, "Expecting 6"
code: test_06_sum_mark.py
import pytest
@pytest.mark.sum01
def test_group2_sum01():
assert sum(1,2,3) == 6, "Expecting 6" @pytest.mark.sum02
def test_group2_sum02():
assert sum(0,1,2) == 6, "Expecting 6" @pytest.mark.sum03
def test_group2_sum03():
assert sum((1,2,3)) == 6, "Expecting 6"
マークされたテストを実行するためには、-mオプションを使用します。
-m MARKEXPR
指定されたマーク式に一致するテストを実行します。
例: -m 'mark1 and not mark2'.
code: bash
$ pytest -m sum03 -v
============================== test session starts ==============================
platform darwin -- Python 3.9.7, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 -- /Users/goichiiisaka/anaconda3/envs/tutorials/bin/python3.9
cachedir: .pytest_cache
rootdir: /Users/goichiiisaka/Projects/Python.Osaka/Tutorial.Testing/03_Pytest, configfile: pytest.ini
plugins: cov-2.12.1, typeguard-2.12.1
collected 23 items / 21 deselected / 2 selected
test_05_sum_mark.py::test_group1_sum03 PASSED 50% test_06_sum_mark.py::test_group2_sum03 PASSED 100% ======================= 2 passed, 21 deselected in 0.19s ========================
pytest のフィクスチャ
フィクスチャ(Fixture) は、すべてのテストメソッドの前に何らかのコードを実行したいときに使用します。そのため、毎回のテストで同じコードを繰り返すのではなく、フィクスチャを定義します。通常、フィクスチャはデータベース接続を初期化したり、ベースを渡したりするのに使われます。
メソッドに pytest フィクスチャのマークをつけるためには、テストメソッドを次のようにアノテーションします。
code: python
@pytest.fixture
def test_some_test():
...
code: test_10_fixture.py
import pytest
@pytest.fixture
def input_value1():
@pytest.fixture
def input_value2():
@pytest.fixture
def input_value3():
return (1, 2, 3)
def test_demo_sum1(input_value1):
assert sum(input_value1) == 6, "Expecting 6"
def test_demo_sum2(input_value2):
assert sum(input_value2) == 6, "Expecting 6"
def test_demo_sum3(input_value3):
assert sum(input_value3) == 6, "Expecting 6"
この例では、input_value1() 、input_value2()、input_value3() という名前の3つフィクスチャ関数を用意しています。これらの関数は、テストの入力となります。フィクスチャ関数にアクセスするには、テスト側でフィクスチャ名を入力パラメータとして指定する必要があります。
テストが実行されている間、pytest は入力パラメータとしてフィクスチャ名を確認します。そして、フィクスチャ関数を実行し、返された値が入力パラメータに格納され、テストで使用できるようになります。
code: bash
% pytest -v test_10_fixture.py
============================== test session starts ==============================
platform darwin -- Python 3.9.7, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 -- /Users/goichiiisaka/anaconda3/envs/tutorials/bin/python3.9
cachedir: .pytest_cache
rootdir: /Users/goichiiisaka/Projects/Python.Osaka/Tutorial.Testing/03_Pytest, configfile: pytest.ini
plugins: cov-2.12.1, typeguard-2.12.1, xdist-2.4.0, forked-1.3.0
collected 3 items
test_10_fixture.py::test_demo_sum1 PASSED 33% test_10_fixture.py::test_demo_sum2 FAILED 66% test_10_fixture.py::test_demo_sum3 PASSED 100% =================================== FAILURES ====================================
________________________________ test_demo_sum2 _________________________________
def test_demo_sum2(input_value2):
assert sum(input_value2) == 6, "Expecting 6"
E AssertionError: Expecting 6
E assert 3 == 6
E +3
E -6
test_10_fixture.py:18: AssertionError
============================ short test summary info ============================
FAILED test_10_fixture.py::test_demo_sum2 - AssertionError: Expecting 6
========================== 1 failed, 2 passed in 0.24s ==========================
複数のテストファイルから同じフィクスチャを利用したいときがあります。こうしたときは、conftest.py にフィクスチャをまとめておきます。
code: conftesst.py
import pytest
@pytest.fixture
def input_value1():
@pytest.fixture
def input_value2():
@pytest.fixture
def input_value3():
return (1, 2, 3)
それぞれのテストファイルは次のようにシンプルになります。
code: test_11_share_fixture.py
def test_group1_sum1(input_value1):
assert sum(input_value1) == 6, "Expecting 6"
def test_group1_sum2(input_value2):
assert sum(input_value2) == 6, "Expecting 6"
def test_group1_sum3(input_value3):
assert sum(input_value3) == 6, "Expecting 6"
code:test_12_share_fixture.py
def test_group2_sum1(input_value1):
assert sum(input_value1) == 6, "Expecting 6"
def test_group2_sum2(input_value2):
assert sum(input_value2) == 6, "Expecting 6"
def test_group2_sum3(input_value3):
assert sum(input_value3) == 6, "Expecting 6"
code: bash
$ pytest -v -k sum3 *share_fixture.py
============================== test session starts ==============================
platform darwin -- Python 3.9.7, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 -- /Users/goichiiisaka/anaconda3/envs/tutorials/bin/python3.9
cachedir: .pytest_cache
rootdir: /Users/goichiiisaka/Projects/Python.Osaka/Tutorial.Testing/03_Pytest, configfile: pytest.ini
plugins: cov-2.12.1, typeguard-2.12.1, xdist-2.4.0, forked-1.3.0
collected 6 items / 4 deselected / 2 selected
test_11_share_fixture.py::test_group1_sum3 PASSED 50% test_12_share_fixture.py::test_group2_sum3 PASSED 100% ======================== 2 passed, 4 deselected in 0.04s ========================
pytestは、まず実行しようとしているテストファイルの中でフィクスチャを探します。そこに、フィクスチャが見つからない場合は、 conftest.py ファイル内のフィクスチャを探します。fixture が見つかると、 fixture メソッドが呼び出され、その結果がテストの 引数に返されます。
パラメタライズ・テスト
parametrizeは、はpytestで使えるデコレータの1つです。parametrize を使用すると、変数と結果をパラメータとしてテストコードに渡すことで、1つのテストメソッドで複数パターンのテストを行うことができるようになります。
code: test_20_parametrize.py
import pytest
def fibonacci(n):
a, b = 1, 0
for _ in range(n+1):
a, b = b, a + b
return b
test_data = [
( 8, 34),
( 9, 55),
(10, 89),
(11, 144),
]
@pytest.mark.parametrize("n, expect", test_data)
def test_fibonacci(n, expect):
result = fibonacci(n)
assert result == expect
code: bash
% pytest -v test_20_parametrize.py
============================== test session starts ==============================
platform darwin -- Python 3.9.7, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 -- /Users/goichiiisaka/anaconda3/envs/tutorials/bin/python3.9
cachedir: .pytest_cache
rootdir: /Users/goichiiisaka/Projects/Python.Osaka/Tutorial.Testing/03_Pytest, configfile: pytest.ini
plugins: cov-2.12.1, typeguard-2.12.1, xdist-2.4.0, forked-1.3.0
collected 4 items
test_20_parametrize.py::test_fibonacci8-34 PASSED 25% test_20_parametrize.py::test_fibonacci9-55 PASSED 50% test_20_parametrize.py::test_fibonacci10-89 PASSED 75% test_20_parametrize.py::test_fibonacci11-144 PASSED 100% =============================== 4 passed in 0.05s ===============================
複数のパラメータ化された引数のすべての組み合わせを取得するには、parametrize デコレータを重ねることができます。
code: test_21_parametrize.py
import pytest
@pytest.mark.parametrize('a', 5, 10) @pytest.mark.parametrize('b', 6, 12) @pytest.mark.parametrize('c', 7, 14) def test_sum(a, b, c):
pass
この動作を確認してみましょう。
code: bash
$ pytest -v test_21_parametrize.py
============================== test session starts ==============================
platform darwin -- Python 3.9.7, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 -- /Users/goichiiisaka/anaconda3/envs/tutorials/bin/python3.9
cachedir: .pytest_cache
rootdir: /Users/goichiiisaka/Projects/Python.Osaka/Tutorial.Testing/03_Pytest, configfile: pytest.ini
plugins: cov-2.12.1, typeguard-2.12.1, xdist-2.4.0, forked-1.3.0
collected 8 items
test_21_parametrize.py::test_sum7-6-5 PASSED 12% test_21_parametrize.py::test_sum7-6-10 PASSED 25% test_21_parametrize.py::test_sum7-12-5 PASSED 37% test_21_parametrize.py::test_sum7-12-10 PASSED 50% test_21_parametrize.py::test_sum14-6-5 PASSED 62% test_21_parametrize.py::test_sum14-6-10 PASSED 75% test_21_parametrize.py::test_sum14-12-5 PASSED 87% =============================== 8 passed in 0.07s ===============================
Skip テストと Xfail テスト
テストを行うときに、テストをスキップしたり、テストの失敗を無視したいときがあります。
通常は、次ののような状況において、こうした要望が強くなります。
あるテストが、何らかの理由でしばらくの間、関連性がない。
ある機能がまだ未実装で、その機能のためのテストに意味がない。
新しい機能が実装されていて、その機能のために既にテスト済み。
pytest では、 xfailed テストは実行はしますが、そのテストは失敗したテストや合格したテストの一部とはみなされません。(unittest の @expectedfaulureを思い出してください)。テストが失敗しても、これらのテストの詳細は表示されません (pytest は通常、失敗したテストの詳細を表示します)。次のようなマーカーを使用してテストを xfail することができます。
code: python
@pytest.mark.xfail
テストをスキップさせたいときは、次のようにマークします。
code: python
@pytest.mark.skip
テストが適切なものになったとき、マーカーを削除することができます。
code: test_25_skip_xfail.py
import pytest
@pytest.mark.sum01
def test_group1_sum01():
assert sum(1,2,3) == 6, "Expecting 6" @pytest.mark.xfail
@pytest.mark.sum02
def test_group1_sum02():
assert sum(0,1,2) == 6, "Expecting 6" @pytest.mark.skip
@pytest.mark.sum03
def test_group1_sum03():
assert sum((1,2,3)) == 6, "Expecting 6"
code: bash
% pytest -v test_25_skip_xfail.py
============================== test session starts ==============================
platform darwin -- Python 3.9.7, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 -- /Users/goichiiisaka/anaconda3/envs/tutorials/bin/python3.9
cachedir: .pytest_cache
rootdir: /Users/goichiiisaka/Projects/Python.Osaka/Tutorial.Testing/03_Pytest, configfile: pytest.ini
plugins: cov-2.12.1, typeguard-2.12.1, xdist-2.4.0, forked-1.3.0
collected 3 items
test_25_skip_xfail.py::test_group1_sum01 PASSED 33% test_25_skip_xfail.py::test_group1_sum02 XFAIL 66% test_25_skip_xfail.py::test_group1_sum03 SKIPPED (unconditional skip) 100% ==================== 1 passed, 1 skipped, 1 xfailed in 0.15s ====================
N個のテストが失敗したらテストスイートを停止させる
デフォルトでは、pytest では複数のテストが失敗してもテストスイートは継続して実行されます。失敗したテストの個数が多いと問題の対象がぼやけてしまうことがあります。
pytest では、maxfailを使うと、指定した個数のテストが失敗するとテストスイートの実行を停止することができます。
2個のテストが失敗したときにテストスイートを指定させる方法は、次の通りです。
code: bash
$ pytest --maxfail=2 テストファイル
次のテストファイルで確認してみましょう。このテストファイルには4つのテストがあり、うち2つは(意図的に)失敗します。
code: test_26_maxfail.py
import pytest
def fibonacci(n):
a, b = 1, 0
for _ in range(n+1):
a, b = b, a + b
return b
test_data = [
( 8, 34),
( 9, 56), # 正解: 55
(10, 90), # 正解: 89
(11, 144),
]
@pytest.mark.parametrize("n, expect", test_data)
def test_fibonacci(n, expect):
result = fibonacci(n)
assert result == expect
ひとつのテストが失敗したときにテストスイートを停止させてみましょう。
code: bash
% pytest -v --maxfail=1 test_26_maxfail.py
============================== test session starts ==============================
platform darwin -- Python 3.9.7, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 -- /Users/goichiiisaka/anaconda3/envs/tutorials/bin/python3.9
cachedir: .pytest_cache
rootdir: /Users/goichiiisaka/Projects/Python.Osaka/Tutorial.Testing/03_Pytest, configfile: pytest.ini
plugins: cov-2.12.1, typeguard-2.12.1, xdist-2.4.0, forked-1.3.0
collected 4 items
test_26_maxfail.py::test_fibonacci8-34 PASSED 25% test_26_maxfail.py::test_fibonacci9-56 FAILED 50% =================================== FAILURES ====================================
_____________________________ test_fibonacci9-56 ______________________________ n = 9, expect = 56
@pytest.mark.parametrize("n, expect", test_data)
def test_fibonacci(n, expect):
result = fibonacci(n)
assert result == expect
E assert 55 == 56
E +55
E -56
test_26_maxfail.py:19: AssertionError
============================ short test summary info ============================
FAILED test_26_maxfail.py::test_fibonacci9-56 - assert 55 == 56 !!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
========================== 1 failed, 1 passed in 0.23s ==========================
ロギングとの連携
pytest は、レベル WARNING 以上のログメッセージを自動的にキャプチャし、キャプチャされた標準出力や標準エラーと同じように、失敗したテストごとに独自のセクションに表示します。
pytest はライブロギングをサポートしていて、テストで発行されたすべてのログレコードをターミナルに出力させることができます。ライブロギングはデフォルトでは無効になっています。有効にするには、pyproject.toml または pytest.ini の設定で log_cli = 1 を設定します。ライブロギングはターミナルとファイルへの出力をサポートしており、関連するオプションでレコードをカスタマイズすることができます。
端末/コンソール:
log_cli_level / --log-cli-level=LOG_CLI_LEVEL:端末でのロギングレベルの指定
log_cli_format / --log-cli-format=LOG_CLI_FORMAT:logging で使用されるログフォーマット
log_cli_date_format / --log-cli-date-format=LOG_CLI_DATE_FORMAT:logging で使用されるログの日時フォーマット
ファイル :
log_file / --log-file=LOG_FILE:ログ出力するファイルパス
log_file_level / --log-file-level=LOG_FILE_LEVEL:ログファイルでのログレベル
log_file_format/ --log-file-format=LOG_FILE_FORMAT:loggingで使用されるログフォーマット
log_file_date_format / --log-file-date-format=LOG_CLI_DATE_FORMAT:logging で使用されるログの日時フォーマット
code: test_30_logging.py
import logging
LOGGER = logging.getLogger(__name__)
def test_loglevel():
LOGGER.info('LOGLevel info')
LOGGER.warning('LOGLevel warning')
LOGGER.error('LOGLevel error')
LOGGER.critical('LOGLevel critical')
assert True
ログ関連のオプションを何も与えないと、これまでと同様に次の出力となります。
code: bash
$ pytest -v test_30_logging.py
============================== test session starts ==============================
platform darwin -- Python 3.9.7, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 -- /Users/goichiiisaka/anaconda3/envs/tutorials/bin/python3.9
cachedir: .pytest_cache
rootdir: /Users/goichiiisaka/Projects/Python.Osaka/Tutorial.Testing/03_Pytest, configfile: pytest.ini
plugins: cov-2.12.1, typeguard-2.12.1, xdist-2.4.0, forked-1.3.0
collected 1 item
test_30_logging.py::test_loglevel PASSED 100% =============================== 1 passed in 0.04s ===============================
ログレベルをDEBUGにして、実行してみます。
code: bash
$ pytest -v test_30_logging.py --log-cli-level=DEBUG
============================== test session starts ==============================
platform darwin -- Python 3.9.7, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 -- /Users/goichiiisaka/anaconda3/envs/tutorials/bin/python3.9
cachedir: .pytest_cache
rootdir: /Users/goichiiisaka/Projects/Python.Osaka/Tutorial.Testing/03_Pytest, configfile: pytest.ini
plugins: cov-2.12.1, typeguard-2.12.1, xdist-2.4.0, forked-1.3.0
collected 1 item
test_30_logging.py::test_loglevel
--------------------------------- live log call ---------------------------------
INFO test_30_logging:test_30_logging.py:6 LOGLevel info
WARNING test_30_logging:test_30_logging.py:7 LOGLevel warning
ERROR test_30_logging:test_30_logging.py:8 LOGLevel error
CRITICAL test_30_logging:test_30_logging.py:9 LOGLevel critical
=============================== 1 passed in 0.03s ===============================
ログ出力を取り込んで評価する
pytest にデフォルトで用意されている fixture の caplog を使用すると、logging で出力される内容を取り込んで(Caputure)くれるます。 まず、使用例をみてみましょう。
code: test_31_caplog.py
import logging
LOGGER = logging.getLogger(__name__)
def myfunc():
LOGGER.info('LOGLevel info')
LOGGER.warning('LOGLevel warning')
LOGGER.error('LOGLevel error')
LOGGER.critical('LOGLevel critical')
class TestCapLog:
def test_caplog1(self, caplog):
myfunc()
assert (__name__, logging.INFO, "LOGLevel info") \
not in caplog.record_tuples, \
"ログレベルはWARNINGがデフォルトなので、INFOは出力されない"
assert (__name__, logging.WARNING, "LOGLevel warning") \
in caplog.record_tuples
assert (__name__, logging.ERROR, "LOGLevel error") \
in caplog.record_tuples
caplog.set_level(logging.INFO)
myfunc()
assert (__name__, logging.INFO, "LOGLevel info") \
in caplog.record_tuples, \
"ログレベルをINFOにしたので、このログは出力される"
def test_caplog2(self, caplog):
# test_caplog1 で設定したログレベルはテスト終了時にリセットされている
myfunc()
assert (__name__, logging.INFO, "LOGLevel info") \
not in caplog.record_tuples, \
"ログレベルはWARNINGがデフォルトなので、INFOは出力されない"
assert (__name__, logging.WARNING, "LOGLevel warning") \
in caplog.record_tuples
assert (__name__, logging.ERROR, "LOGLevel error") \
in caplog.record_tuples
with caplog.at_level(logging.INFO):
myfunc()
assert (__name__, logging.INFO, "LOGLevel info") \
in caplog.record_tuples, \
"ログレベルをINFOにしたので、このログは出力される"
このテストはすべて成功するはずです。
code: bash
% pytest -v test_31_caplog.py
============================== test session starts ==============================
platform darwin -- Python 3.9.7, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 -- /Users/goichiiisaka/anaconda3/envs/tutorials/bin/python3.9
cachedir: .pytest_cache
rootdir: /Users/goichiiisaka/Projects/Python.Osaka/Tutorial.Testing/03_Pytest, configfile: pytest.ini
plugins: cov-2.12.1, typeguard-2.12.1, xdist-2.4.0, forked-1.3.0
collected 2 items
test_31_caplog.py::TestCapLog::test_caplog1 PASSED 50% test_31_caplog.py::TestCapLog::test_caplog2 PASSED 100% =============================== 2 passed in 0.06s ===============================
caplog.record_tuples には、出力されたログが (logger_name, log_level, log_text) のタプルのリストとして格納されています。
テスト内部でcaplog.set_level()を使って、キャプチャしたログメッセージのログレベルを変更することができます。設定されたログレベルは、テストの終了時に自動的に復元されます。
また、コンテクストマネージャーを使って、withブロック内で一時的にログレベルを変更することも可能です。この場合は、caplog.at_level()を使用します。
unittestでのテストファイルの実行
pytest は unittest のテストファイルを実行することができます。
code: test_90_unittest.py
import unittest
import requests
class DemoTest(unittest.TestCase):
status = 200
def setUp(self):
@unittest.skip('無条件にスキップ')
def test_request1(self):
r1 = requests.get(self.url)
@unittest.skipIf(status > 200, 'status が 200より大きい時はスキップ')
def test_request2(self):
# アサーションの結果が真であれば続行し、
# そうでなければテストをスキップします。
r2 = requests.get(self.url)
status2 = r2.status_code
self.assertTrue(status2 > self.status)
@unittest.skipUnless(status == 404, 'status が 404 でなければスキップ')
def test_request3(self):
# 結果が真でない限り、このテストをスキップします。
r3 = requests.get(self.url)
status3 = r3.status_code
self.assertTrue(status3 > self.status)
@unittest.expectedFailure
def test_request4(self):
# テストケースは "expected failed"(予想される失敗)と表示されます。
# テストが実行された場合、テスト結果は失敗ではないと判断します。
r4 = requests.get(self.url+'/posts%2F4331797390207884')
status4 = r4.status_code
self.assertTrue(status4 ==self.status)
def tearDown(self):
pass
if __name__ == '__main__':
unittest.main()
このテストファイルは4つのテストがあります。
test_request1:無条件にスキップされる
test_request2:失敗する
test_request3:条件スキップ
test_request4:期待される失敗
このテストファイルをそのまま pytest で実行してみましょう。
code: bash
% pytest -v test_90_unittest.py
============================== test session starts ==============================
platform darwin -- Python 3.9.7, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 -- /Users/goichiiisaka/anaconda3/envs/tutorials/bin/python3.9
cachedir: .pytest_cache
rootdir: /Users/goichiiisaka/Projects/Python.Osaka/Tutorial.Testing/03_Pytest, configfile: pytest.ini
plugins: cov-2.12.1, typeguard-2.12.1, xdist-2.4.0, forked-1.3.0
collected 4 items
test_90_unittest.py::DemoTest::test_request1 SKIPPED (無条件にスキップ) 25% test_90_unittest.py::DemoTest::test_request2 FAILED 50% test_90_unittest.py::DemoTest::test_request3 SKIPPED (status が 404 ...) 75% test_90_unittest.py::DemoTest::test_request4 XFAIL 100% =================================== FAILURES ====================================
____________________________ DemoTest.test_request2 _____________________________
self = <test_90_unittest.DemoTest testMethod=test_request2>
@unittest.skipIf(status > 200, 'status が 200より大きい時はスキップ')
def test_request2(self):
# アサーションの結果が真であれば続行し、
# そうでなければテストをスキップします。
r2 = requests.get(self.url)
status2 = r2.status_code
self.assertTrue(status2 > self.status)
E AssertionError: False is not true
test_90_unittest.py:19: AssertionError
============================ short test summary info ============================
FAILED test_90_unittest.py::DemoTest::test_request2 - AssertionError: False is...
==================== 1 failed, 2 skipped, 1 xfailed in 2.58s ====================
うまくテストが実行されたことが確認できます。
テストファイルを書いてみよう
これまでの説明で、プロジェクトにテストファイルを追加することを考えてみましょう。
新しいプロジェクトのためのディレクトリ project を作成し、その中に新しいディレクトリ mymath を作成します。
code: bash
$ mkdir -p project/mymath
$ cd project/mymath
mymath の中に、__init__.py という空のファイルを作成します。__init__.py ファイルを作成することで、mymathディレクトリを親ディレクトリからモジュールとしてインポートできるようになります。
プロジェクトのディレクトリはこのようになっているはずです。
code: bash
$ tree project
project
└── mymath
└── __init__.py
1 directory, 1 file
ディレクトリ mymath に mymath.py を作成します。
code: mymath.py
def add(x, y):
return x + y
def multiply(x, y):
return x * y
def square(n):
return(n**2)
def cube(n):
return(n**3)
def fibonacci(n):
a, b = 1, 0
for _ in range(n+1):
a, b = b, a + b
return b
これだけでは、モジュール mymath に、mymath.pyの関数が登録されていないので、__init__.pyを次の行を追加します。
code: project/mymath/__init__.py
from .mymath import *
これで、プロジェクトのディレクトリproject のパスが環境変数 PYTHONPATH に登録されていれば、新しいモジュール mymath が利用できるようになります。
code: bash
% env PYTHONPATH=./project python
Python 3.9.7 | packaged by conda-forge | (default, Sep 29 2021, 19:23:19)
Type "help", "copyright", "credits" or "license" for more information.
>> import mymath
>> mymath.cube(5)
125
>>
テストを書く場所
モジュールの関数別にテストできるようにマーカーを用意しておきます。
code: pytest.ini
markers =
add
multiply
square
cube
packageフォルダに次の2つのテストファイルを配置します。
code: test_40_two_args.py
import pytest
import mymath
test_add_data = [
(10, 20, 30),
(15, 25, 40),
(20, 30, 50),
]
test_multiply_data = [
(10, 20, 200),
(15, 25, 375),
(20, 30, 600),
]
class TestMymath_TwoArgs:
@pytest.mark.add
@pytest.mark.parametrize('a, b, expect', test_add_data)
def test_add(self, a, b, expect):
v = mymath.add(a, b)
assert v == expect, f'Expecting {expect}'
@pytest.mark.multiply
@pytest.mark.parametrize('a, b, expect', test_multiply_data)
def test_multiply(self, a, b, expect):
v = mymath.multiply(a, b)
assert v == expect, f'Expecting {expect}'
code: test_40_single_args.py
import pytest
import mymath
test_square_data = [
( 3, 9),
( 4, 16),
( 5, 25),
]
test_cube_data = [
( 3, 27),
( 4, 64),
( 5, 125),
]
class TestMymath_SingleArgs:
@pytest.mark.square
@pytest.mark.parametrize('n, expect', test_square_data)
def test_square(self, n, expect):
v = mymath.square(n)
assert v == expect, f'Expecting {expect}'
@pytest.mark.cube
@pytest.mark.parametrize('n, expect', test_cube_data)
def test_cube(self, n, expect):
v = mymath.cube(n)
assert v == expect, f'Expecting {expect}'
ファイルとディレクトリは次のようになっているはずです。
code: bash
% tree project
project
├── mymath
│ ├── __init__.py
│ └── mymath.py
├── pytest.ini
├── test_40_single_args.py
└── test_40_two_args.py
1 directory, 5 files
これで、テストが実行できるようになります。
code: bash
% pytest -v
============================== test session starts ==============================
platform darwin -- Python 3.9.7, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 -- /Users/goichiiisaka/anaconda3/envs/tutorials/bin/python3.9
cachedir: .pytest_cache
rootdir: /Users/goichiiisaka/Projects/Python.Osaka/Tutorial.Testing/03_Pytest, configfile: pytest.ini
plugins: cov-2.12.1, typeguard-2.12.1, xdist-2.4.0, forked-1.3.0
collected 12 items
test_40_single_args.py::TestMymath::test_square3-9 PASSED 8% test_40_single_args.py::TestMymath::test_square4-16 PASSED 16% test_40_single_args.py::TestMymath::test_square5-25 PASSED 25% test_40_single_args.py::TestMymath::test_cube3-27 PASSED 33% test_40_single_args.py::TestMymath::test_cube4-64 PASSED 41% test_40_single_args.py::TestMymath::test_cube5-125 PASSED 50% test_40_two_args.py::TestMymath::test_add10-20-30 PASSED 58% test_40_two_args.py::TestMymath::test_add15-25-40 PASSED 66% test_40_two_args.py::TestMymath::test_add20-30-50 PASSED 75% test_40_two_args.py::TestMymath::test_multiply10-20-200 PASSED 83% test_40_two_args.py::TestMymath::test_multiply15-25-375 PASSED 91% test_40_two_args.py::TestMymath::test_multiply20-30-600 PASSED 100% ============================== 12 passed in 0.10s ===============================
mmath.add()だけのテストもマーカーを指定して実行できます。
code: bash
% pytest -v -m add
============================== test session starts ==============================
platform darwin -- Python 3.9.7, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 -- /Users/goichiiisaka/anaconda3/envs/tutorials/bin/python3.9
cachedir: .pytest_cache
rootdir: /Users/goichiiisaka/Projects/Python.Osaka/Tutorial.Testing/03_Pytest/project, configfile: pytest.ini
plugins: cov-2.12.1, typeguard-2.12.1, xdist-2.4.0, forked-1.3.0
collected 12 items / 9 deselected / 3 selected
test_40_two_args.py::TestMymath_TwoArgs::test_add10-20-30 PASSED 33% test_40_two_args.py::TestMymath_TwoArgs::test_add15-25-40 PASSED 66% test_40_two_args.py::TestMymath_TwoArgs::test_add20-30-50 PASSED 100% ======================== 3 passed, 9 deselected in 0.07s ========================
テストファイルを作成するときは、次のことを考慮するようにします。
テストする機能やモジュールに応じて、異なるテストファイルを作成する。
テストファイルやメソッドには意味のある名前をつける。
様々な基準に基づいてテストをグループ化するための十分なマーカーを用意する。
必要に応じてフィクスチャを使用する。
プラグイン
pytest の特徴の1つに豊富なプラグインが利用できることがあります。
pytest によるテストの並列実行
通常は、テストスイートには複数のテストファイルと数多く(規模が大きければ何百)のテストメソッドがあり、実行にはかなりの時間がかかるものです。pytest を使用すると、テストを並行して実行することができます。
そのためには、プラグイン pytest-xdist をインストールする必要があります。
code: bash
$ pip install pytest-xdist
2並列でテストを実行する場合は、-n 2 オプションを与えます。
code: bash
% pytest -v -n 2 test_26_maxfail.py
============================== test session starts ==============================
platform darwin -- Python 3.9.7, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 -- /Users/goichiiisaka/anaconda3/envs/tutorials/bin/python3.9
cachedir: .pytest_cache
rootdir: /Users/goichiiisaka/Projects/Python.Osaka/Tutorial.Testing/03_Pytest, configfile: pytest.ini
plugins: cov-2.12.1, typeguard-2.12.1, xdist-2.4.0, forked-1.3.0
gw0 darwin Python 3.9.7 cwd: /Users/goichiiisaka/Projects/Python.Osaka/Tutorial.Testing/03_Pytest gw1 darwin Python 3.9.7 cwd: /Users/goichiiisaka/Projects/Python.Osaka/Tutorial.Testing/03_Pytest gw0 Python 3.9.7 | packaged by conda-forge | (default, Sep 29 2021, 19:23:19) -- Clang 11.1.0 gw1 Python 3.9.7 | packaged by conda-forge | (default, Sep 29 2021, 19:23:19) -- Clang 11.1.0 scheduling tests via LoadScheduling
test_26_maxfail.py::test_fibonacci8-34 test_26_maxfail.py::test_fibonacci9-56 gw0 25% PASSED test_26_maxfail.py::test_fibonacci8-34 test_26_maxfail.py::test_fibonacci10-90 gw1 50% FAILED test_26_maxfail.py::test_fibonacci9-56 test_26_maxfail.py::test_fibonacci11-144 =================================== FAILURES ====================================
_____________________________ test_fibonacci9-56 ______________________________ gw1 darwin -- Python 3.9.7 /Users/goichiiisaka/anaconda3/envs/tutorials/bin/python3.9 n = 9, expect = 56
@pytest.mark.parametrize("n, expect", test_data)
def test_fibonacci(n, expect):
result = fibonacci(n)
assert result == expect
E assert 55 == 56
E +55
E -56
test_26_maxfail.py:19: AssertionError
_____________________________ test_fibonacci10-90 _____________________________ gw0 darwin -- Python 3.9.7 /Users/goichiiisaka/anaconda3/envs/tutorials/bin/python3.9 n = 10, expect = 90
@pytest.mark.parametrize("n, expect", test_data)
def test_fibonacci(n, expect):
result = fibonacci(n)
assert result == expect
E assert 89 == 90
E +89
E -90
test_26_maxfail.py:19: AssertionError
============================ short test summary info ============================
FAILED test_26_maxfail.py::test_fibonacci9-56 - assert 55 == 56 FAILED test_26_maxfail.py::test_fibonacci10-90 - assert 89 == 90 ========================== 2 failed, 2 passed in 1.42s ==========================
gw0 と gw1 の2つのプロセスが生成されて、Python インタプリタを起動しています。
テストコードはこのプロセスにロードバランシングで処理されます。
pytest-benchmark で変更間のパフォーマンス低下のテスト
Pythonのコードをベンチマークする方法はたくさんあります。標準ライブラリにはtimeitモジュールがあり、関数を何回も実行して、その分布を得ることができます。この例では、test()を100回実行し、出力をprint()しています。
code: python
def test():
# 何かしらのコード
if __name__ == '__main__':
import timeit
print(timeit.timeit("test()", setup="from __main__ import test", number=100))
テストランナーとして pytest を使うことにした場合、もうひとつの選択肢として pytest-benchmark プラグインがあります。これは benchmark という pytest のフィクスチャを提供するものです。benchmark()に任意のcallableを渡すと、pytestの結果にcallableのタイミングをロギングしてくれます。
pytest-benchmark は PyPI から pip を使ってインストールできます。
code: bash
$ pip install pytest-benchmark
そして、フィクスチャを使用し、呼び出し可能オブジェクトを渡すテストを追加して実行することができます。
code: python
def test_my_function(benchmark):
result = benchmark(test)
より詳細な情報は、pytesst-benchmark の ドキュメント を参照してください。 pytest-flake8 / pytest-black
pytest-randomly
これは、特定の順序で実行されることに依存しているテスト、つまり他のテストにステートフルに依存しているテストを発見するのに最適な方法です。もしあなたが pytest でテストスイートをゼロから構築したのであれば、このようなことはあまり考えられません。pytest に移行したテストスイートでは、このようなことが起こりがちです。
このプラグインは、設定の説明にシード値を表示します。この値を使用して、問題を修正するのと同じ順序でテストを実行することができます。
pytest-cov
テストが実装コードをどれだけカバーしているかを測定する場合、カバレッジ パッケージを使用することが多いでしょう。pytest-cov はカバレッジを統合しているので、pytest --cov を実行するとテスト カバレッジ レポートを見ることができます。
このプラグインはデフォルトでインストールされます。
pytest-django
pytest-django は、Django のテストを扱うための便利なフィクスチャやマークを提供します。 rf フィクスチャは Django の RequestFactory のインスタンスに直接アクセスできます。settings フィクスチャは Django の設定を素早く設定したり上書きしたりする方法を提供します。これは Django のテストの生産性を大きく向上させてくれます! pytest-flask
pytest-flask は、Flask の拡張機能やアプリケーションのテストや開発を簡略化するための便利なツール群を提供します。 pytest-envvar
pytest-envvars は、設定に関するモックの一貫性をチェックするために、ユニットテストの環境変数の値をランダムにします。テストに間違ったモックがあると、このテストは壊れてしまい誤りの存在に気づくことができます。 pytest-securestore
pytest-securestore は、暗号化されたデータをテストリポジトリに含める方法を提供し、プロジェクトチームのメンバーがテストアカウントデータ(ログイン名、パスワード、キー)を共有できるようにします。その際、復号化パスワードとファイル名をリポジトリの外で共有するだけで済むようになります。 次のようなソースとなるYAMLをリポジトリの外側におきます。
code: YAML
---
# a comment
a_general_user:
username: the_username
password: a_password
usertype: some_defined_type
...
これを、 pyAesCrypt で暗号化したものをリポジトリ内で保持します。
code: python
import os
import pyAesCrypt
buffer_size = 64 * 1024 # 64K
filename = os.getenv('SECURE_STORE_FILE')
password = os.getenv('SECURE_STORE_PASSWORD')
pyAesCrypt.encryptFile("/path/to/yaml/file", filename, password, buffer_size)
pytest のテストコードでは次のよおうに使用します。
code: python
ef test_get_store_values(store):
# one way to get the value
user = store.get('a_general_user')
# or another
username = store.get('a_general_user').get('username')
# or even another
password = store.get('a_general_user')'password'.value # or
# ...
some_site.log_in(username, password, user_type)
pytest-kwparametrize
pytest-kwparametrize は、pytest.mark.parametrize の代替構文で、テストケースに辞書を使用できるようになります。また、デフォルト値のフォールバックとしても使用できます。 pytest の パラメタライズは簡単で有用ではあるのですが、パラメタが多くなると読みくくなってしまいます。
code: pytho
@pytest.mark.parametrize(
"a, b, c, d, e, f, expect",
[
(3, "one", 4.0, 0x01, 0o5, 9e0, 0b10,),
(6, "five", 3.0, 0x05, 0o10, 9e0, 0b111,),
],
)
def test_my_func(a, b, c, d, e, f, expect):
assert my_func(a, b, c, d, e, f) == expect
pytests-kwparmetrize では、次のように記述できるようになります。
code: python
@pytest.mark.kwparametrize(
dict(a=3, b="one", c=4.0, d=0x01, e=0o5, f=9e0, expect=0b10,),
dict(a=6, b="five", c=3.0, d=0x05, e=0o10, f=9e0, expect=0b111,),
)
def test_my_func(a, b, c, d, e, f, expect):
assert my_func(a, b, c, d, e, f) == expect
pytest-echo
pytest-echo は、環境変数、パッケージのバージョン、一般的な属性を、テスト開始時の状態で表示します。 継続的インテグレーションにおいて、テストの設定や環境をダンプしたり、属性が適切に設定されているかどうかをチェックするのに便利です(つまり、os.environで環境を変更します)。
code: bash
$ pytest --echo-env=HOME
============================= test session starts =========================
platform linux2 -- Python 2.7.4 -- py-1.4.22 -- pytest-2.6.0 -- /bin/python
Environment:
HOME: /Users/sax
plugins: echo, pydev, cov, cache, django
code: bash
$ pytest --echo-version=pytest_echo
============================= test session starts =========================
platform linux2 -- Python 2.7.4 -- py-1.4.22 -- pytest-2.6.0 -- /bin/python
Package version:
pytest_echo: 0.1
plugins: echo, pydev, cov, cache, django
code: bash
$ pytest --echo-attr=django.conf.settings.DEBUG
============================= test session starts =========================
platform linux2 -- Python 2.7.4 -- py-1.4.22 -- pytest-2.6.0 -- /bin/python
Inspections
django.conf.settings.DEBUG: False
plugins: echo, pydev, cov, cache, django
code: pytest.cfg
addopts = -vvv
--tb=short
--capture=no
--echo-env PWD
--echo-env VIRTUAL_ENV
--echo-env DBENGINE
--echo-version django
--echo-version pip
--echo-version pytest-echo
--echo-attr django.conf.settings.DATABASES.default.ENGINE
pytest-describe
pytest には、クラスを使用することで複数のテストコード(メソッド)をまとめることができますが、そのクラスにはTestというプレフィックスを付けなければならなかったり、テストコード(メソッド)ごとにselfを引数に取る必要があるなどの、制約があります。pytest-describe を使うと、関数を使ってテストをまとめることができるため、これらの制約がありません。
code: python
def describe_list():
@pytest.fixture
def list():
return []
def describe_append():
def adds_to_end_of_list(list):
list.append('foo')
list.append('bar')
def describe_remove():
@pytest.fixture
def list():
def removes_item_from_list(list):
list.remove('foo')
pytest-icdiff
pytest-icdiff は、ICDiff を使用した pytest のアサーションエラーメッセージで、より良い diff が得られるようになります。 https://gyazo.com/8a104e9ec76f1a5c12777d96b7bd60ae
pytest-mock
pytest-mock は、 mock パッケージが提供するパッチング API のラッパーであるmockerフィクスチャを提供します。モックをフィクスチャにすることで、定型的なコードを減らすことができます。
mock が有用になる外部依存には次のようなものがあります。
日付または時刻
インターネットを 利用する必要のあるウェブサービス
ファイルシステム。ファイルシステム:作成、読み取り、編集、削除などに必要なファイル
データベース 選択・挿入・更新・削除するデータ
ランダム性。ランダムやnp.randomを利用したコードのことです。
pytest-mock が提供する機能には次のものあります。
mocker.patch で任意のオブジェクトをモック化する
retrun_value で戻り値を固定にする
side_effect で任意の処理に差し替える
例外を強制的に送出する
モック化した処理に対する操作内容を検証する
pytest-json-reort
pytest では、 --junitxml="ファイル名"を与えることで、JUnitのレポート形式であるXMLフォーマットで出力することができます。
code: bash
$ pip install pytest-json-report
これにより、次のオプションが追加されます。
table: JSONレポートに関するオプション
オプション 説明
--json-report JSONフォーマットでレポートを作成。
--json-report-file=PATH JSONレポートを保存するパスを指定。'none'は保存しない。
--json-report-summary 個別のテスト結果は除いて、サマリーだけをレポートに出力す>る
--json-report-omit=FIELD_LIST レポートで省略するフィールドのリスト
指定可能なフィールド: collectors, log, traceback, streams, warnings, keywords
--json-report-indent=LEVEL JSONを指定したインデントレベルで整形出力する
--json-report-verbosity=LEVEL 出力の冗長度を指定
デフォルトは--verbosityで指定した値
使用方法
実行すると次のようになります。
code: bash
% pytest -v test_26_maxfail.py --json-report --json-report-summary --json-report-indent=2 --json-report-file=report.json
============================== test session starts ==============================
platform darwin -- Python 3.9.7, pytest-6.2.5, py-1.10.0, pluggy-1.0.0 -- /Users/goichiiisaka/anaconda3/envs/tutorials/bin/python3.9
cachedir: .pytest_cache
metadata: {'Python': '3.9.7', 'Platform': 'macOS-11.6-x86_64-i386-64bit', 'Packages': {'pytest': '6.2.5', 'py': '1.10.0', 'pluggy': '1.0.0'}, 'Plugins': {'cov': '2.12.1', 'metadata': '1.11.0', 'typeguard': '2.12.1', 'xdist': '2.4.0', 'json-report': '1.4.1', 'forked': '1.3.0'}}
rootdir: /Users/goichiiisaka/Projects/Python.Osaka/Tutorial.Testing/03_Pytest, configfile: pytest.ini
plugins: cov-2.12.1, metadata-1.11.0, typeguard-2.12.1, xdist-2.4.0, json-report-1.4.1, forked-1.3.0
collected 4 items
test_26_maxfail.py::test_fibonacci8-34 PASSED 25% test_26_maxfail.py::test_fibonacci9-56 FAILED 50% test_26_maxfail.py::test_fibonacci10-90 FAILED 75% test_26_maxfail.py::test_fibonacci11-144 PASSED 100% =================================== FAILURES ====================================
_____________________________ test_fibonacci9-56 ______________________________ n = 9, expect = 56
@pytest.mark.parametrize("n, expect", test_data)
def test_fibonacci(n, expect):
result = fibonacci(n)
assert result == expect
E assert 55 == 56
E +55
E -56
test_26_maxfail.py:19: AssertionError
_____________________________ test_fibonacci10-90 _____________________________ n = 10, expect = 90
@pytest.mark.parametrize("n, expect", test_data)
def test_fibonacci(n, expect):
result = fibonacci(n)
assert result == expect
E assert 89 == 90
E +89
E -90
test_26_maxfail.py:19: AssertionError
---------------------------------- JSON report ----------------------------------
report saved to: report.json
============================ short test summary info ============================
FAILED test_26_maxfail.py::test_fibonacci9-56 - assert 55 == 56 FAILED test_26_maxfail.py::test_fibonacci10-90 - assert 89 == 90 ========================== 2 failed, 2 passed in 0.24s ==========================
次のようなレポートが report.json として生成されます。
code: bash
% cat report.json
{
"created": 1636933898.602473,
"duration": 0.13885712623596191,
"exitcode": 1,
"root": "/Users/goichiiisaka/Projects/Python.Osaka/Tutorial.Testing/03_Pytest",
"environment": {
"Python": "3.9.7",
"Platform": "macOS-11.6-x86_64-i386-64bit",
"Packages": {
"pytest": "6.2.5",
"py": "1.10.0",
"pluggy": "1.0.0"
},
"Plugins": {
"cov": "2.12.1",
"metadata": "1.11.0",
"typeguard": "2.12.1",
"xdist": "2.4.0",
"json-report": "1.4.1",
"forked": "1.3.0"
}
},
"summary": {
"passed": 2,
"failed": 2,
"total": 4,
"collected": 4
}
}
メタデータ
json_metadata テストフィクスチャを使うと、テストアイテムに独自のメタデータを簡単に追加することができます。
code: python
def test_something(json_metadata):
json_metadata'foo' = {"some": "thing"} または、conftest.py で pytest_json_runtest_metadata フックを使って、現在のテスト実行に基づいてメタデータを追加します。返されたdictは、既存のメタデータと自動的にマージされます。
例えば、次の定義は各テストのコールステージの開始時間と停止時間を追加します。
code: python
def pytest_json_runtest_metadata(item, call):
if call.when != 'call':
return {}
return {'start': call.start, 'stop': call.stop}
また、pytest-metadataの--metadataスイッチを使ってメタデータを追加することもできます。この場合、メタデータはレポートの環境セクションに追加されますが、特定のテスト項目には追加されません。すべてのメタデータがJSONでシリアライズできることを確認してください。
フックに関する注意点
プラグインがインストールされていないか、アクティブでない(--json-report を使用していない)にもかかわらず、pytest_json_* フックを使用している場合、pytest はフックを認識せず、次のような内部エラーで失敗することがあります。
code: bash
INTERNALERROR> pluggy.manager.PluginValidationError: unknown hook 'pytest_json_runtest_metadata' in plugin <module 'conftest' from 'conftest.py'>
これを避けるためには、フックの実装をオプションとして宣言します。
code: python
import pytest
@pytest.hookimpl(optionalhook=True)
def pytest_json_runtest_metadata(item, call):
...
レポートの修正
pytest_json_modifyreport フックを使うと、保存される前にレポート全体を修正することができます。
conftest.pyにこのフックを実装してください。
code: python
def pytest_sessionfinish(session):
report = session.config._json_report.report
テストステージの実行結果を、pytest_json_runtest_stageフックを使用することで、どのようにJSONに変換されるかを変更することができます。このフックは TestReport を受け取り、JSON にシリアライズ可能な dict を返します。
code: python
def pytest_json_runtest_stage(report):
return {'outcome': report.outcome}
コードからの直接呼び出し
コードから直接 pytest.main() を呼び出すときに、このプラグインを使用することができます。
code: python
import pytest
from pytest_jsonreport.plugin import JSONReport
json_plugin = JSONReport()
これでレポートオブジェクトにアクセスすることができるように’なります。
code: python
print(json_plugiin.report)
ファイルパスを指定して保存することもできます。
code: python
json_plugin.save_report('/tmp/my_report.json')
その他のプラグイン
pytest には、たくさんの有益なプラグインがサードパーティーから提供されています。
PyCharmからテストを実行する
PyCharm IDEを使用している場合、以下の手順でunittestまたはpytestを実行することができます。 プロジェクトツールウィンドウで、testsディレクトリを選択します。
コンテキストメニューで、unittestの実行コマンドを選択します。例えば、「Run 'Unittests in my Tests...'」を選択します。
これで、テストウィンドウ内でunittestが実行され、PyCharm内で結果が表示されます。
詳細は、PyCharm のドキュメント を参照してください。 Visual Studio Codeからのテストの実行
Microsoft Visual Studio Code IDE を使用している場合、unittest、pytest、nose の実行サポートは Python プラグインに組み込まれています。
Pythonプラグインがインストールされていれば、Ctrl+Shift+P でコマンドパレットを開き、「Python test」と入力することで、テストの構成を設定することができます。すると、さまざまなオプションが表示されます。
https://gyazo.com/f15e30e45b18bf3b99f0b579afe861c4
Debug All Unit Testsを選択すると、VSCodeはテスト・フレームワークを設定するためのプロンプトを表示します。アイコンをクリックして、テスト・ランナー(unittest)とホーム・ディレクトリ(...)を選択します。
これが設定されると、ウィンドウの下部にテストのステータスが表示され、これらのアイコンをクリックすることでテスト・ログに素早くアクセスし、テストを再実行することができます。
まとめ
実際の運用では、新しいバージョンのコードをデプロイする準備ができると、まずステージング環境にデプロイします。そして、その上でテスト・スイートが実行されることになります。すべてのテストスイートが成功した場合にのみ、コードは本番環境へのデプロイすることが可能になり、テストに失敗した場合、たとえそれが1つであろうと、そのコードは本番環境に対応したものではありません。実装されている機能が期待通りに動作するかどうかを、繰り返しテストを行い、品質を担保するようにします。
参考