「自分が書いたコードが期待通りに動いている」ことを確認する
via. 第3回 「テスト」という言葉について
pytest
は人気のライブラリでしたpytest
および pytest-django
を採用してるプロジェクトが私の周りではあまりありませんでした
- ツール特有の書き方を覚える必要がない
- pytest とコマンドを打つだけでテストを自動収集してくれる
- assert の挙動がカスタマイズされているのでエラーがみやすい
tests
パッケージを用意spam
├── __init__.py
├── admin.py
├── apps.py
├── forms.py
├── models.py
├── utils.py
├── urls.py
├── views.py
└── tests < -- here
├── __init__.py
├── test_admin.py
├── test_forms.py
├── test_models.py
├── test_utils.py
└── test_views.py
# spam/utils.py ----
from datetime import date
def diff_days(from_date: date, to_date: date) -> int | None:
""" 日付の差分日数を返す """
if from_date >= to_date:
return None
return (to_date - from_date).days
# Usage --
date1 = date(2025, 1, 1)
date2 = date(2025, 1, 6)
print(diff_days(date1, date2)) # => 5
print(diff_days(date2, date1)) # => None
# spam/tests/test_utils.py ----
import unittest
from datetime import date
class TestDiffDays(unittest.TestCase):
def _callFUT(self, from_date, to_date):
from spam import diff_days
return diff_days(from_date, to_date)
def test_valid_case(self):
actual = self._callFUT(date(2018, 1, 1), date(2018, 1, 6))
self.assertEqual(5, actual)
def test_none_case(self):
actual = self._callFUT(date(2018, 1, 6), date(2018, 1, 1))
self.assertIsNone(actual)
# spam/tests/test_utils.py ----
import pytest
from datetime import date
class TestDiffDays: # <- 継承不要
@pytest.fixture
def target(self):
from spam import diff_days
return diff_days
def test_valid_case(self, target):
actual = target(date(2018, 1, 1), date(2018, 1, 6))
assert 5 == actual # <- assert でOK
def test_none_case(self, target):
actual = target(date(2018, 1, 6), date(2018, 1, 1))
assert actual is None
assert
の挙動が変えられてるので、失敗した時の差分がとても見やすいassertXXXX
のメソッド群を覚える必要がない# Pylonsのガイドラインでは `_callFUT` メソッド名
# FUT = Function Under the Test = テスト対象の関数
@pytest.fixture
def target(self):
from spam import diff_days
return diff_days
# キーワード引数として自動的に渡ってくる
def test_valid_case(self, target):
# Bad ----
from spam import diff_days # ImporErrorになるとする
class TestDiffDays:
def test_valid_case(self):
# ↓ 関係ないテストも落ちてしまう
class TestOther:
def test_other(self):
# Bad ----
def test_all_test_cases(self, target):
# from_date < to_date
actual = target(2018, 1, 1), date(2018, 1, 6))
assert actual == 5
# from_date >= to_date
actual = target(date(2018, 1, 6), date(2018, 1, 1))
assert actual is None
def test_boundary_case1(self, target):
# 1が返る境界値をテストする
actual = target(date(2018, 1, 1), date(2018, 1, 2))
assert actual == 1
def test_boundary_case2(self, target):
# Noneが返る境界値をテストする
actual = target(date(2018, 1, 1), date(2018, 1, 1))
assert actual is None
このような場合
# Bad --
def test_say_hello(self, target):
assert target(None) == 'hello tell-k' # 1. ここで失敗
assert target('hirokiky') == 'hello hirokiky' # 2. 以後のアサーションは無視
assert target('django') == 'hello django'
assert target('kashew') == 'hello kashew'
# Good --
@pytest.mark.parametrize("input_str,expected", [
(None, "hello tell-k'"), # このテストが失敗しても他のテストは実行される
("hirokiky", "hello hirokiky'"),
("django", "hello django'"),
("kashew", "hello kashew'"),
])
def test_say_hello(self, input_str, expected):
assert target(input_str) == expected
pytest.mark.django_db
というマーカーを利用します。pytest.mark.django_db
マーカーはデコレーターとして利用できます。import pytest
from sample.models import Item
# ↓ このクラステストケースが実行されるたびにDBをクリアしてくれる
@pytest.mark.django_db
class TestSample:
def test_one(self):
Item.objects.create(name='name1')
assert 1 == Item.objects.all()
# テストケースが終わるとDBの中身はクリア(rollbackされる)
def test_two(self):
Item.objects.create(name='name1')
assert 1 == Item.objects.all()
# モジュール全体でマーカーが利用される
pytestmark = pytest.mark.django_db
# このクラスのみマーカー適用
@pytest.mark.django_db
class TestSample:
# このメソッドのみマーカー適用
@pytest.mark.django_db
def test_one(self):
Item.objects.create(name='name1')
assert 1 == Item.objects.all()
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "test.settings"
# -- recommended but optional:
python_files = ["test_*.py", "*_test.py", "testing/python/*.py"]
$ pytest
TestXXX
という風に Test
プレフィックスが必要test_
というプレフィックスが必要
XxxTest
のような名前にしてしまうと無視されてしまうpytest.fixture`
もそのままフィクスチャーsetup_method
… テストケース実行前の処理teardown_method
… テストケース実行後の処理# Bad ----
class TestDoSomething:
@pytest.fixture
def target(self):
from sample.api import do_something
return do_something
def setup_method(self, method):
self.good_data = make_fixture_data(good=True) # フィクスチャーの生成
self.bad_data = make_fixture_data(bad=True)
def teardown_method(self, method):
destory_fixture(self.good_data) # フィクスチャーの破棄
destory_fixture(self.bad_data)
def test_do_something_ok(self, target):
assert target(self.good_data) is True
def test_do_something_ng(self, False):
assert target(self.bad_data) is False
# Good --
@pytest.fixture
def good_data():
data = make_fixture_data(good=True)
yield data # ジェネレータを使うことで後処理を挟める
destory_fixture(good_data)
@pytest.fixture
def bad_data():
data = make_fixture_data(bad=True)
yield data
destory_fixture(bad_data)
class TestDoSomething:
@pytest.fixture
def target(self):
from sample.api import do_something
return do_something
def test_do_something_ok(self, target, good_data): # good_dataのみが生成される
assert target(good_data) is True
def test_do_something_ng(self, target, bad_data): # bad_dataのみが生成される
assert target(bad_data) is False
# sample/tests/factories.py
import factory
class ItemFactory(factory.django.DjangoModelFactory):
name = factory.Sequence(lambda n: 'name{}'.format(n))
email = factory.Sequence(lambda n: 'hoge{}@example.com'.format(n))
price = 100
owner = factory.SubFactory("account.tests.factories.UserFactory")
class Meta:
model = "sample.Item"
item = ItemFactory()
print(item.name) # => name0
print(item.user) # => User object
# フィールドの値も指定できる
ItemFactory(name='newitem')
# 一気に複数オブジェクトを生成することもできる
ItemFactory.create_batch(10)
# テスト対象
def get_display_price(item):
return "{}円".format(item.price)
# Bad --
def test_display_price(self, target):
item = ItemFactory() # <- ItemFactory.price 100から変更されたらテスト失敗
expected = '100円'
assert expected == target(item)
# Good --
def test_display_price(self, target):
item = ItemFactory(price=100) # <- 100固定
expected = '100円'
assert expected == target(item)
# Good --
def test_display_price(self, target):
item = ItemFactory()
expected = '{}円'.format(item.price) # <- item.price を使って期待値を生成
assert expected == target(item)
# Bad ----
def test_check_hoge(self, target):
piyo = PiyoFactory(
name="piyo",
attr1="attr1",
)
fuga = FugaFactory(
piyo=piyo,
name="fuga",
)
# HogeFactoryのモデルが欲しいだけなのに
# 外部キーで繋がるモデルまで用意している
hoge = HogeFactory(
fuga=fuga,
name="hoge",
)
expected = "this is valid hoge"
assert expected == target(hoge)
SubFactory
や Sequence
を活用し 引数なし でモデルを生成できると良いです。# Bad ----
def test_check_hoge(self, target):
hoge = HogeFactory(
name="hoge",
)
expected = "this is valid hoge"
assert expected == target(hoge)
pytest-mock
は unittest.mock
を pytest
で使いやすくするラップしたライブラリです。# sample/api.py ---
from item.api import calc_tax_included_price
# テスト対象
def get_display_price(item):
price = calc_tax_included_price(item) # <- これをモック(Test Stub)に置き換える
return "{}円".format(price)
def test_display_price(self, target, mocker): # <- mocker が自動で渡される
item = ItemFactory()
# patch を通して 108 という間接入力値 をテスト対象(get_display_price) に渡してる
with mocker.patch('sample.api.calc_tax_included_price', return_value=108) as m:
expected = '108円'
assert expected == target(item) # => OK
# calc_tax_included_price に item引数が渡ったかチェック
m.assert_called_with(item)
patch
がうまく当たらないケースがある# egg.py ---
import spam
def say_egg():
return spam.say_spam() # <- patch対象
from unittest import mock
from egg import say_egg
with mock.patch('spam.say_spam', return_value="Patched!"):
print(say_egg()) # => Patched!
# egg.py ---
- import spam
+ from spam import say_spam
def echo():
- return spam.say_spam()
+ return say_spam()
from import
で importされたものは、元のモジュールから切り離されるsay_egg
) が利用してるものに patch
をあてる。# Good
- with mock.patch('spam.say_spam', return_value="Patched!"):
+ with mock.patch('egg.say_spam', return_value="Patched!"):
print(say_egg()) # => Patched!
patch
の影響下を局所化する意味でも import されてるところで patchする方が良いです。datetime.now
はpatchできない
django.util.timezone.now
は patch可能- Python: freezegun で時刻のテストを楽に書く
requests
)をモックしたい
coverage
を pytest
で使えるようにしたライブラリpyproject.tmol
に除外対象を設定すると良いです[tool.coverage.report]
omit = [
"*/migrations/*",
"apps/settings/*",
"apps/manage.py"
]
$ pytest --cov apps --cov-report term-missing
--cov-branch
オプションをつけて実行しますName Stmts Miss Cover Missing
-----------------------------------------------------------
account/__init__.py 1 0 100%
account/admin.py 145 22 85% 19, 24-26, 59, 63-64, 100, 104-105, 137, 141-142, 173, 177-178, 209, 213-214, 247, 251-252
account/apps.py 9 0 100%
account/forms.py 29 0 100%
account/models.py 239 0 100%
account/views.py 150 5 97% 59, 101, 199
〜 省略 〜
-----------------------------------------------------------
TOTAL 28606 1240 96%
- テストがない ところがわかる
- 素早くテストも終わる
# 特定のモジュール
$ pytest apps/account/tests/test_models.py
# 特定のテストクラス
$ pytest apps/account/tests/test_models.py::TestSpamClass
# 特定のテストケース
$ pytest apps/account/tests/test_models.py::TestSpamClass::test_spam
--reuse-db
… DBを破棄せずに再利用するオプション--ff
… 直前に失敗したテストを最初に実行する-x
… テストケースが失敗したらその時点でテストを止める$ pytest --reuse-db --ff -x apps/account/tests/test_models.py
pytest-randomly
は テストケースをランダムな実行順で実行するdef test_display_item(self, client, target):
# arrange ---
item = ItemFactory()
# act ---
res = client.get(target(item.id))
# assert ---
assert item.id == res.context['item'].id
assert 200 == res.status_code
assertTemplateUsed(res, 'item/detail.html')
pytest
に client
フィクスチャがあるのでそれを使うそもそもviewのテストは
import pytest
from pytest_django.asserts import assertTemplateUsed
@pytest.mark.django_db
class TestItemDetailView:
@pytest.fixture
def target(self):
def _inner(pk):
return reverse('item:detail', kwargs={'pk': pk})
return _inner
def test_not_found(self, client, target):
res = client.get(target(1))
assert 404 == res.status_code
def test_display_item(self, client, target):
item = ItemFactory()
res = client.get(target(item.id))
assert item.id == res.context['item'].id
assert 200 == res.status_code
assertTemplateUsed(res, 'item/detail.html')
pytest
に mailoutbox
フィクスチャがあるのでそれを使うfrom django.core import mail
from django.test import TestCase
class TestSendEmail():
def test_send_email(self, mailoutbox):
mail.send_mail('subject',
'body.',
'from@example.com',
['to@example.com'], fail_silently=False)
assert len(mailoutbox) == 1
assert mailoutbox[0].subject == 'subject'
from django.core.management import call_command
class TestSpamCommand:
@pytest.fixture
def target(self):
def _inner(*args, **kwargs):
return call_command('spam', *args, **kwargs)
return _inner
def test_spam_command(self, target):
target(ham=1)
caplog
という フィクスチャーでログ出力をチェックできます。def test_baz(caplog):
func_under_test()
for record in caplog.records:
assert record.levelname != "CRITICAL"
assert "wally" not in caplog.text
settings
フィクスチャdef test_with_specific_settings(settings):
settings.USE_TZ = True
assert settings.USE_TZ
どうすれば良いのか?
CELERY_ALWAYS_EAGER = True
tox-uv
を使うと virtualenv
の代わりに uv
で環境を構築してくれるので速くなりますnox
という pythonでかけるtoxみたいなツールも最近人気です。あまり使ったことないです。トピック
pytest-xdist
で並列実行