Django中級 #7 テスト — Django TestCase、fixtures、pytest-django
Django 中級の最後の記事は テスト です。ビルトイン django.test.TestCase から、フィクスチャ/ファクトリパターン、そして事実上の標準となった pytest-django まで一カ所で見ます。
テストがあれば #1 CBV の権限検査、#2 ORM のクエリ加工、#3 シグナル の副作用のようなところで 安心してリファクタリング できます。今回はそのテーマを扱います。
django.test.TestCase — 基本
#
Django は標準 unittest の上に DB とクライアントの統合 を乗せた TestCase を提供します。
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 名>) — 運用データが安全 assertEqual、assertTrue、assertRaisesなど 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 を経てビューまで実際に呼び出されます。
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 + フォーム #
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 つの方法。
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)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)setUp | setUpTestData | |
|---|---|---|
| 呼び出し頻度 | 各テストメソッド | クラスごとに 1 回 |
| 隔離 | 強い | savepoint で隔離 (オブジェクト自体は共有) |
| 速度 | 遅い | 速い |
基本は setUpTestData — オブジェクト生成コストを 1 度だけ払います。トランザクション savepoint で各テストは隔離されます。ただし setUpTestData はオブジェクトをクラス属性に置くので、そのオブジェクトを変更するテスト があれば同じクラスの他のテストに影響が出る可能性があるので注意が必要です。
Fixtures — JSON でデータを入れる #
昔から使われていたパターン。JSON / YAML でシードデータを書いておきます。
python manage.py dumpdata blog.Post --indent 2 > 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)python manage.py loaddata posts.jsonFixture の落とし穴 #
- データがコードから遠ざかる — JSON ファイルを別途管理する必要あり
- マイグレーション変更に脆弱 — フィールドが変われば全 fixture が壊れる
- 再利用が難しい — 「少し違うのが必要」がよくあるが毎回新しいファイル
この落とし穴のため実務では factory pattern がより好まれます。
Factory pattern — factory_boy
#
factory_boy は モデルインスタンスをコードで生成 するパターン。必要なときに少しずつ違うインスタンスを簡単に作れます。
pip install factory_boyimport 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]
DJANGO_SETTINGS_MODULE = config.settings
python_files = tests.py test_*.py *_tests.py[tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "config.settings"
python_files = ["tests.py", "test_*.py", "*_tests.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 がトランザクション + ロールバックを自動処理します。
ビルトインフィクスチャ #
| フィクスチャ | 意味 |
|---|---|
client | django.test.Client |
admin_client | superuser でログインされたクライアント |
db | DB アクセス有効化 (普通は mark で代用) |
transactional_db | 本物のトランザクション使用 (シグナルなどのテストに必要) |
settings | 設定の一時変更 |
rf | RequestFactory |
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 # レスポンス本文がある自前のフィクスチャ #
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.contentconftest.py にフィクスチャを集めれば 複数のテストファイルが自動で共有 します。依存性注入のように動きます。
パラメータ化 — 同じテストに複数の入力 #
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_slug3 つのケースを 1 つの関数で。失敗時にどの入力が壊れたか明確に見せてくれます。
ディレクトリ構造の推奨 #
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_models、test_views …) に分けると探しやすいです。
python manage.py test vs pytest
#
manage.py test | pytest | |
|---|---|---|
| 入口 | unittest ベース | pytest ベース |
| フィクスチャ | setUp、setUpTestData | @pytest.fixture、conftest.py |
| アサーション | self.assertEqual(...) | assert 一行 |
| パラメータ化 | 直接 (反復メソッド) | @pytest.mark.parametrize |
| プラグインエコシステム | 狭い | 非常に広い |
| 学習曲線 | 低い | 少し |
新規プロジェクトなら pytest-django が普通の答え です。既存プロジェクトが manage.py test ならそのまま置いて新しいテストだけ pytest で追加しても両方互換になります (pytest-django が TestCase も実行してくれます)。
Coverage — どこまでテストされているか #
pip install coveragecoverage run --source="." -m pytest
coverage report
coverage html # htmlcov/index.html にレポート[tool.coverage.run]
source = ["blog", "accounts"]
omit = [
"*/migrations/*",
"*/tests/*",
"manage.py",
"config/wsgi.py",
"config/asgi.py",
]
[tool.coverage.report]
fail_under = 80100% が目標ではありません。重要なビジネスロジック、権限分岐、エッジケース が押さえられているかの方が大事です。80% くらいが普通の答え。
テスト加速のヒント #
テストが遅くなると開発も遅くなります。よくある加速ツール:
--keepdb— DB 再生成コストの削減--parallel(manage.py test) /pytest-xdist(pytest) — 並列実行setUpTestDataの積極活用- factory_boy の
build()vscreate()— DB 保存しなくて良ければbuild()(メモリのみ) MIGRATION_MODULES = {"app": None}— テストでマイグレーションをスキップ (大きな効果)- 外部呼び出しの mock — ネットワーク/外部サービスは
unittest.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— 自動トランザクションロールバック、別途のテスト DBClient— HTTP 統合テスト、assertContains/assertRedirects/assertTemplateUsedclient.login/client.force_login、get_user_model()の使用assertNumQueries— N+1 回帰防御setUp(毎回) vssetUpTestData(クラス 1 回、速い)- Fixtures —
dumpdata/loaddata、JSON。落とし穴が多い factory_boy—Sequence、Faker、SubFactory、create_batch。新規プロジェクトの答えpytest-django—@pytest.mark.django_db、ビルトインフィクスチャ (client、settings、rf)conftest.pyでフィクスチャ共有、@pytest.mark.parametrizeで入力の多様化- アプリ別
tests/ディレクトリ + 種類別の分離 coverageで測定、80% 程度を目標- 加速:
--keepdb、並列、mock、マイグレーションスキップ
シリーズの締めくくり #
ここまでが Django 中級 7 編 です。
| # | 記事 |
|---|---|
| 1 | CBV の深堀り |
| 2 | ORM 中級 |
| 3 | Signals と Middleware |
| 4 | ユーザー / 権限 |
| 5 | メッセージ/セッション/クッキー |
| 6 | Static/Media 運用 |
| 7 | テスト ← この記事 |
基礎のツールを本格ツールに変える 7 編でした。次は Django 上級 — 非同期、最適化、キャッシング、Channels、セキュリティのような領域に入ります。
次回 (Django 上級 #1 Async views と ASGI) からは Django の非同期モデル — async views、ASGI、同期/非同期コード間の橋、非同期 ORM のような領域を見ます。モダン Django の最大の変化の 1 つです。