Closed4

今更ながらpytestを試す

kun432kun432

Get Startedに従って進める

https://docs.pytest.org/en/stable/getting-started.html

インストール

作業ディレクトリ作成

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()の実行結果をテストする。

test_add.py
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になるため、テストは成功している。

意図的にテスト失敗するようにしてみる。

test_add.py
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がどうテスト項目を探索するか?については以下に記載がある。

https://docs.pytest.org/en/stable/explanation/goodpractices.html#test-discovery

ざっくりまとめるとこう

  1. テスト収集開始場所
    • 引数がない場合:testpaths(設定されていれば)または現在のディレクトリ。
    • 引数がある場合:指定したディレクトリ、ファイル、ノードIDを使用。
  2. 探索ルール
    • ディレクトリを再帰的に探索(ただしnorecursedirsは除外)。
    • test_*.py または *_test.py ファイルを検索。
  3. テスト項目の収集
    • クラス外:testで始まる関数やメソッド。
    • クラス内:Testで始まるクラス内のtestで始まるメソッド(@staticmethod@classmethodも含む)。
  4. その他の検出方法
    • 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 ========================================

testpathspytest.inipyproject.tomlなどの設定ファイルで指定する。この設定があれば、そこがテスト収集開始場所となる。

例えば以下のようにディレクトリを作成してテストコードを用意する。

mkdir sample_dir
sample_dir/test_multiply.py
def multiply(x,y):
    return x * y


def test_multiply():
    assert multiply(5, 2) == 11

この状態で普通に実行すると、test_add.pysample_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.ini
[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を指定。

pyetst.ini
[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ヘルパーを使って、例外が発生することをテストする。

test_sysexit.py
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が使える。例外グループ内に特定の例外が含まれているかをテストする例。

test_exceptiongroup.py
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 を付与して、クラス内のすべてのテストに適用可能。
test_class.py
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では各テストごとにクラスの新しいインスタンスが作成される。これはテストの独立性のためであり、同じインスタンスを共有するようなイメージで以下のような書き方だと失敗する。

test_class2.py
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_oneself.value1 に設定しているが、test_twoでは新しいインスタンスが作成されるため、self.value はリセットされている。

これを回避するには、フィクスチャで状態を定義する。フィクスチャはテストの処理を行うための機能。

test_class3.py
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_pathpathlib.Path オブジェクトとして提供され、ファイルやディレクトリの操作も簡単に行える

ができる。

以下のテストコードをテストしてみる

test_tmp_path.py
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

一時ディレクトリが作成されているのがわかる。

こんな感じで使える。

test_tmp_path2.py
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 ========================================
kun432kun432

組み込みのフィクスチャは以下で確認できる。

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 ======================================

ドキュメントはこちら

https://docs.pytest.org/en/stable/builtin.html

このスクラップは13日前にクローズされました