장고 중급 #7 테스트 — Django TestCase, fixtures, pytest-django
장고 중급의 마지막 글은 테스트 입니다. 빌트인 django.test.TestCase부터, 픽스처/팩토리 패턴, 그리고 사실상 표준이 된 pytest-django까지 한 호흡에 봅니다.
테스트가 있으면 #1 CBV의 권한 검사, #2 ORM의 쿼리 가공, #3 시그널의 부수효과 같은 지점에서 마음 놓고 리팩터링할 수 있습니다. 이 글이 그 기반입니다.
django.test.TestCase — 기본
#
장고는 표준 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="둘째", body="...", published=True)
response = self.client.get(reverse("post_list"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "첫 글")
self.assertContains(response, "둘째")
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
#
테스트 데이터를 만드는 두 가지 방식입니다.
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 — 객체 생성 비용을 한 번만 치릅니다. 트랜잭션 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="ko_KR")
author = factory.SubFactory(UserFactory)
published = True
class CommentFactory(factory.django.DjangoModelFactory):
class Meta:
model = Comment
post = factory.SubFactory(PostFactory)
body = factory.Faker("sentence", locale="ko_KR")사용 #
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 — 사실상 표준 #
장고 진영의 테스트는 점점 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 b"내 글" 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_slug세 경우를 한 함수로. 실패 시 어떤 입력이 깨졌는지 명확하게 보여줍니다.
디렉터리 구조 추천 #
blog/
├── models.py
├── views.py
├── ...
└── tests/
├── __init__.py
├── factories.py # 팩토리 모음
├── test_models.py
├── test_views.py
├── test_forms.py
└── test_signals.py기본 tests.py 한 파일은 작은 앱에서만. 앱이 커지면 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, 마이그레이션 스킵
시리즈 마무리 #
여기까지가 장고 중급 7편 입니다.
| # | 글 |
|---|---|
| 1 | CBV 깊이 |
| 2 | ORM 중급 |
| 3 | Signals와 Middleware |
| 4 | 사용자/권한 |
| 5 | 메시지/세션/쿠키 |
| 6 | Static/Media 운영 |
| 7 | 테스트 ← 이 글 |
기초의 도구를 본격 도구로 바꾸는 7편이었습니다. 다음은 장고 고급 — 비동기, 최적화, 캐싱, 채널, 보안 같은 주제로 들어갑니다.
다음 글(장고 고급 #1 Async views와 ASGI)부터는 장고의 비동기 모델 — async views, ASGI, 동기/비동기 코드 사이의 다리, 비동기 ORM 같은 주제를 봅니다. 모던 장고의 가장 큰 변화 중 하나입니다.