Django中級 #7 テスト — Django TestCase、fixtures、pytest-django

読了 8分

Django 中級の最後の記事は テスト です。ビルトイン django.test.TestCase から、フィクスチャ/ファクトリパターン、そして事実上の標準となった pytest-django まで一カ所で見ます。

テストがあれば #1 CBV の権限検査、#2 ORM のクエリ加工、#3 シグナル の副作用のようなところで 安心してリファクタリング できます。今回はそのテーマを扱います。

django.test.TestCase — 基本 #

Django は標準 unittest の上に DB とクライアントの統合 を乗せた TestCase を提供します。

blog/tests/test_models.py
from django.test import TestCase
from blog.models import Post

class PostModelTest(TestCase):
    def test_str(self):
        post = Post.objects.create(title="こんにちは", body="...")
        self.assertEqual(str(post), "こんにちは")

    def test_slug_auto(self):
        post = Post.objects.create(title="Hello World", body="...")
        self.assertEqual(post.slug, "hello-world")

主要な保証:

  • 各テストメソッドがトランザクションで包まれて自動ロールバック — あるテストのデータが次のテストに漏れない
  • テスト用 DB が別途生成される (test_<元 DB 名>) — 運用データが安全
  • assertEqualassertTrueassertRaises など unittest 標準メソッドをすべて使用可能

実行 #

テスト実行
python manage.py test
python manage.py test blog                    # アプリ単位
python manage.py test blog.tests.test_models  # モジュール単位
python manage.py test blog.tests.test_models.PostModelTest.test_slug_auto  # メソッド単位
python manage.py test --keepdb                # DB 再利用 (繰り返し実行を加速)
python manage.py test --parallel              # 並列実行

--keepdb はマイグレーションが安定した後の繰り返し実行で時間を大幅に削減します。

Client — HTTP 統合テスト #

TestCase の中に自動で入っている self.clientテスト用 HTTP クライアント です。URL conf を経てビューまで実際に呼び出されます。

blog/tests/test_views.py
from django.test import TestCase
from django.urls import reverse
from blog.models import Post

class PostListViewTest(TestCase):
    def test_get(self):
        Post.objects.create(title="最初の記事", body="...", published=True)
        Post.objects.create(title="2 番目", body="...", published=True)

        response = self.client.get(reverse("post_list"))

        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "最初の記事")
        self.assertContains(response, "2 番目")
        self.assertTemplateUsed(response, "blog/post_list.html")

よく使うアサーション:

アサーション意味
assertEqual(response.status_code, 200)ステータスコード
assertContains(response, text)本文にテキスト含む
assertNotContains(response, text)含まない
assertRedirects(response, url)リダイレクト検証
assertTemplateUsed(response, name)使用されたテンプレート
assertFormError(form, field, message)フォームエラー
assertNumQueries(N)クエリ数アサーション (#2 の N+1 回帰防御)

POST + フォーム #

POST テスト
def test_create(self):
    response = self.client.post(
        reverse("post_create"),
        data={"title": "新記事", "body": "本文"},
    )
    self.assertEqual(response.status_code, 302)
    self.assertEqual(Post.objects.count(), 1)
    post = Post.objects.first()
    self.assertEqual(post.title, "新記事")

ログイン — client.login #

ログイン後のテスト
from django.contrib.auth import get_user_model

class PostCreateViewTest(TestCase):
    def setUp(self):
        User = get_user_model()
        self.user = User.objects.create_user(
            username="curtis", password="testpass"
        )

    def test_login_required(self):
        response = self.client.get(reverse("post_create"))
        self.assertEqual(response.status_code, 302)   # ログインページへ

    def test_create_after_login(self):
        self.client.login(username="curtis", password="testpass")
        response = self.client.post(
            reverse("post_create"),
            data={"title": "記事", "body": "..."},
        )
        self.assertRedirects(response, reverse("post_list"))

client.login(...) またはより速い client.force_login(user) (パスワード検証省略)。

#4 ユーザー / 権限 で見た get_user_model() を常に使ってください。User を直接 import するとカスタム user model で壊れます。

assertNumQueries — N+1 回帰防御 #

クエリ数アサーション
def test_list_efficient(self):
    for i in range(10):
        post = Post.objects.create(title=f"記事{i}", body="...", published=True)
        for j in range(3):
            Comment.objects.create(post=post, body=f"コメント{j}")

    with self.assertNumQueries(2):   # 記事 1 回 + コメント prefetch 1 回
        response = self.client.get(reverse("post_list"))
        self.assertEqual(response.status_code, 200)

このテストが壊れたら N+1 回帰が発生したということになります。性能回帰をテストで防御 する強力なツールです。

setUp vs setUpTestData #

テストデータを作る 2 つの方法。

setUp — 各テストごとに実行
class PostTest(TestCase):
    def setUp(self):
        self.author = User.objects.create_user(username="a", password="p")
        self.post = Post.objects.create(title="t", body="b", author=self.author)
setUpTestData — クラス単位 1 回
class PostTest(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.author = User.objects.create_user(username="a", password="p")
        cls.post = Post.objects.create(title="t", body="b", author=cls.author)
setUpsetUpTestData
呼び出し頻度各テストメソッドクラスごとに 1 回
隔離強いsavepoint で隔離 (オブジェクト自体は共有)
速度遅い速い

基本は setUpTestData — オブジェクト生成コストを 1 度だけ払います。トランザクション savepoint で各テストは隔離されます。ただし setUpTestData はオブジェクトをクラス属性に置くので、そのオブジェクトを変更するテスト があれば同じクラスの他のテストに影響が出る可能性があるので注意が必要です。

Fixtures — JSON でデータを入れる #

昔から使われていたパターン。JSON / YAML でシードデータを書いておきます。

既存データを fixture に
python manage.py dumpdata blog.Post --indent 2 > blog/fixtures/posts.json
blog/fixtures/posts.json
[
  {
    "model": "blog.post",
    "pk": 1,
    "fields": {"title": "こんにちは", "body": "...", "published": true}
  }
]
テストでロード
class PostTest(TestCase):
    fixtures = ["posts.json"]

    def test_loaded(self):
        self.assertEqual(Post.objects.count(), 1)
開発 DB にロード
python manage.py loaddata posts.json

Fixture の落とし穴 #

  • データがコードから遠ざかる — JSON ファイルを別途管理する必要あり
  • マイグレーション変更に脆弱 — フィールドが変われば全 fixture が壊れる
  • 再利用が難しい — 「少し違うのが必要」がよくあるが毎回新しいファイル

この落とし穴のため実務では factory pattern がより好まれます

Factory pattern — factory_boy #

factory_boyモデルインスタンスをコードで生成 するパターン。必要なときに少しずつ違うインスタンスを簡単に作れます。

インストール
pip install factory_boy
blog/tests/factories.py
import factory
from django.contrib.auth import get_user_model
from blog.models import Post, Comment

User = get_user_model()


class UserFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = User

    username = factory.Sequence(lambda n: f"user{n}")
    email = factory.LazyAttribute(lambda u: f"{u.username}@example.com")
    password = factory.PostGenerationMethodCall("set_password", "testpass")


class PostFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Post

    title = factory.Sequence(lambda n: f"記事 {n}")
    body = factory.Faker("paragraph", locale="ja_JP")
    author = factory.SubFactory(UserFactory)
    published = True


class CommentFactory(factory.django.DjangoModelFactory):
    class Meta:
        model = Comment

    post = factory.SubFactory(PostFactory)
    body = factory.Faker("sentence", locale="ja_JP")

使用 #

テストで
def test_post_with_comments(self):
    post = PostFactory()                            # 全フィールド自動
    comments = CommentFactory.create_batch(5, post=post)
    self.assertEqual(post.comments.count(), 5)

def test_unpublished(self):
    post = PostFactory(published=False)             # 一部だけオーバーライド

def test_with_specific_author(self):
    author = UserFactory(username="curtis")
    post = PostFactory(author=author)

主要な武器:

  • Sequence — 呼び出しごとに 1、2、3 … 順番
  • Faker — 偽データ (名前、文章、メールなど)
  • SubFactory — リレーションフィールドは別のファクトリで自動生成
  • LazyAttribute — 他のフィールドを参照して計算
  • create_batch(N) — N 個一度に

Fixture の短所がほぼすべて消えます。新規プロジェクトは factory pattern が普通の答え。

pytest-django — 事実上の標準 #

Django 陣営のテストはどんどん pytest + pytest-django に移っています。短く、フィクスチャが強力です。

セットアップ #

インストール
pip install pytest pytest-django
# または
uv add --dev pytest pytest-django
pytest.ini (または pyproject.toml)
[pytest]
DJANGO_SETTINGS_MODULE = config.settings
python_files = tests.py test_*.py *_tests.py
pyproject.toml 形式
[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "config.settings"
python_files = ["tests.py", "test_*.py", "*_tests.py"]

最も短いテスト #

blog/tests/test_models.py
import pytest
from blog.models import Post

@pytest.mark.django_db
def test_post_str():
    post = Post.objects.create(title="こんにちは", body="...")
    assert str(post) == "こんにちは"

@pytest.mark.django_dbこのテストは DB アクセスが必要 であることを示します。pytest-django がトランザクション + ロールバックを自動処理します。

ビルトインフィクスチャ #

フィクスチャ意味
clientdjango.test.Client
admin_clientsuperuser でログインされたクライアント
dbDB アクセス有効化 (普通は mark で代用)
transactional_db本物のトランザクション使用 (シグナルなどのテストに必要)
settings設定の一時変更
rfRequestFactory
フィクスチャの使用
import pytest
from django.urls import reverse
from blog.tests.factories import PostFactory

@pytest.mark.django_db
def test_list(client):
    PostFactory.create_batch(3, published=True)
    response = client.get(reverse("post_list"))
    assert response.status_code == 200
    assert b"" in response.content   # レスポンス本文がある

自前のフィクスチャ #

conftest.py
import pytest
from blog.tests.factories import UserFactory, PostFactory


@pytest.fixture
def user():
    return UserFactory()


@pytest.fixture
def published_posts():
    return PostFactory.create_batch(5, published=True)


@pytest.fixture
def auth_client(client, user):
    client.force_login(user)
    return client
使用
@pytest.mark.django_db
def test_my_posts(auth_client, user):
    PostFactory.create_batch(3, author=user)
    PostFactory.create_batch(2)   # 別の作者
    response = auth_client.get(reverse("my_posts"))
    assert "私の記事".encode() in response.content

conftest.py にフィクスチャを集めれば 複数のテストファイルが自動で共有 します。依存性注入のように動きます。

パラメータ化 — 同じテストに複数の入力 #

parametrize
import pytest

@pytest.mark.parametrize("title,expected_slug", [
    ("Hello World", "hello-world"),
    ("こんにちは 世界", "こんにちは-世界"),
    ("  空白 ", "空白"),
])
@pytest.mark.django_db
def test_slug(title, expected_slug):
    post = Post.objects.create(title=title, body="...")
    assert post.slug == expected_slug

3 つのケースを 1 つの関数で。失敗時にどの入力が壊れたか明確に見せてくれます。

ディレクトリ構造の推奨 #

アプリ別 tests ディレクトリ
blog/
├── models.py
├── views.py
├── ...
└── tests/
    ├── __init__.py
    ├── factories.py        # ファクトリ集約
    ├── test_models.py
    ├── test_views.py
    ├── test_forms.py
    └── test_signals.py

デフォルトの tests.py 1 ファイルは小さなアプリだけ。アプリが大きくなったら tests/ ディレクトリ に分けます。種類別 (test_modelstest_views …) に分けると探しやすいです。

python manage.py test vs pytest #

manage.py testpytest
入口unittest ベースpytest ベース
フィクスチャsetUpsetUpTestData@pytest.fixture、conftest.py
アサーションself.assertEqual(...)assert 一行
パラメータ化直接 (反復メソッド)@pytest.mark.parametrize
プラグインエコシステム狭い非常に広い
学習曲線低い少し

新規プロジェクトなら pytest-django が普通の答え です。既存プロジェクトが manage.py test ならそのまま置いて新しいテストだけ pytest で追加しても両方互換になります (pytest-django が TestCase も実行してくれます)。

Coverage — どこまでテストされているか #

インストール
pip install coverage
実行
coverage run --source="." -m pytest
coverage report
coverage html       # htmlcov/index.html にレポート
pyproject.toml — 除外設定
[tool.coverage.run]
source = ["blog", "accounts"]
omit = [
    "*/migrations/*",
    "*/tests/*",
    "manage.py",
    "config/wsgi.py",
    "config/asgi.py",
]

[tool.coverage.report]
fail_under = 80

100% が目標ではありません。重要なビジネスロジック、権限分岐、エッジケース が押さえられているかの方が大事です。80% くらいが普通の答え。

テスト加速のヒント #

テストが遅くなると開発も遅くなります。よくある加速ツール:

  • --keepdb — DB 再生成コストの削減
  • --parallel (manage.py test) / pytest-xdist (pytest) — 並列実行
  • setUpTestData の積極活用
  • factory_boy の build() vs create() — DB 保存しなくて良ければ build() (メモリのみ)
  • MIGRATION_MODULES = {"app": None} — テストでマイグレーションをスキップ (大きな効果)
  • 外部呼び出しの mock — ネットワーク/外部サービスは unittest.mock で偽物に
mock の例
from unittest.mock import patch

@patch("blog.services.send_email")
def test_publish_sends_email(mock_send):
    post = PostFactory()
    post.publish()
    mock_send.assert_called_once_with(post)

まとめ #

今回押さえたもの:

  • django.test.TestCase — 自動トランザクションロールバック、別途のテスト DB
  • Client — HTTP 統合テスト、assertContains/assertRedirects/assertTemplateUsed
  • client.login / client.force_loginget_user_model() の使用
  • assertNumQueries — N+1 回帰防御
  • setUp (毎回) vs setUpTestData (クラス 1 回、速い)
  • Fixtures — dumpdata/loaddata、JSON。落とし穴が多い
  • factory_boySequenceFakerSubFactorycreate_batch。新規プロジェクトの答え
  • pytest-django@pytest.mark.django_db、ビルトインフィクスチャ (clientsettingsrf)
  • conftest.py でフィクスチャ共有、@pytest.mark.parametrize で入力の多様化
  • アプリ別 tests/ ディレクトリ + 種類別の分離
  • coverage で測定、80% 程度を目標
  • 加速: --keepdb、並列、mock、マイグレーションスキップ

シリーズの締めくくり #

ここまでが Django 中級 7 編 です。

基礎のツールを本格ツールに変える 7 編でした。次は Django 上級 — 非同期、最適化、キャッシング、Channels、セキュリティのような領域に入ります。

次回 (Django 上級 #1 Async views と ASGI) からは Django の非同期モデル — async views、ASGI、同期/非同期コード間の橋、非同期 ORM のような領域を見ます。モダン Django の最大の変化の 1 つです。

X