장고 중급 #7 테스트 — Django TestCase, fixtures, pytest-django

7 분 소요

장고 중급의 마지막 글은 테스트 입니다. 빌트인 django.test.TestCase부터, 픽스처/팩토리 패턴, 그리고 사실상 표준이 된 pytest-django까지 한 호흡에 봅니다.

테스트가 있으면 #1 CBV의 권한 검사, #2 ORM의 쿼리 가공, #3 시그널의 부수효과 같은 지점에서 마음 놓고 리팩터링할 수 있습니다. 이 글이 그 기반입니다.

django.test.TestCase — 기본 #

장고는 표준 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 이름>) — 운영 데이터 안전
  • 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를 거쳐 뷰까지 실제로 호출됩니다.

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="둘째", 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 + 폼 #

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 #

테스트 데이터를 만드는 두 가지 방식입니다.

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 — 객체 생성 비용을 한 번만 치릅니다. 트랜잭션 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="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.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 b"내 글" 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

세 경우를 한 함수로. 실패 시 어떤 입력이 깨졌는지 명확하게 보여줍니다.

디렉터리 구조 추천 #

앱별 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 한 파일은 작은 앱에서만. 앱이 커지면 tests/ 디렉터리로 풉니다. 종류별 (test_models, test_views …)로 나누면 찾기 쉽습니다.

python manage.py test vs pytest #

manage.py testpytest
진입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 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_login, get_user_model() 사용
  • assertNumQueries — N+1 회귀 방어
  • setUp (매번) vs setUpTestData (클래스 1회, 빠름)
  • Fixtures — dumpdata/loaddata, JSON. 함정 많음
  • factory_boySequence, Faker, SubFactory, create_batch. 새 프로젝트의 답
  • pytest-django@pytest.mark.django_db, 빌트인 픽스처 (client, settings, rf)
  • conftest.py로 픽스처 공유, @pytest.mark.parametrize로 입력 다양화
  • 앱별 tests/ 디렉터리 + 종류별 분리
  • coverage로 측정, 80% 정도 목표
  • 가속: --keepdb, 병렬, mock, 마이그레이션 스킵

시리즈 마무리 #

여기까지가 장고 중급 7편 입니다.

기초의 도구를 본격 도구로 바꾸는 7편이었습니다. 다음은 장고 고급 — 비동기, 최적화, 캐싱, 채널, 보안 같은 주제로 들어갑니다.

다음 글(장고 고급 #1 Async views와 ASGI)부터는 장고의 비동기 모델 — async views, ASGI, 동기/비동기 코드 사이의 다리, 비동기 ORM 같은 주제를 봅니다. 모던 장고의 가장 큰 변화 중 하나입니다.

X