😊

pytestを触ってみた(備忘録)

2024/07/29に公開

pytestを触ってみた(備忘録)

最近、業務でpythonのテストツールのpytestを触る機会があったので備忘録までに書いていきたいと思います。

pytestってなに?

pytestは、Pythonのためのシンプルで強力なテストフレームワークであり、使いやすい構文、詳細なエラーレポート、フィクスチャやパラメータ化テストのサポートなどの機能を提供します。

なにができる

単体テストの実行:

関数やクラスなどの小さな単位のテストを簡単に実行できます。

sampls.py
def add_one(x):
    return x + 1
test_sample.py
from sample import add_one

def test_add_one():
    assert add_one(1) == 2
    assert add_one(-1) == 0
    assert add_one(0) == 1

対象のディレクトリに移動して

pytest

で実行ができます。

============================= test session starts =============================
platform linux -- Python 3.x.x, pytest-6.x.x, py-1.x.x, pluggy-0.x.x
collected 1 item

test_sample.py .                                                           [100%]

============================== 1 passed in 0.12s ==============================

こんな使い方ができる

feature

  • @pytest.fixtureと記述して各テストの前に実行したい関数を指定することができます。
  • yieldを使うと途中で処理を中断することができます。
  • @pytest.fixture(scope='function')
スコープ

フィクスチャにはスコープ(scope)という概念があります。

スコープを理解すると、フィクスチャ関数が実行される粒度を制御出来るようになります。

まず、以下にスコープの種類と実行粒度をまとめます。

スコープ名 実行粒度
function テストケースごとに1回実行される(デフォルト)
class テストクラス全体で1回実行される
module テストファイル全体で1回実行される
session テスト全体で1回だけ実行される
test_sample.py
import pytest

from app import Users


# これがフィクスチャ
@pytest.fixture
def db():
    print('\n db関数が呼ばれました')
    users = Users()
    users.insert('Bob', 10)
    users.insert('Alice', 12)
    yield users
    print('\n db関数が終了しました')


# dbフィクスチャを利用するテストケース
def test_one(db):
    assert db.get(1)['name'] == 'Bob'


# フィクスチャは複数のテストケースで共有できる
def test_two(db):
    assert db.get(2)['name'] == 'Alice'


# クラスベースのテストでも利用できる
class TestUers:
    def test_one(self, db):
        assert db.get(1)['name'] == 'Bob'import pytest

from app import Users


# これがフィクスチャ
@pytest.fixture
def db():
    print('\n db関数が呼ばれました')
    users = Users()
    users.insert('Bob', 10)
    users.insert('Alice', 12)
    yield users
    print('\n db関数が終了しました')


# dbフィクスチャを利用するテストケース
def test_one(db):
    assert db.get(1)['name'] == 'Bob'


# フィクスチャは複数のテストケースで共有できる
def test_two(db):
    assert db.get(2)['name'] == 'Alice'


# クラスベースのテストでも利用できる
class TestUers:
    def test_one(self, db):
        assert db.get(1)['name'] == 'Bob'
app.py
class Users:
    def __init__(self):
        self.last_insert_id = 0
        self.rows = {}

    def insert(self, name, age):
        self.last_insert_id += 1
        self.rows[self.last_insert_id] = {
            'id': self.last_insert_id,
            'name': name,
            'age': age,
        }

    def get(self, id_):
        return self.rows[id_]
platform darwin -- Python 3.12.4, pytest-8.2.2, pluggy-1.5.0 -- /Library/Frameworks/Python.framework/Versions/3.12/bin/python3.12
cachedir: .pytest_cache
rootdir: /Users/tokunagamutsuki/workspace/studying/pytest
plugins: clarity-1.0.1, mock-3.14.0
collected 3 items                                                                                                                                                           

test_sample.py::test_one 
 db関数が呼ばれました
PASSED
 db関数が終了しました

test_sample.py::test_two 
 db関数が呼ばれました
PASSED
 db関数が終了しました

test_sample.py::TestUers::test_one 
 db関数が呼ばれました
PASSED
 db関数が終了しました

db関数が各処理の前と後に呼ばれていることがわかります。

オプション

--collect-only 実行予定のテストケースを表示する

テスト予定のテスト一覧を表示することができます。

 pytest --collect-only  
<Dir pytest>
  <Module test_sample.py>
    <Function test_success>
    <Function test_success2>
    <Function test_failed>

※テストの実行はされません

-k 特定のケースを実行する

sample.py
def test_success():
    assert(1+2) == 3

def test_success2():
    assert(2+3) == 5

def test_failed():
    assert(1+2) == 4
// test_successのみが実行される
pytest -k test_success
// 文字の部分一致も対象にできる
pytest --collect-only  -k su
// テストケースにsuが含まれるものだけが実行予定である
<Dir pytest>
  <Module test_sample.py>
    <Function test_success>
    <Function test_success2>

-m特定のカテゴリを実行する

sample.py
import pytest

@pytest.mark.success
def test_success():
    assert(1+2) == 3
@pytest.mark.success
def test_success2():
    assert(2+3) == 5

def test_failed():
    assert(1+2) == 4
// @pytest.mark.successが記載されているテストのみが実行される
pytest -m success

-sprintを出力する

pytestのコマンドをつけないとprint部分が出力してくれない

test_sample.py
import pytest

@pytest.mark.success
def test_success():
    assert(1+2) == 3
@pytest.mark.success
def test_success2():
    print("test_success2が実行されました")
    assert(2+3) == 5

def test_failed():
    assert(1+2) == 4
pytest

print("test_success2が実行されました")が出力されない

============================================================================ test session starts ============================================================================
platform darwin -- Python 3.12.4, pytest-8.2.2, pluggy-1.5.0
rootdir: /workspace/studying/pytest
plugins: clarity-1.0.1, mock-3.14.0
collected 3 items                                                                                                                                                           

test_sample.py ..F                                                                                                                                                    [100%]

================================================================================= FAILURES ==================================================================================
________________________________________________________________________________ test_failed ________________________________________________________________________________

    def test_failed():
>       assert(1+2) == 4
E       assert (1 + 2) == 4

test_sample.py:12: AssertionError
============================================================================= warnings summary ==============================================================================
test_sample.py:3
  /workspace/studying/pytest/test_sample.py:3: PytestUnknownMarkWarning: Unknown pytest.mark.success - 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.success

test_sample.py:6
  /workspace/studying/pytest/test_sample.py:6: PytestUnknownMarkWarning: Unknown pytest.mark.success - 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.success

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================================================================== short test summary info ==========================================================================
FAILED test_sample.py::test_failed - assert (1 + 2) == 4
================================================================== 1 failed, 2 passed, 2 warnings in 0.03s ==================================================================

-sをつけて実行する

pytest -s
sample.py
KRQ2403-02:pytest tokunagamutsuki$ pytest -s
============================================================================ test session starts ============================================================================
platform darwin -- Python 3.12.4, pytest-8.2.2, pluggy-1.5.0
rootdir: /workspace/studying/pytest
plugins: clarity-1.0.1, mock-3.14.0
collected 3 items                                                                                                                                                           

test_sample.py .test_success2が実行されました
.F

================================================================================= FAILURES ==================================================================================
________________________________________________________________________________ test_failed ________________________________________________________________________________

    def test_failed():
>       assert(1+2) == 4
E       assert (1 + 2) == 4

test_sample.py:12: AssertionError
============================================================================= warnings summary ==============================================================================
test_sample.py:3
  /workspace/studying/pytest/test_sample.py:3: PytestUnknownMarkWarning: Unknown pytest.mark.success - 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.success

test_sample.py:6
  /workspace/studying/pytest/test_sample.py:6: PytestUnknownMarkWarning: Unknown pytest.mark.success - 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.success

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
========================================================================== short test summary info ==========================================================================
FAILED test_sample.py::test_failed - assert (1 + 2) == 4
================================================================== 1 failed, 2 passed, 2 warnings in 0.03s ==================================================================
KRQ2403-02:pytest tokunagamutsuki$ 

test_success2が実行されましたが出力されました。

|-x, --maxfail| n回数失敗したらテストを中止|

-v テストケースごとに PASSED/FAILED が表示される。

KRQ2403-02:pytest tokunagamutsuki$ pytest -v
========================================================================= test session starts =========================================================================
platform darwin -- Python 3.12.4, pytest-8.2.2, pluggy-1.5.0 -- /Library/Frameworks/Python.framework/Versions/3.12/bin/python3.12
cachedir: .pytest_cache
rootdir: /workspace/studying/pytest
plugins: clarity-1.0.1, mock-3.14.0
collected 3 items                                                                                                                                                     

test_sample.py::test_success PASSED                                                                                                                             [ 33%]
test_sample.py::test_success2 PASSED                                                                                                                            [ 66%]
test_sample.py::test_failed FAILED                                                                                                                              [100%]

============================================================================== FAILURES ===============================================================================
_____________________________________________________________________________ test_failed _____________________________________________________________________________

    def test_failed():
>       assert(1+2) == 4
E       assert (1 + 2) == 4

test_sample.py:12: AssertionError
========================================================================== warnings summary ===========================================================================
test_sample.py:3
  /workspace/studying/pytest/test_sample.py:3: PytestUnknownMarkWarning: Unknown pytest.mark.success - 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.success

test_sample.py:6
  /workspace/studying/pytest/test_sample.py:6: PytestUnknownMarkWarning: Unknown pytest.mark.success - 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.success

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
======================================================================= short test summary info =======================================================================
FAILED test_sample.py::test_failed - assert (1 + 2) == 4
=============================================================== 1 failed, 2 passed, 2 warnings in 0.03s ===============================================================

-if 前回NGだったケースだけテストする

last-failed (前回failedだったケース) のみ実施する。

pytest -v --lf

テスト時間を記録

テストコードの定義関数ごとの時間を表示する。
処理時間の結果について、1つの定義関数につき setup/call/teardown に分かれることに注意。

pytest -v --duration=0
study_pytest>pytest -v --duration=0
============================= test session starts =============================
platform win32 -- Python 3.6.3, pytest-3.4.2, py-1.5.2, pluggy-0.6.0 -- c:\users\xxx\appdata\local\programs\python\python36\python.exe
cachedir: .pytest_cache
rootdir: C:\Users\xxx\Documents\python\study_pytest, inifile:
collected 12 items

tests/test_calc.py::test_add_01 PASSED                                   [  8%]
tests/test_calc.py::test_add_02 PASSED                                   [ 16%]
tests/test_calc.py::test_dif_01 PASSED                                   [ 25%]
tests/test_calc.py::test_dif_02 PASSED                                   [ 33%]
tests/test_calc.py::test_seki_01 PASSED                                  [ 41%]
tests/test_calc.py::test_seki_02 PASSED                                  [ 50%]
tests/test_calc.py::test_shou_01 PASSED                                  [ 58%]
tests/test_calc.py::test_shou_02 PASSED                                  [ 66%]
tests/test_say.py::test_foo_say PASSED                                   [ 75%]
tests/test_say.py::test_foo_say2 FAILED                                  [ 83%]
tests/test_say.py::test_hoge_say PASSED                                  [ 91%]
tests/test_say.py::test_hoge_say2 PASSED                                 [100%]

=========================== slowest test durations ============================
0.00s call     tests/test_say.py::test_foo_say2
0.00s setup    tests/test_calc.py::test_add_01
0.00s teardown tests/test_say.py::test_foo_say
0.00s call     tests/test_say.py::test_hoge_say2
0.00s teardown tests/test_say.py::test_foo_say2
0.00s call     tests/test_calc.py::test_add_01
0.00s setup    tests/test_calc.py::test_add_02
0.00s call     tests/test_calc.py::test_seki_01
0.00s teardown tests/test_calc.py::test_shou_01
0.00s teardown tests/test_calc.py::test_dif_02
0.00s setup    tests/test_calc.py::test_dif_02
0.00s call     tests/test_say.py::test_foo_say
0.00s teardown tests/test_calc.py::test_shou_02
0.00s call     tests/test_say.py::test_hoge_say
0.00s call     tests/test_calc.py::test_shou_02
0.00s call     tests/test_calc.py::test_shou_01
0.00s call     tests/test_calc.py::test_seki_02
0.00s call     tests/test_calc.py::test_dif_02
0.00s call     tests/test_calc.py::test_dif_01
0.00s call     tests/test_calc.py::test_add_02
0.00s teardown tests/test_say.py::test_hoge_say2
0.00s setup    tests/test_say.py::test_hoge_say2
0.00s teardown tests/test_say.py::test_hoge_say
0.00s setup    tests/test_say.py::test_hoge_say
0.00s setup    tests/test_say.py::test_foo_say2
0.00s setup    tests/test_say.py::test_foo_say
0.00s setup    tests/test_calc.py::test_shou_02
0.00s setup    tests/test_calc.py::test_shou_01
0.00s teardown tests/test_calc.py::test_seki_02
0.00s setup    tests/test_calc.py::test_seki_02
0.00s teardown tests/test_calc.py::test_seki_01
0.00s setup    tests/test_calc.py::test_seki_01
0.00s teardown tests/test_calc.py::test_dif_01
0.00s setup    tests/test_calc.py::test_dif_01
0.00s teardown tests/test_calc.py::test_add_02
0.00s teardown tests/test_calc.py::test_add_01
================================== FAILURES ===================================
________________________________ test_foo_say2 ________________________________

    def test_foo_say2():
>       assert Foo().say2() == 'foo'
E       AssertionError: assert 'foo2' == 'foo'
E         - foo2
E         ?    -
E         + foo

tests\test_say.py:7: AssertionError
===================== 1 failed, 11 passed in 0.26 seconds =====================

-pdbテスト失敗時にデバッグモードにする

数を指定してテストを停止することができます。 Pytestはビルトインのデバッガー、PDB(Python Debugger)を用いてテストを実行していますが、 --pdbオプションでテストが任意の回数失敗した時点でテストを停止し、PDBプロンプトを表示します。

pytest -x --pdb   # テストに失敗した時点でテストを中止しPDBプロンプトを起動。
pytest --pdb --maxfail=3  # テストに3回通らなかった時点でテストを中止しPDBプロンプトを起動。

また、テストをbreakpoint()と記述するとその時点で処理を止めてデバックモードを使用できます。

import pytest

def test_success2():
    print("test_success2が実行されました")
    breakpoint()
    assert(2+3) == 5

デバッグモード時のコマンド
c: 次のブレークポイントまで実行を継続します。
s: 現在の行を実行し、関数呼び出しの中に入ります(ステップイン)。
n: 現在の行を実行し、次の行に進みます(ステップオーバー)。
r: 現在の関数の終了まで実行します。
l: 現在のソースコードの周辺行を表示します。
q: デバッガを終了します。

Mock

Mockは、外部の依存関係や副作用を持つコードをテストするために使用されます。例えば、API呼び出しやデータベースアクセスなどの部分をMockに置き換えることで、テストの実行が速くなり、また外部リソースに依存しないため、再現性の高いテストが可能になります。以下は具体例です。

外部API呼び出しのMock

import pytest
from unittest.mock import Mock, patch
import my_module

# 外部API呼び出しを行う関数
def send_email(api_client, email, subject, body):
    response = api_client.send(email, subject, body)
    return response.status_code

def test_send_email():
    # Mockオブジェクトの作成
    mock_sendgrid = Mock()
    # sendメソッドの戻り値を設定
    mock_sendgrid.send.return_value.status_code = 200

    # Mockを使って関数を呼び出し
    response_code = send_email(mock_sendgrid, 'test@example.com', 'Hello', 'This is a test')

    # 呼び出し回数と引数のチェック
    # assert_called_once_withは指定した引数で一度だけ呼ばれたかを検証する
    mock_sendgrid.send.assert_called_once_with('test@example.com', 'Hello', 'This is a test')
    # 戻り値のチェック
    assert response_code == 200

外部モジュールのMock

import pytest
from unittest.mock import patch
import requests

# 外部APIを呼び出す関数
def get_weather(api_url):
    response = requests.get(api_url)
    return response.json()

def test_get_weather():
    # requests.getをMockに置き換える
    with patch('requests.get') as mock_get:
        # Mockの戻り値を設定
        mock_get.return_value.json.return_value = {'weather': 'sunny'}

        # テスト対象の関数を呼び出し
        weather = get_weather('http://fakeapi.com/weather')

        # 呼び出し回数と引数のチェック
        mock_get.assert_called_once_with('http://fakeapi.com/weather')
        # 戻り値のチェック
        assert weather == {'weather': 'sunny'}

パラメータ化テスト

同じテストケースを異なるデータセットで繰り返し実行することができます。これにより、冗長なテストコードを書くことなく、複数のシナリオを簡単にテストできます。

sample.py
def add_one(input):
    return input + 1
import pytest
# test_add_oneの関数のinput,expectedの引数を指定して、想定したパラメータを自分で決めることができる
@pytest.mark.parametrize("input,expected", [
    (1, 2), # 一度目 input=1, expected=2
    (-1, 0),# 二度目 input=-1, expected=0
    (0, 1),# 三度目 input=0, expected=1
])
def test_add_one(input, expected):
    from sample import add_one
    assert add_one(input) == expected

テスト予定を確認してみると三回実行される予定である

 pytest --collect-only  
<Dir pytest>
  <Module test_sample.py>
    <Function test_add_one[1-2]>
    <Function test_add_one[-1-0]>
    <Function test_add_one[0-1]>

まとめ

pytestは非常に強力でありながら使いやすいテストフレームワークで、多くの機能を備えています。今回紹介した機能以外にも、テストの並列実行、プラグインの活用、カスタムフィクスチャの作成など、さらに多くの機能があります。pytestを使いこなして、品質の高いコードを書けるようになりたいです。

おまけ

pytestを便利に使う拡張機能もあるようですので自分好みにカスタマイズしてみると更に使いやすくなるかもしれません。
https://kdotdev.com/kdotdev/pytest-plugin

レスキューナウテックブログ

Discussion