🫰

pytestさん初めまして。

2024/04/25に公開

挨拶

pytestさんへ
本日からお世話になります。新人です。

本記事の到達目標

  1. pytestのtestコードをある程度読めるようになる。
  2. 主要なツールを用いて簡単なtestコードを書けるようになる。
  3. pytestさんと友好を深める。

pytestとは

このpytestフレームワークを使用すると、小さくて読みやすいテストを簡単に作成でき、アプリケーションやライブラリの複雑な機能テストをサポートするように拡張できます。

https://docs.pytest.org/en/8.1.x/
その名の通りpythonで使用されるtest用ツールです。友好を深めるにあたって、主要な処理をまとめていきます。

実行方法

ディレクトリ構成

├── main.py
├── add.py
└── tests
    ├── test_main.py
    └── calculations
        └── test_add.py

簡単に上記のようなディレクトリ構成だとすると、下記のような実行方法になります。

tests配下ファイルorフォルダをすべて実行

- tests配下すべて -
pytest tests
- 特定のフォルダ -
pytest tests/calculation
- 特定ファイル -
pytest tests/calculation/test_add.py

特定のテスト名

pytest tests/main.py::class_name::method_name

便利なツール

下記引用させていただきました。

フラグ 説明
-s print 文 などを表示できるようになる
-v テストケース名、テストケースごとに PASSED や FAILED などの詳細情報が表示される
-k テスト名の部分一致
-x テストが失敗した時点でテストを終了する
-m <maker名> 指定した maker 名 の デコレータをつけたテストのみ実行される。デコレータは次のように書く@pytest.mark.<maker名>
--durations=N テストケースごとの実行時間、setup や teardown の時間を表示する。N=0 だと全てのテストケースに対して実行される
--cov --cov-branch 条件分岐のカバレッジ(C1)をみるためのフラグ

https://www.oio-blog.com/contents/pytest

実行方法はわかったものの、ここでいうclass_nameとmethod_nameなどは何なのか次章で書き込んでいきます。

pytestで(多分)よく使うもの

テスト対象は以下とします。

main.py
def add(x, y):
    return x + y

def subtract(x, y):
    return x - y

assert

検証したい条件式を記載する至極シンプルなものです。
<使用例>

tests/test_main.py
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from main import add, subtract

def test_add():
    assert add(2, 3) == 5
    assert add(0, 0) == 0
    assert add(-1, 1) == 0


def test_subtract():
    assert subtract(5, 3) == 2
    assert subtract(0, 0) == 0
    assert subtract(1, 1) == 10

<実行結果>

$ pytest tests
=================================== test session starts ====================================
collected 2 items

tests/test_main.py .F                                                                [100%]

========================================= FAILURES =========================================
______________________________________ test_subtract _______________________________________

    def test_subtract():
        assert subtract(5, 3) == 2
        assert subtract(0, 0) == 0
>       assert subtract(1, 1) == 10
E       assert 0 == 10
E        +  where 0 = subtract(1, 1)

tests/test_main.py:15: AssertionError
================================= short test summary info ==================================
FAILED tests/test_main.py::test_subtract - assert 0 == 10
=============================== 1 failed, 1 passed in 0.19s ================================

上記で分かるように、assertを用いて引数に任意の値を渡したときに、条件通りになるかをtestすることができます。
追記
ここで、あえてsubtract関数でエラーが出るようにしているのですが、tests/test_main.py .Fで「.」と「F」の記載があり、これは初めのテストした関数は成功して二つ目が失敗したという結果を表しています。
すなわち「.」が成功、「F」が失敗という意味です。

fixture

fixtureではテストの前後に実施したい処理を記載し、実行することができます。
fixtureの書き方は以下の通りで、処理の中にあるyieldでテストを実行しています。
<使用例>

tests/test_main.py
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@pytest.fixture()
def setup():
    # 前処理
    print("\nテストを開始します")

    # 各テスト実行
    yield
    
    # 後処理
    print("\nテストを終了します")

def test_add(setup):
    assert add(2, 3) == 5
    assert add(0, 0) == 0
    assert add(-1, 1) == 0
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

<実行結果>

$ pytest tests -s
=================================== test session starts ====================================
platform linux -- Python 3.10.12, pytest-8.1.1, pluggy-1.4.0
rootdir: /{Path}/pytest_prac
collected 1 item

tests/test_main.py
テストを開始します
.
テストを終了します


==================================== 1 passed in 0.06s =====================================

上記の通り、fixtureで作成した関数をテスト関数に渡すことで記載の前後処理を実行することができます。

別のパターン
fixtureでは複数テストの共通処理等も設定することができます。

スコープ 説明
function テスト関数ごとにfixtureが実行されます。
class テストクラス内の全てのメソッドで1つのfixtureが共有。
module モジュール内の全てのテストで1つのfixtureが共有。
package パッケージ内の全てのテストで1つのfixtureが共有。
session pytest実行中の全てのテストで1つのfixtureが共有。

具体的には上記のスコープを活用します。
<使用例>

tests/test_main.py
@pytest.fixture(scope="module")
def setup_module():
    print("\nモジュールの前処理")
    yield
    print("\nモジュールの後処理")

class TestAddition:
    @pytest.fixture(scope="class")
    def setup_class(self):
        print("\nクラスの前処理")
        yield
        print("\nクラスの後処理")

    @pytest.fixture(scope="function")
    def setup_function(self):
        print("\nテスト関数の前処理")
        yield
        print("\nテスト関数の後処理")

    def test_add(self, setup_module, setup_class, setup_function):
        print("テスト関数: test_add")
        assert add(2, 3) == 5
        assert add(0, 0) == 0
        assert add(-1, 1) == 0

class TestSubtraction:
    @pytest.fixture(scope="class")
    def setup_class(self):
        print("\nクラスの前処理")
        yield
        print("\nクラスの後処理")

    @pytest.fixture(scope="function")
    def setup_function(self):
        print("\nテスト関数の前処理")
        yield
        print("\nテスト関数の後処理")

    def test_subtract(self, setup_module, setup_class, setup_function):
        print("テスト関数: test_subtract")
        assert subtract(5, 3) == 2
        assert subtract(0, 0) == 0
        assert subtract(1, 1) == 0

<実行結果>

collected 2 items

tests/test_main.py
モジュールの前処理

クラスの前処理

テスト関数の前処理
テスト関数: test_add
.
テスト関数の後処理

クラスの後処理

クラスの前処理

テスト関数の前処理
テスト関数: test_subtract
.
テスト関数の後処理

クラスの後処理

モジュールの後処理

上記の実行結果で分かる通り、それぞれ実行のタイミングが異なります。

  • setup_module の場合、モジュールの前処理と後処理はモジュール内の全てのテストで1度だけ実行されます。
  • setup_class の場合、クラスの前処理と後処理はクラス内の全てのテストで1度だけ実行されます。
  • setup_function の場合、テスト関数の前処理と後処理は各テスト関数ごとに1度ずつ実行されます。

mark

markはテスト関数にマーク(タグ)を付けることができます。これにより、テスト関数をグループ化し、実行時に特定のマークを持つテストのみを実行することができます。
<使用例>

tests/test_main.py
@pytest.fixture(scope="function")
def setup():
    # 前処理
    print("\nテストを開始します")

    # 各テスト実行
    yield
    
    # 後処理
    print("\nテストを終了します")

@pytest.mark.add
def test_add(setup):
    assert add(2, 3) == 5
    assert add(0, 0) == 0
    assert add(-1, 1) == 0

@pytest.mark.subtract
def test_subtract():
    assert subtract(5, 3) == 2
    assert subtract(0, 0) == 0
    assert subtract(1, 1) == 10

<実行結果>

$ pytest tests -m add -s
=================================== test session starts ====================================
platform linux -- Python 3.10.12, pytest-8.1.1, pluggy-1.4.0
rootdir: {PATH}
collected 2 items / 1 deselected / 1 selected

tests/test_main.py
テストを開始します
.
テストを終了します


===================================== warnings summary =====================================
tests/test_main.py:18
  /{PATH}/tests/test_main.py:18: PytestUnknownMarkWarning: Unknown pytest.mark.add - is this a typo?  You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html
    @pytest.mark.add

tests/test_main.py:24
  /{PATH}/test_main.py:24: PytestUnknownMarkWarning: Unknown pytest.mark.subtract - is this a typo?  You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/how-to/mark.html      
    @pytest.mark.subtract

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
======================= 1 passed, 1 deselected, 2 warnings in 0.12s ========================

上記のように、markによってtest_add関数のみを実行できました。反対にmarkしたもの以外の実行はnotを付け加えることで実行できます。
しかしここで実行はできたものの警告が発生しました。
この警告はpytest.mark.add と pytest.mark.subtract が不明なマークであるため発生しています。これは、pytestがそのようなマークを認識できないためだそうです。
この警告を回避するためには、iniファイルを作成したり、conftest.pyファイルでマークを登録したりする必要があります。

ini
Copy code
[pytest]
markers =
    add: mark a test as add.
    subtract: mark a test as subtract.

別のパターン
実行を進めていく中でテストコードがどんどん増えていきます。しかし、実際はデータのみ変えたいこともあると思います。そんな時にmarkが解決してくれます。
そこで用いるのがmark.parametrizeです。これは同じテストコードに対して複数のデータセットでテストを行うことができます。
<使用例>

tests/test_main.py
import pytest

@pytest.fixture()
def setup():
    # 前処理
    print("\nテストを開始します")

    # 各テスト実行
    yield
    
    # 後処理
    print("\nテストを終了します")

@pytest.mark.parametrize(["data1", "data2"], [
    pytest.param(2, 3),
    pytest.param(0, 0, id="zero"),
    pytest.param(-1, 1)
])
def test_add(setup, data1, data2):
    assert add(data1, data2) == (data1 + data2)

@pytest.mark.parametrize("data1, data2", [
    (5, 3),
    (0, 0),
    (1, 1)
])
def test_subtract(setup, data1, data2):
    assert subtract(data1, data2) == (data1 - data2)

<実行結果>

collected 6 items

tests/test_main.py
テストを開始します
.
テストを終了します

テストを開始します
.
テストを終了します

テストを開始します
.
テストを終了します

テストを開始します
.
テストを終了します

テストを開始します
.
テストを終了します

テストを開始します
.
テストを終了します

assertの記載は減りましたが、上記のようにただ実行するだけだと、実行したいデータのみをテストすることができないため、idを設定して下記のようなコマンドを打つことで一致するものを実行できます。

Copy code
pytest -k "test_add[zero]"

raise

raiseは例外を発生させるテストケースを書く際に pytest.raises() を使用します。これにより、特定の例外が発生するかどうかをテストできます。
この時指定された例外が発生しなかった場合、テストは失敗します。
<使用例>

tests/test_main.py
@pytest.fixture(scope="function")
def setup():
    # 前処理
    print("\nテストを開始します")

    # 各テスト実行
    yield
    
    # 後処理
    print("\nテストを終了します")

def test_add(setup):
    try:
        assert add(2, 3) == 5
        assert add(0, 0) == 0
        assert add(-1, "e") == 0
    except AssertionError as e:
        pytest.fail(f"AssertionError: {e}")

def test_subtract(setup):
    with pytest.raises(ValueError):
        subtract(1, 1)

<実行結果>

collected 2 items

tests/test_main.py
テストを開始します
F
テストを終了します

テストを開始します
F
テストを終了します


========================================= FAILURES =========================================
_________________________________________ test_add _________________________________________

setup = None

    def test_add(setup):
        try:
            assert add(2, 3) == 5
            assert add(0, 0) == 0
>           assert add(-1, "e") == 0

tests/test_main.py:22:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

x = -1, y = 'e'

    def add(x, y):
>       return x + y
E       TypeError: unsupported operand type(s) for +: 'int' and 'str'

main.py:2: TypeError
______________________________________ test_subtract _______________________________________

setup = None

    def test_subtract(setup):
>       with pytest.raises(ValueError):
E       Failed: DID NOT RAISE <class 'ValueError'>

tests/test_main.py:27: Failed
================================= short test summary info ==================================
FAILED tests/test_main.py::test_add - TypeError: unsupported operand type(s) for +: 'int' and 'str'
FAILED tests/test_main.py::test_subtract - Failed: DID NOT RAISE <class 'ValueError'>       
==================================== 2 failed in 0.16s =====================================

test_subtract関数ではraiseを使用して、ValueErrorが出ることを望んでいましたが予測したエラーを取得することができなかったのでテスト失敗となりました。
test_add関数ではraiseを使用せずにtry:exceptでエラーを出力するものですが、こちらの方が柔軟にエラーを出力することができました。

まとめ

構成を考えずにひたすら書き続けたのでとても見づらいですが、少しでも参考になれば幸いです。
そして、はじめにたてた下記目標は達成できたと思います。

  1. pytestのtestコードをある程度読めるようになる。
  2. 主要なツールを用いて簡単なtestコードを書けるようになる。
  3. pytestさんと友好を深める。
    今後もpytestさんとより仲良くなるために、細かいところの学習を進めていきます。
    ありがとうございました。

参考

https://docs.pytest.org/en/8.1.x/
https://www.oio-blog.com/contents/pytest
https://zenn.dev/onigiri_w2/articles/5e6cf4d3ba9ed5

Discussion