pytestを触ってみた(備忘録)
pytestを触ってみた(備忘録)
最近、業務でpythonのテストツールのpytest
を触る機会があったので備忘録までに書いていきたいと思います。
pytestってなに?
pytestは、Pythonのためのシンプルで強力なテストフレームワークであり、使いやすい構文、詳細なエラーレポート、フィクスチャやパラメータ化テストのサポートなどの機能を提供します。
なにができる
単体テストの実行:
関数やクラスなどの小さな単位のテストを簡単に実行できます。
def add_one(x):
return x + 1
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回だけ実行される |
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'
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
特定のケースを実行する
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
特定のカテゴリを実行する
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
-s
printを出力する
pytestのコマンドをつけないとprint部分が出力してくれない
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
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'}
パラメータ化テスト
同じテストケースを異なるデータセットで繰り返し実行することができます。これにより、冗長なテストコードを書くことなく、複数のシナリオを簡単にテストできます。
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