Django TDD, REST API

Haks.·2025년 2월 5일
1

Study

목록 보기
49/65

TDD(Test-Driven Development)

TDD(Test-Driven Development)는 소프트웨어 개발 방법론 중 하나로, 테스트를 먼저 작성하고 그 테스트를 통과하는 코드를 작성하는 방식을 의미합니다. TDD는 세 가지 주요 단계를 포함합니다.

Red: 테스트 코드를 작성한 후, 테스트를 실행하여 실패(red)하도록 합니다.
Green: 테스트를 통과하도록 최소한의 코드를 작성합니다.
Refactor: 테스트가 통과되면, 코드를 리팩토링하여 개선합니다.

  • 장점
    1. 목표가 명확
    2. 버그 방어막을 만듬
    3. 협업이 편해짐
    4. 코드 품질이 올라감
  • 단점
    1. 시간 소모: 초기 테스트 작성 및 반복적인 테스트 실행으로 인해 개발 시간이 늘어날 수 있습니다.
    2. 복잡성: 복잡한 로직의 경우, 테스트 케이스 작성이 어려울 수 있습니다.
    3. 과도한 테스트: 모든 코드를 테스트하려고 하면, 비효율적인 테스트 코드가 늘어날 수 있습니다

Django Test Code

  • Django는 자체적으로 강력한 테스트 프레임워크를 제공하며, unittest를 기반으로 동작합니다.
  • Django의 테스트는 애플리케이션의 다양한 부분을 자동화된 테스트로 확인할 수 있게 해줍니다.
  • Django에서 테스트는 각 앱의 tests.py 파일에 작성되며, 각 테스트는 TestCase 또는 APITestCase를 상속받아 작성합니다.

Django TDD

  1. 테스트 작성 (Red): 실패하는 테스트를 먼저 작성합니다.
def test_example(self):
    response = self.client.get('/my-url/')
    self.assertEqual(response.status_code, 200)
  1. 코드 작성 (Green): 테스트가 통과할 수 있도록 최소한의 코드를 작성합니다.
from django.http import HttpResponse

def my_view(request):
    return HttpResponse("Hello, World!")
  1. 리팩토링 (Refactor): 테스트가 통과한 후, 코드를 리팩토링하여 개선합니다.

주요 테스트 기법

모델 테스트

모델의 메서드, 속성, 데이터베이스 동작을 테스트합니다.

class MyModelTest(TestCase):
    def test_str_method(self):
        my_model = MyModel(name="Test")
        self.assertEqual(str(my_model), "Test")

뷰 테스트

뷰의 HTTP 응답, 템플릿 렌더링 등을 테스트합니다.

from django.urls import reverse

class MyViewTest(TestCase):
    def test_view_url_exists(self):
        response = self.client.get(reverse('my_view'))
        self.assertEqual(response.status_code, 200)

폼 테스트

폼의 유효성 검증을 테스트합니다.

from .forms import MyForm

class MyFormTest(TestCase):
    def test_valid_form(self):
        form = MyForm(data={'name': 'Test'})
        self.assertTrue(form.is_valid())

    def test_invalid_form(self):
        form = MyForm(data={'name': ''})
        self.assertFalse(form.is_valid())

시그널 테스트

시그널이 제대로 작동하는지 테스트합니다.

from django.db.models.signals import post_save
from .models import MyModel

class MySignalTest(TestCase):
    def test_post_save_signal(self):
        with self.assertLogs('myapp', level='INFO') as cm:
            MyModel.objects.create(name="Test")
        self.assertIn('Signal triggered', cm.output[0])

Django에서 테스트 실행

Django에서 테스트를 실행하려면, 아래 명령어를 사용합니다.

이 명령어는 tests.py에 정의된 모든 테스트를 실행하고, 결과를 출력합니다.

python manage.py test

특정 앱에 대한 테스트만 실행하려면 아래 명령어를 사용합니다.

이 명령어는 특정 앱의 tests.py에 정의된 모든 테스트를 실행하고, 결과를 출력합니다.

python manage.py test <app name>

🧑‍💻 실습

  1. python manage.py startapp blog -> test.py
  2. 기본 모델 제작
# utils/models.py

from django.db import models
class TimestampModel(models.Model):
    created_at = models.DateField(auto_now_add=True)
    updated_at = models.DateField(auto_now=True)
    class Meta:
        abstract = True

# blog/models.py

from django.db import models
from django.contrib.auth import get_user_model
from utils.models import TimestampModel

User = get_user_model()

class Blog(TimestampModel) :
    title = models.CharField('제목', max_length=100)
    content = models.TextField('본문')
    author = models.ForeignKey(User, on_delete=models.CASCADE)
    published_at = models.DateTimeField('배포일시', null=True)

    @property # 읽기전용 () 안붙여도되게하는 데코레이터
    def is_active(self) : # 배포가 된지 안된지 판단하는 부분
        now = timezone.now()

        if not self.published_at :
            return True

        return self.published_at <= now

    class Meta:
        verbose_name = '블로그'
        verbose_name_plural = '블로그 목록'
        ordering = ('-created_at', '-id')
  1. 모델이 잘 만들어 졌는지 확인하는 test.py 제작, test_ 가 없으면 일반 함수인지 알고 안도니까 주의
# blog/test.py

class BlogModelTestCase(TestCase):
    # 모델이 이 잘 만들어진지
    def setUp(self): # 테스트 데이터를 생성하는 곳, 제일 먼저 실행되는 함수
        user = User.objects.create(
            username='test',
            is_active=True
        )
        Blog.objects.create(
            title='배포',
            content='본문',
            author=user,
            # 우리가 만들어놓은 is_active 함수가 없으면 True로 해놨기에 배포 되어있음
        )
        future_published_at = timezone.now() + timedelta(days=30)
        Blog.objects.create(
            title='아직 배포안됨',
            content='본문',
            author = user,
            # 현재시간보다 많은 것을 배포날짜로 넣엇기에 is_active가 false일 것임
            published_at=future_published_at
        )
	def test_blog_is_published(self):
        published_blog = Blog.objects.get(title='배포')
        unpublished_blog = Blog.objects.get(title='아직 배포안됨')

        self.assertEqual(published_blog.is_active, True)
        self.assertEqual(unpublished_blog.is_active, False) # True면 오류발생
# 📌 assertEqual(a, b)는 두 값이 같은지 확인하는 테스트 함수
# 	•	unittest.TestCase에서 제공하는 단위 테스트 메서드입니다.
# 	•	a와 b의 값이 동일하면 테스트가 통과 ✅
# 	•	값이 다르면 테스트 실패 ❌
#
# self.assertEqual(a, b, "오류 메시지")
#
# •    a == b → ✅ 테스트
# 성공
# •    a != b → ❌ 테스트
# 실패(오류 발생)
  1. python manage.py test => 현재 is_active 가 없어서 에러 발생

커스텀 매니저를 만들어서 objects를 사용할수 있도록 설정

# blog/models.py
class PublishedManager(Manager): # Django Manager 상속
    def get_queryset(self):
        now = timezone.now()

        from django.db.models import Q
        return super().get_queryset().filter( # get_queryset 오버라이딩
            Q(published_at__isnull=True) | # 즉시활성화된글 publihsed_at = None
            Q(published_at__lte=now), # 현재시간보다 이전에 배포된 글
        )
  • ORM 사용 예시
from blog.models import Blog

# 🔹 배포된 블로그만 조회
published_blogs = Blog.objects.all()
print(published_blogs)  # PublishedManager가 적용된 쿼리셋 반환 (배포된 게시글만 조회)

# 🔹 모든 블로그 조회 (배포 여부 상관 없음)
all_blogs = Blog.all_objects.all()
print(all_blogs)  # 모든 Blog 객체 반환 (미래에 배포될 글도 포함)
  • test 해보기
    def test_blog_is_published(self):
        published_blog = Blog.objects.get(title='배포')
        # unpublished_blog = Blog.objects.get(title='아직 배포안됨') # 이제 objects에서는 가져올 수 없음
        unpublished_blog = Blog.all_objects.get(title='아직 배포안됨') # all_objects를 사용

        self.assertEqual(published_blog.is_active, True)
        self.assertEqual(unpublished_blog.is_active, False) # True면 오류발생

    def test_blog_manager(self):
        object_count = Blog.objects.count()
        all_object_count = Blog.all_objects.count()

        self.assertEqual(object_count, 1)
        self.assertEqual(all_object_count, 2)

View Test

  • blog_list -> get 요청시 -> blog_list에 1개의 context만 들어있음
  • Blog_create -> post요청시에 새로운 Blog가 생성됨 -> 3가지 방법
  • LoginRequired => 확인
    1. publihsed_at가 null인것
    2. published_at가 future 인것
    3. published_at가 현재보다 작은 것

List, Create

  1. test 제작
from datetime import datetime, timedelta
from http.client import responses

from django.conf import settings
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone

from blog.models import Blog

# blog/view_test.py

class BlogModelTestCase(TestCase):
    # 모델이 이 잘 만들어진지
    def setUp(self): # 테스트 데이터를 생성하는 곳, 아래보다 먼저
        user = User.objects.create(
            username='test',
            is_active=True
        )
        Blog.objects.create(
            title='배포',
            content='본문',
            author=user,
            # 우리가 만들어놓은 is_active 함수가 없으면 True로 해놨기에 배포 되어있음
        )

        future_published_at = timezone.now() + timedelta(days=30)
        Blog.objects.create(
            title='아직 배포안됨',
            content='본문',
            author = user,
            # 현재시간보다 많은 것을 배포날짜로 넣엇기에 is_active가 false일 것임
            published_at=future_published_at
        )

    def test_blog_list(self):
        response = self.client.get(reverse('blog_list'))

        blog_list = Blog.objects.all()
        # status code
        self.assertEqual(response.status_code, 200)
        # context
        self.assertEqual(response.context.get('blog_list').count(), blog_list.count())

    def test_blog_create_not_login(self):
        response = self.client.post(reverse('blog_create'))

        self.assertEqual(response.status_code, 302)
        self.assertEqual(response['Location'], settings.LOGIN_URL + '?next=/create/')

    def test_blog_create(self):
        user=User.objects.first()
        self.client.force_login(user) # 로그인 한것처럼 처리

        blog_count = Blog.objects.count() # 기존의 블로그 개수

        response = self.client.post(
            reverse('blog_create'),
            data={
                'title':'제목',
                'content':'본문',
                'published_at': '' # 배포일시 없음 -> 즉시 게시
            }
        )

        self.assertEqual(response.status_code, 302) # 생성후 리다이렉트가 발생되어야 함
        self.assertEqual(response['location'], reverse('blog_list')) # 정상적으로 리스트 페이지로 이동했는지 확인
        self.assertEqual(blog_count + 1, Blog.objects.all().count()) # 생성되엇으므로 기존보다 + 1

        all_count = Blog.all_objects.count()

        self.client.post(
            reverse('blog_create'),
            data={
                'title': '제목',
                'content': '본문',
                'published_at': timezone.now() + timedelta(days=2) # 아직 게시되지 않은 상태
            }
        )
        # 위에서 확인했으니 카운트가 안늘어나는지만 확인
        self.assertEqual(blog_count + 1, Blog.objects.all().count())  # 여기는 나타나지 않았으니 기존 + 1 이 끝
        self.assertEqual(all_count + 1, Blog.all_objects.all().count())

        self.client.post(
            reverse('blog_create'),
            data={
                'title': '제목',
                'content': '본문',
                'published_at': timezone.now() - timedelta(days=2) # 이전이니 게시
            }
        )

        self.assertEqual(blog_count + 2, Blog.objects.all().count()) # 게시한것 = 기존 + 2. 각각의 views.py 에서 blog_list, create_list 생성
```python
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.views.generic import ListView, CreateView

from blog.models import Blog


# Create your views here.
class BlogListView(ListView):
    model = Blog
    queryset = Blog.objects.all()
    template_name = 'list.html' # 앱 지정하기 싫으면 그냥 blog 폴더 안에 templates/list.html 하면 된다.

class BlogCreateView(LoginRequiredMixin, CreateView): # 처음에 로그인 기능을 안넣어줘서 테스트에 오류가 발생할것임
    model = Blog
    fields = ('title', 'content', 'published_at')
    template_name = 'form.html'

    # 폼이 valid 할때 어디로 가야할지 지정해주지 않아 status code 에서 오류 발생
    # form_valid 할떄 author 도 넣어줘야함
    def form_valid(self, form):
        self.object = form.save(commit=False)
        self.object.author = self.request.user
        self.object.save()

        return HttpResponseRedirect(reverse('blog_list'))

    # 이렇게 했는데 에러가 발생하는것은 form에서 에러가나서 그런것일 거임 => null=True, blank= True 를 해주지 않아서 그럼

Django Test Client (self.client)

Django의 TestCase에서 제공하는 테스트 클라이언트(self.client)
서버를 실행하지 않고 가상의 HTTP 요청을 보낼 수 있도록 해줍니다.


1. self.client.get(reverse('blog_list'))의 동작 과정

response = self.client.get(reverse('blog_list'))

📌 동작 원리

1️⃣ reverse('blog_list')blog_list URL 패턴을 실제 URL로 변환 ('/blogs/' 등)

2️⃣ self.client.get('/blogs/')GET 요청을 보냄 (브라우저에서 해당 URL을 방문하는 것과 동일)

3️⃣ response = self.client.get('/blogs/') → 요청 후 서버의 응답을 response 객체에 저장


2. self.client의 주요 기능

✅ 1. GET 요청 보내기

from django.test import TestCase
from django.urls import reverse

class BlogViewTestCase(TestCase):
    def test_blog_list_view(self):
        response = self.client.get(reverse('blog_list'))  # GET 요청 보내기
        self.assertEqual(response.status_code, 200)  # 200 OK 응답인지 확인

📌 설명

  • reverse('blog_list')를 사용하여 URL을 가져옴
  • self.client.get()을 사용하여 GET 요청을 보냄
  • 응답(response.status_code)이 200인지 테스트하여 정상 응답 확인

✅ 2. POST 요청 보내기

response = self.client.post(reverse('blog_create'), data={
    'title': 'New Blog',
    'content': 'Blog content',
})

📌 설명

  • reverse('blog_create')를 사용하여 블로그 생성 URL을 가져옴
  • self.client.post()를 사용하여 POST 요청을 보냄
  • data에 폼 데이터를 전달하여 실제 블로그 생성 요청과 동일한 테스트 수행

✅ 3. 로그인 테스트

self.client.login(username='testuser', password='password123')
response = self.client.get(reverse('profile'))
self.assertEqual(response.status_code, 200)

📌 설명

  • self.client.login()을 사용하면 로그인된 상태에서 요청을 보낼 수 있음
  • 로그인한 상태에서 reverse('profile') URL에 접근하여 200 OK 응답인지 확인

✅ 4. 로그인하지 않은 사용자 테스트

response = self.client.get(reverse('profile'))
self.assertEqual(response.status_code, 302)  # 로그인 안 하면 리다이렉트(302)

📌 설명

  • 로그인하지 않은 상태에서 reverse('profile')로 요청을 보냄
  • 로그인해야 접근할 수 있는 페이지라면 302 Redirect 응답이 올 것임

3. self.client 기능 정리

코드설명
self.client.get(reverse('blog_list'))블로그 목록 페이지에 GET 요청을 보냄
self.client.post(reverse('blog_create'), data={...})블로그 생성 페이지에 POST 요청을 보냄
self.client.login(username='testuser', password='1234')로그인한 상태에서 요청을 보냄
self.client.get(reverse('profile'))로그인 필요 페이지 접근 시 302 리다이렉트 확인

📌 결론

self.clientDjango 서버를 실행하지 않고도 테스트 환경에서 HTTP 요청을 보낼 수 있는 가짜 클라이언트입니다. 🚀

Django 모델에서 null=True vs blank=True 차이점

Django의 모델 필드에서 null=Trueblank=True는 서로 다른 역할을 합니다.

1. 차이점 요약

옵션설명
null=True데이터베이스에서 NULL 값을 허용
blank=True폼에서 빈 값 입력을 허용 (유효성 검사에서 허용)

2. 상세 설명

null=True

  • 데이터베이스 필드에서 NULL 값을 허용
  • 문자열 필드(CharField, TextField)에서는 권장되지 않음 (blank=True만 사용)
  • IntegerField, DateTimeField 등에서 사용 가능

예제

class Blog(models.Model):
    published_at = models.DateTimeField(null=True)  # 데이터베이스에서 NULL 허용

SQL 변환

published_at DATETIME NULL;

blank=True

  • 폼 입력에서 필드를 비워둘 수 있도록 허용
  • 모델 필드의 유효성 검사에만 영향을 줌
  • CharField, TextField에서 사용하면 빈 문자열('')이 저장됨 (NULL이 아님)

예제

class Blog(models.Model):
	title = models.CharField(max_length=100, blank=True) # 입력 폼에서 필수 아님

3. null=True, blank=True 같이 사용하기

  • null=True, blank=True함께 사용하면 데이터베이스에서 NULL 값을 저장할 수 있고, 입력 폼에서도 필수 입력이 아님
  • 주로 DateTimeField, IntegerField, ForeignKey 같은 숫자 또는 날짜 필드에서 사용됨

예제

class Blog(models.Model):
    published_at = models.DateTimeField(null=True, blank=True)  # NULL 허용 + 입력 필수 아님

동작

  • 입력값이 없으면 폼에서 유효성 검사 통과 (blank=True)
  • 데이터베이스에 NULL 저장 (null=True)

4. CharField, TextField에서 null=True 사용하면 안 되는 이유

  • Django에서는 CharField, TextField빈 값은 빈 문자열("") 로 저장됨
  • null=True를 사용하면 빈 값이 NULL 또는 ""로 혼용될 수 있어 데이터 처리 시 혼란을 초래할 수 있음

잘못된 예제

class Blog(models.Model):
    title = models.CharField(max_length=100, null=True, blank=True)  # 비추천

올바른 예제

class Blog(models.Model):
    title = models.CharField(max_length=100, blank=True)  # NULL 대신 빈 문자열 저장

5. 정리

필드 옵션데이터베이스 (DB)Django 폼 (Form)
null=TrueNULL 값을 저장 가능영향 없음
blank=True""(빈 문자열) 허용입력 필수 아님
null=True, blank=TrueNULL 또는 값 저장 가능입력 필수 아님

📌 결론

  • null=True데이터베이스에서 NULL 허용 (IntegerField, DateTimeField 등에 적합)
  • blank=True폼 입력에서 빈 값 허용 (CharField, TextField에서 사용)
  • null=True, blank=True같이 사용하면 폼에서도 입력을 안 해도 되고, DB에서도 NULL 허용됨

🔹 is_active 메서드 분석

@property  # 읽기 전용 (괄호 없이 접근 가능)
def is_active(self):  # 배포 여부 판단
    now = timezone.now()

    if not self.published_at:  # published_at이 없으면 아직 배포되지 않음
        return True

    return self.published_at <= now  # 현재 시간이 배포일시 이후면 True, 아니면 False

🔥 동작 방식

  1. @property 데코레이터가 적용되어 있으므로, 메서드가 속성처럼 동작한다.

    • 즉, blog.is_active()가 아니라 blog.is_active로 호출 가능
  2. self.published_at 값이 없으면 (None이면) True를 반환 → 아직 배포되지 않음.

  3. self.published_at 값이 현재 시간과 같거나 이전이면 True → 배포됨.


🔹 실행 예시

from django.utils import timezone

# published_at이 없는 경우
blog1 = Blog(title="Draft Post", content="This is a draft.", author=user, published_at=None)
print(blog1.is_active)  # True (배포되지 않음)

# published_at이 현재 시간보다 과거인 경우
blog2 = Blog(title="Published Post", content="This is published.", author=user, published_at=timezone.now() - timezone.timedelta(days=1))
print(blog2.is_active)  # True (배포됨)

# published_at이 미래인 경우
blog3 = Blog(title="Scheduled Post", content="This is scheduled.", author=user, published_at=timezone.now() + timezone.timedelta(days=1))
print(blog3.is_active)  # False (아직 배포되지 않음)

🔹 @property를 안 쓰면?

def is_active(self):
    ...

이렇게 하면 blog.is_active()처럼 함수 호출 방식으로만 사용할 수 있어.

반면 @property를 붙이면 blog.is_active처럼 속성처럼 접근할 수 있어서 더 직관적이야.

읽기 전용 속성을 만들고 싶다면 @property를 사용하는 게 좋다!

🔹 Q 객체란?

Q 객체는 Django ORM에서 복잡한 쿼리를 만들 때 사용하는 도구입니다.
AND, OR, NOT 같은 논리 연산자를 사용하여 필터링 조건을 더 유연하게 조합할 수 있도록 도와줍니다.

from django.db.models import Q

🔹 Q 객체의 주요 기능

✅ 1. OR 연산 (| 연산자 사용)

OR 조건을 사용할 때 | 연산자를 사용합니다.

from django.db.models import Q
from django.utils import timezone

now = timezone.now()

Blog.objects.filter(
    Q(published_at__isnull=True) | Q(published_at__lte=now)  # OR 조건
)

위 코드의 의미:

  • published_atNULL인 게시글 (published_at__isnull=True)
  • 또는 published_at이 현재 시간보다 과거인 게시글 (published_at__lte=now)
  • 두 조건 중 하나라도 만족하면 결과에 포함됩니다.

✅ 2. AND 연산 (& 연산자 사용)

기본적으로 filter()AND 연산을 수행하지만, 명시적으로 & 연산자를 사용할 수도 있습니다.

Blog.objects.filter(
    Q(title__icontains="Django") & Q(author__username="admin")  # AND 조건
)

위 코드의 의미:

  • title"Django"를 포함하는 글
  • 그리고 authorusername"admin"인 경우
  • 둘 다 만족해야 결과에 포함됩니다.

✅ 3. NOT 연산 (~ 연산자 사용)

특정 조건을 제외하고 싶을 때 ~(틸드)를 사용합니다.

Blog.objects.filter(~Q(author__username="guest"))

위 코드의 의미:

  • author"guest"아닌 글만 가져옵니다.

🔹 Q 객체의 장점

  1. OR 조건을 쉽게 사용할 수 있음 → 기본 filter()AND 조건만 적용됨.
  2. 다양한 조합 가능AND, OR, NOT을 자유롭게 섞을 수 있음.
  3. 가독성 증가 → 복잡한 필터링 조건을 더 쉽게 읽고 수정할 수 있음.

🔹 PublishedManager 코드 분석

class PublishedManager(Manager):
    def get_queryset(self):
        now = timezone.now()

        from django.db.models import Q
        return super().get_queryset().filter( 
            Q(published_at__isnull=True) |  # published_at이 None인 경우 (즉시 활성화된 글)
            Q(published_at__lte=now),      # published_at이 현재 시간보다 이전인 경우 (이미 배포된 글)
        )

위 코드의 역할:

  • published_atNone이면 즉시 활성화된 글로 간주합니다.
  • published_at이 현재 시간보다 이전이면 이미 배포된 글로 간주합니다.
  • 두 조건 중 하나라도 만족하면 해당 게시글이 조회 대상이 됩니다.

0개의 댓글

관련 채용 정보