今更ながらpytestを試す
ずっと雰囲気でしかやっていないので、改めて一通りやっておく。
GitHubレポジトリ
公式ドキュメント
Get Startedに従って進める
インストール
作業ディレクトリ作成
mkdir pytest-test && cd pytest-test
仮想環境作成。自分はmiseで。
mise use python@3.12
cat << 'EOS' >> .mise.toml
[env]
_.python.venv = { path = ".venv", create = true }
EOS
mise trust
パッケージインストール
pip install -U pytest
pytest --version
pytest 8.3.4
最初のテスト
テストを含むコード。add()
の実行結果をテストする。
def add(x,y):
return x + y
def test_add():
assert add(3, 4) == 7
テスト実行
pytest test_add.py
======================================= test session starts =======================================
platform darwin -- Python 3.12.7, pytest-8.3.4, pluggy-1.5.0
rootdir: /Users/kun432/work/pytest-test
collected 1 item
test_add.py . [100%]
======================================== 1 passed in 0.00s ========================================
add(3,4)
の結果は7
であり、assert
の結果はTrueになるため、テストは成功している。
意図的にテスト失敗するようにしてみる。
def add(x,y):
return x + y
def test_add():
assert add(3, 4) == 8 # 正しくは7だが、意図的にテスト失敗させる
pytest test_add.py
======================================= test session starts =======================================
platform darwin -- Python 3.12.7, pytest-8.3.4, pluggy-1.5.0
rootdir: /Users/kun432/work/pytest-test
collected 1 item
test_add.py F [100%]
============================================ FAILURES =============================================
____________________________________________ test_add _____________________________________________
def test_add():
> assert add(3, 4) == 8
E assert 7 == 8
E + where 7 = add(3, 4)
test_add.py:6: AssertionError
===================================== short test summary info =====================================
FAILED test_add.py::test_add - assert 7 == 8
======================================== 1 failed in 0.01s ========================================
複数のテストを実行する
上記のコードではtest_*
で始まる関数をテスト項目とみなしている。pytestがどうテスト項目を探索するか?については以下に記載がある。
ざっくりまとめるとこう
- テスト収集開始場所
- 引数がない場合:
testpaths
(設定されていれば)または現在のディレクトリ。 - 引数がある場合:指定したディレクトリ、ファイル、ノードIDを使用。
- 引数がない場合:
- 探索ルール
- ディレクトリを再帰的に探索(ただし
norecursedirs
は除外)。 -
test_*.py
または*_test.py
ファイルを検索。
- ディレクトリを再帰的に探索(ただし
- テスト項目の収集
- クラス外:
test
で始まる関数やメソッド。 - クラス内:
Test
で始まるクラス内のtest
で始まるメソッド(@staticmethod
や@classmethod
も含む)。
- クラス外:
- その他の検出方法
-
unittest.TestCase
のサブクラスも検出対象。
-
最初の例だと以下でもOK。引数がないので、現在のディレクトリ・そこにあるtest_sample.py
・その中のtest_answer()
がテスト項目となる
pytest
======================================= test session starts =======================================
platform darwin -- Python 3.12.7, pytest-8.3.4, pluggy-1.5.0
rootdir: /Users/kun432/work/pytest-test
collected 1 item
test_add.py F [100%]
============================================ FAILURES =============================================
____________________________________________ test_add _____________________________________________
def test_add():
> assert add(3, 4) == 8
E assert 7 == 8
E + where 7 = add(3, 4)
test_add.py:6: AssertionError
===================================== short test summary info =====================================
FAILED test_add.py::test_add - assert 7 == 8
======================================== 1 failed in 0.01s ========================================
testpaths
はpytest.ini
やpyproject.toml
などの設定ファイルで指定する。この設定があれば、そこがテスト収集開始場所となる。
例えば以下のようにディレクトリを作成してテストコードを用意する。
mkdir sample_dir
def multiply(x,y):
return x * y
def test_multiply():
assert multiply(5, 2) == 11
この状態で普通に実行すると、test_add.py
とsample_dir/test_multiply.py
両方がテスト対象とされ、2項目のテストが行われる。
pytest
======================================= test session starts =======================================
platform darwin -- Python 3.12.7, pytest-8.3.4, pluggy-1.5.0
rootdir: /Users/kun432/work/pytest-test
collected 2 items
sample_dir/test_multiply.py F [ 50%]
test_add.py F [100%]
============================================ FAILURES =============================================
__________________________________________ test_multiply __________________________________________
def test_multiply():
> assert multiply(5, 2) == 11 # 正しくは7だが、意図的にテスト失敗させる
E assert 10 == 11
E + where 10 = multiply(5, 2)
sample_dir/test_multiply.py:6: AssertionError
____________________________________________ test_add _____________________________________________
def test_add():
> assert add(3, 4) == 8
E assert 7 == 8
E + where 7 = add(3, 4)
test_add.py:6: AssertionError
===================================== short test summary info =====================================
FAILED sample_dir/test_multiply.py::test_multiply - assert 10 == 11
FAILED test_add.py::test_add - assert 7 == 8
======================================== 2 failed in 0.02s ========================================
pytest.ini
を作成して、testpaths
を定義する。
[pytest]
testpaths = sample_dir
再度テスト
pytest
======================================= test session starts =======================================
platform darwin -- Python 3.12.7, pytest-8.3.4, pluggy-1.5.0
rootdir: /Users/kun432/work/pytest-test
configfile: pytest.ini
testpaths: sample_dir
collected 1 item
sample_dir/test_multiply.py F [100%]
============================================ FAILURES =============================================
__________________________________________ test_multiply __________________________________________
def test_multiply():
> assert multiply(5, 2) == 11 # 正しくは7だが、意図的にテスト失敗させる
E assert 10 == 11
E + where 10 = multiply(5, 2)
sample_dir/test_multiply.py:6: AssertionError
===================================== short test summary info =====================================
FAILED sample_dir/test_multiply.py::test_multiply - assert 10 == 11
======================================== 1 failed in 0.01s ========================================
指定したディレクトリ以下がテスト収集開始場所となっているのがわかる。
norecursedirs
で明示的にテスト探索対象から除外することもできる。pytest.iniを修正して、testpaths
の定義を削除・norecursedirs
を指定。
[pytest]
norecursedirs = sample_dir
カレントディレクトリに対してテスト
pytest
======================================= test session starts =======================================
platform darwin -- Python 3.12.7, pytest-8.3.4, pluggy-1.5.0
rootdir: /Users/kun432/work/pytest-test
configfile: pytest.ini
collected 1 item
test_add.py F [100%]
============================================ FAILURES =============================================
____________________________________________ test_add _____________________________________________
def test_add():
> assert add(3, 4) == 8
E assert 7 == 8
E + where 7 = add(3, 4)
test_add.py:6: AssertionError
===================================== short test summary info =====================================
FAILED test_add.py::test_add - assert 7 == 8
======================================== 1 failed in 0.01s ========================================
指定したディレクトリ以下は探索されず、現在のディレクトリにあるテストファイル・テスト項目だけが対象になっているのがわかる。
それ以外は後で出てくるので後述。
特定の例外に対するAssert
raises
ヘルパーを使って、例外が発生することをテストする。
import pytest
def f():
raise SystemExit(1) # SystemExit例外を発生
def test_mytest():
with pytest.raises(SystemExit): # 例外が発生することを確認
f()
pytest test_sysexit.py
======================================= test session starts =======================================
platform darwin -- Python 3.12.7, pytest-8.3.4, pluggy-1.5.0
rootdir: /Users/kun432/work/pytest-test
configfile: pytest.ini
collected 1 item
test_sysexit.py . [100%]
======================================== 1 passed in 0.00s ========================================
Python 3.11以降であれば、複数の例外をまとめて扱えるExceptionGroup
が使える。例外グループ内に特定の例外が含まれているかをテストする例。
import pytest
def f():
raise ExceptionGroup(
"Group message", # グループ全体の説明
[RuntimeError()], # 含まれる例外
)
def test_exception_in_group():
with pytest.raises(ExceptionGroup) as excinfo: # 例外グループをキャプチャ
f()
assert excinfo.group_contains(RuntimeError) # RuntimeErrorが含まれる
assert not excinfo.group_contains(TypeError) # TypeErrorは含まれない
pytest test_exceptiongroup.py
======================================= test session starts =======================================
platform darwin -- Python 3.12.7, pytest-8.3.4, pluggy-1.5.0
rootdir: /Users/kun432/work/pytest-test
configfile: pytest.ini
collected 1 item
test_exceptiongroup.py . [100%]
======================================== 1 passed in 0.00s ========================================
なお、-q
をつけると出力が簡潔になる。
pytest -q test_exceptiongroup.py
. [100%]
1 passed in 0.00s
複数のテストをクラスにまとめる
複数のテストを1つのクラスにまとめることで、テストコードを整理できる。
- テストが整理できる
- 関連するテストを1つのクラス内にグループ化。
- モジュールが大規模になっても見通しが良くなる。
- フィクスチャの共有
- フィクスチャを使えば特定のクラス内のテストだけで状態を共有できる。
- マークの適用
- クラス単位で
@pytest.mark
を付与して、クラス内のすべてのテストに適用可能。
- クラス単位で
class TestClass:
def test_one(self):
x = "this"
assert "h" in x # "h"が"x"に含まれることを確認
def test_two(self):
x = "hello"
assert hasattr(x, "check") # "hello"が"check"属性を持つかを確認
pytest -q test_class.py
.F [100%]
============================================ FAILURES =============================================
_______________________________________ TestClass.test_two ________________________________________
self = <test_class.TestClass object at 0x10533d730>
def test_two(self):
x = "hello"
> assert hasattr(x, "check") # "hello"が"check"属性を持つかを確認
E AssertionError: assert False
E + where False = hasattr('hello', 'check')
test_class.py:8: AssertionError
===================================== short test summary info =====================================
FAILED test_class.py::TestClass::test_two - AssertionError: assert False
1 failed, 1 passed in 0.01s
ただし、pyetstでは各テストごとにクラスの新しいインスタンスが作成される。これはテストの独立性のためであり、同じインスタンスを共有するようなイメージで以下のような書き方だと失敗する。
class TestClassDemoInstance:
value = 0 # クラス属性(すべてのテストで共有)
def test_one(self):
self.value = 1 # `self.value` を変更
assert self.value == 1
def test_two(self):
assert self.value == 1 # ここで失敗
pytest -q test_class2.py
.F [100%]
============================================ FAILURES =============================================
_________________________________ TestClassDemoInstance.test_two __________________________________
self = <test_class2.TestClassDemoInstance object at 0x104c1e600>
def test_two(self):
> assert self.value == 1 # ここで失敗
E assert 0 == 1
E + where 0 = <test_class2.TestClassDemoInstance object at 0x104c1e600>.value
test_class2.py:9: AssertionError
===================================== short test summary info =====================================
FAILED test_class2.py::TestClassDemoInstance::test_two - assert 0 == 1
1 failed, 1 passed in 0.02s
test_one
で self.value
を 1
に設定しているが、test_two
では新しいインスタンスが作成されるため、self.value
はリセットされている。
これを回避するには、フィクスチャで状態を定義する。フィクスチャはテストの処理を行うための機能。
import pytest
class TestClassDemoWithSelf:
@pytest.fixture(autouse=True)
def setup(self):
# 各テストごとに初期化されるインスタンス属性
self.value = 0
def test_one(self):
self.value = 1 # test_oneで変更
assert self.value == 1
def test_two(self):
# test_oneの影響を受けず、初期化された値でテスト
assert self.value == 0
pytest -q test_class3.py
.. [100%]
2 passed in 0.00s
機能テスト用に一時ディレクトリをリクエストする
pytestには他にも複数のフィクスチャが組み込まれている。これの1つであるtmp_path
を使うと、
- テスト実行ごとに固有の一時ディレクトリを作成、テスト後に自動的に削除
-
tmp_path
はpathlib.Path
オブジェクトとして提供され、ファイルやディレクトリの操作も簡単に行える
ができる。
以下のテストコードをテストしてみる
def test_needsfiles(tmp_path):
print(tmp_path) # 一時ディレクトリのパスを表示
assert 0 # テストを失敗させる(デモ用)
pytest -q test_tmp_path.py
F [100%]
============================================ FAILURES =============================================
_________________________________________ test_needsfiles _________________________________________
tmp_path = PosixPath('/private/var/folders/5z/mnlc5_7x5dv8r528s4sg1h2r0000gn/T/pytest-of-kun432/pytest-1/test_needsfiles0')
def test_needsfiles(tmp_path):
print(tmp_path) # 一時ディレクトリのパスを表示
> assert 0 # テストを失敗させる(デモ用)
E assert 0
test_tmp_path.py:3: AssertionError
-------------------------------------- Captured stdout call ---------------------------------------
/private/var/folders/5z/mnlc5_7x5dv8r528s4sg1h2r0000gn/T/pytest-of-kun432/pytest-1/test_needsfiles0
===================================== short test summary info =====================================
FAILED test_tmp_path.py::test_needsfiles - assert 0
1 failed in 0.01s
一時ディレクトリが作成されているのがわかる。
こんな感じで使える。
def test_write_file(tmp_path):
print(f"Temporary directory for test_write_file: {tmp_path}") # 一時ディレクトリのパスを出力
file = tmp_path / "example.txt"
file.write_text("Hello, pytest!")
assert file.read_text() == "Hello, pytest!"
assert file.exists()
def test_create_subdirectory(tmp_path):
print(f"Temporary directory for test_create_subdirectory: {tmp_path}")
sub_dir = tmp_path / "subdir"
sub_dir.mkdir()
assert sub_dir.is_dir()
assert (tmp_path / "subdir").exists()
def test_multiple_files(tmp_path):
print(f"Temporary directory for test_multiple_files: {tmp_path}")
file1 = tmp_path / "file1.txt"
file2 = tmp_path / "file2.txt"
file1.write_text("File 1 content")
file2.write_text("File 2 content")
assert file1.read_text() == "File 1 content"
assert file2.read_text() == "File 2 content"
def test_file_deletion(tmp_path):
print(f"Temporary directory for test_file_deletion: {tmp_path}")
file = tmp_path / "to_delete.txt"
file.write_text("This file will be deleted")
assert file.exists()
file.unlink()
assert not file.exists()
def test_relative_path_resolution(tmp_path):
print(f"Temporary directory for test_relative_path_resolution: {tmp_path}")
relative_path = tmp_path / "subdir" / ".." / "file.txt"
file = relative_path.resolve()
file.write_text("Resolved path content")
assert file.read_text() == "Resolved path content"
assert file.exists()
テスト実行。-s
で標準出力をテストの出力に含める。
pytest -s test_tmp_path2.py
======================================= test session starts =======================================
platform darwin -- Python 3.12.7, pytest-8.3.4, pluggy-1.5.0
rootdir: /Users/kun432/work/pytest-test
configfile: pytest.ini
collected 5 items
test_tmp_path2.py Temporary directory for test_write_file: /private/var/folders/5z/mnlc5_7x5dv8r528s4sg1h2r0000gn/T/pytest-of-kun432/pytest-7/test_write_file0
.Temporary directory for test_create_subdirectory: /private/var/folders/5z/mnlc5_7x5dv8r528s4sg1h2r0000gn/T/pytest-of-kun432/pytest-7/test_create_subdirectory0
.Temporary directory for test_multiple_files: /private/var/folders/5z/mnlc5_7x5dv8r528s4sg1h2r0000gn/T/pytest-of-kun432/pytest-7/test_multiple_files0
.Temporary directory for test_file_deletion: /private/var/folders/5z/mnlc5_7x5dv8r528s4sg1h2r0000gn/T/pytest-of-kun432/pytest-7/test_file_deletion0
.Temporary directory for test_relative_path_resolution: /private/var/folders/5z/mnlc5_7x5dv8r528s4sg1h2r0000gn/T/pytest-of-kun432/pytest-7/test_relative_path_resolution0
.
======================================== 5 passed in 0.01s ========================================
組み込みのフィクスチャは以下で確認できる。
pytest --fixtures
======================================= test session starts =======================================
platform darwin -- Python 3.12.7, pytest-8.3.4, pluggy-1.5.0
rootdir: /Users/kun432/work/pytest-test
configfile: pytest.ini
collected 16 items
cache -- .venv/lib/python3.12/site-packages/_pytest/cacheprovider.py:556
Return a cache object that can persist state between testing sessions.
capsysbinary -- .venv/lib/python3.12/site-packages/_pytest/capture.py:1006
Enable bytes capturing of writes to ``sys.stdout`` and ``sys.stderr``.
capfd -- .venv/lib/python3.12/site-packages/_pytest/capture.py:1034
Enable text capturing of writes to file descriptors ``1`` and ``2``.
capfdbinary -- .venv/lib/python3.12/site-packages/_pytest/capture.py:1062
Enable bytes capturing of writes to file descriptors ``1`` and ``2``.
capsys -- .venv/lib/python3.12/site-packages/_pytest/capture.py:978
Enable text capturing of writes to ``sys.stdout`` and ``sys.stderr``.
doctest_namespace [session scope] -- .venv/lib/python3.12/site-packages/_pytest/doctest.py:741
Fixture that returns a :py:class:`dict` that will be injected into the
namespace of doctests.
pytestconfig [session scope] -- .venv/lib/python3.12/site-packages/_pytest/fixtures.py:1345
Session-scoped fixture that returns the session's :class:`pytest.Config`
object.
record_property -- .venv/lib/python3.12/site-packages/_pytest/junitxml.py:280
Add extra properties to the calling test.
record_xml_attribute -- .venv/lib/python3.12/site-packages/_pytest/junitxml.py:303
Add extra xml attributes to the tag for the calling test.
record_testsuite_property [session scope] -- .venv/lib/python3.12/site-packages/_pytest/junitxml.py:341
Record a new ``<property>`` tag as child of the root ``<testsuite>``.
tmpdir_factory [session scope] -- .venv/lib/python3.12/site-packages/_pytest/legacypath.py:298
Return a :class:`pytest.TempdirFactory` instance for the test session.
tmpdir -- .venv/lib/python3.12/site-packages/_pytest/legacypath.py:305
Return a temporary directory (as `legacy_path`_ object)
which is unique to each test function invocation.
The temporary directory is created as a subdirectory
of the base temporary directory, with configurable retention,
as discussed in :ref:`temporary directory location and retention`.
caplog -- .venv/lib/python3.12/site-packages/_pytest/logging.py:598
Access and control log capturing.
monkeypatch -- .venv/lib/python3.12/site-packages/_pytest/monkeypatch.py:31
A convenient fixture for monkey-patching.
recwarn -- .venv/lib/python3.12/site-packages/_pytest/recwarn.py:35
Return a :class:`WarningsRecorder` instance that records all warnings emitted by test functions.
tmp_path_factory [session scope] -- .venv/lib/python3.12/site-packages/_pytest/tmpdir.py:241
Return a :class:`pytest.TempPathFactory` instance for the test session.
tmp_path -- .venv/lib/python3.12/site-packages/_pytest/tmpdir.py:256
Return a temporary directory (as :class:`pathlib.Path` object)
which is unique to each test function invocation.
The temporary directory is created as a subdirectory
of the base temporary directory, with configurable retention,
as discussed in :ref:`temporary directory location and retention`.
-------------------------------- fixtures defined from test_class3 --------------------------------
setup -- test_class3.py:5
no docstring available
====================================== no tests ran in 0.01s ======================================
ドキュメントはこちら
npaka先生の記事がまとまっててわかりやすい