できる!Djangoでテスト!(2025)

tell-k
DjangoCongress JP 2025 (2025.02.22)

おまえ誰よ?

_images/tell-k.png

ビープラウド- Pythonメインの受託開発

connpass - エンジニアをつなぐIT勉強会支援プラットフォーム

PyQ - Pythonオンライン学習サービス

TRACERY - システム開発のためのドキュメントサービス

目的/動機

対象

今日の目標

_images/kanzenrikai.png

あーなるほどね。完全に理解した

主な参考文献

前提&対象外

テストの種類

ユニットテストに期待すること

「自分が書いたコードが期待通りに動いている」ことを確認する

Developer Testing

目次

pytest(pytest-django)

なぜ pytest なのか?

  • ツール特有の書き方を覚える必要がない
  • pytest とコマンドを打つだけでテストを自動収集してくれる
  • assert の挙動がカスタマイズされているのでエラーがみやすい

テスト設置場所

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

unittestの場合

# 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)

pytestの場合

# 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

書き方がシンプルになる

Pylons 単体テストガイドライン

テスト対象をモジュールのスコープでインポートしない

# 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):

テストケースは、 1つのことだけをテストする

# 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

Assertion Roulette

このような場合

# 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'

Parameterized Test

# 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

Djangoモデルに依存するテストケース

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

pytest実行時の注意点

どこまでユニットテストの対象にすべきか?

フィクスチャー

フィクスチャー

フィクスチャー

# 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

self属性でセットアップを共有しない

self属性でセットアップを共有しない

# 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

Djangoモデルのフィクスチャー

factory_boy

# 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)

factroy_boy のハマりポイント1

# テスト対象
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)

factroy_boy のハマりポイント1

# 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)

factroy_boy のハマりポイント2

# 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)

factroy_boy のハマりポイント2

# Bad ----
def test_check_hoge(self, target):

    hoge = HogeFactory(
      name="hoge",
    )

    expected = "this is valid hoge"
    assert expected == target(hoge)

モック

モック

Test Double

_images/test_double.gif

Test Double



pytest-mock

# 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!

patch の ハマりポイント

# egg.py  ---
- import spam
+ from spam import say_spam

def echo():
-   return spam.say_spam()
+   return say_spam()
# Good

- with mock.patch('spam.say_spam', return_value="Patched!"):
+ with mock.patch('egg.say_spam', return_value="Patched!"):
     print(say_egg()) # => Patched!

モック その他

モック その他2

可能な限り簡潔に

コードカバレッジ

コードカバレッジ

コードカバレッジの分類

カバレッジの計測ツール

[tool.coverage.report]
omit = [
  "*/migrations/*",
  "apps/settings/*",
  "apps/manage.py"
]

カバレッジ

$ pytest --cov apps --cov-report term-missing
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

失敗したテストを最初に実行する

$ pytest --reuse-db --ff -x apps/account/tests/test_models.py

システム全体での カバレッジ 100% に固執しない

先人たちのお言葉

まとめ

Kent Beck のお言葉

参考

ご静聴ありがとうございました

雑多なネタ

テストランダムに実行する(pytest-randomly)

3Aスタイルでテストを書こう

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')

viewのテストどうしてる?

そもそもviewのテストは

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')

Eメール送信のテスト

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'

Django コマンドのテスト

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)

ログ/標準出力の確認

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の非同期タスクのユニットテスト

どうすれば良いのか?

tox

テストの高速化

トピック

Use the left and right arrow keys or click the left and right edges of the page to navigate between slides.
(Press 'H' or navigate to hide this message.)