TDD(Test-Driven Development)는 소프트웨어 개발 방법론 중 하나로, 테스트를 먼저 작성하고 그 테스트를 통과하는 코드를 작성하는 방식을 의미합니다. TDD는 세 가지 주요 단계를 포함합니다.
Red: 테스트 코드를 작성한 후, 테스트를 실행하여 실패(red)하도록 합니다.
Green: 테스트를 통과하도록 최소한의 코드를 작성합니다.
Refactor: 테스트가 통과되면, 코드를 리팩토링하여 개선합니다.
- Django는 자체적으로 강력한 테스트 프레임워크를 제공하며,
unittest
를 기반으로 동작합니다.- Django의 테스트는 애플리케이션의 다양한 부분을 자동화된 테스트로 확인할 수 있게 해줍니다.
def test_example(self):
response = self.client.get('/my-url/')
self.assertEqual(response.status_code, 200)
from django.http import HttpResponse
def my_view(request):
return HttpResponse("Hello, World!")
모델의 메서드, 속성, 데이터베이스 동작을 테스트합니다.
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에서 테스트를 실행하려면, 아래 명령어를 사용합니다.
이 명령어는 tests.py
에 정의된 모든 테스트를 실행하고, 결과를 출력합니다.
python manage.py test
특정 앱에 대한 테스트만 실행하려면 아래 명령어를 사용합니다.
이 명령어는 특정 앱의 tests.py
에 정의된 모든 테스트를 실행하고, 결과를 출력합니다.
python manage.py test <app name>
python manage.py startapp blog
-> test.py
# 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')
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 → ❌ 테스트
# 실패(오류 발생)
python manage.py test
=> 현재 is_active 가 없어서 에러 발생# 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), # 현재시간보다 이전에 배포된 글
)
from blog.models import Blog
# 🔹 배포된 블로그만 조회
published_blogs = Blog.objects.all()
print(published_blogs) # PublishedManager가 적용된 쿼리셋 반환 (배포된 게시글만 조회)
# 🔹 모든 블로그 조회 (배포 여부 상관 없음)
all_blogs = Blog.all_objects.all()
print(all_blogs) # 모든 Blog 객체 반환 (미래에 배포될 글도 포함)
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)
- blog_list -> get 요청시 -> blog_list에 1개의 context만 들어있음
- Blog_create -> post요청시에 새로운 Blog가 생성됨 -> 3가지 방법
- LoginRequired => 확인
- publihsed_at가 null인것
- published_at가 future 인것
- published_at가 현재보다 작은 것
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 를 해주지 않아서 그럼
self.client
)Django의 TestCase
에서 제공하는 테스트 클라이언트(self.client
)는
서버를 실행하지 않고 가상의 HTTP 요청을 보낼 수 있도록 해줍니다.
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
객체에 저장
self.client
의 주요 기능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
인지 테스트하여 정상 응답 확인response = self.client.post(reverse('blog_create'), data={
'title': 'New Blog',
'content': 'Blog content',
})
📌 설명
reverse('blog_create')
를 사용하여 블로그 생성 URL을 가져옴self.client.post()
를 사용하여 POST
요청을 보냄data
에 폼 데이터를 전달하여 실제 블로그 생성 요청과 동일한 테스트 수행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
응답인지 확인response = self.client.get(reverse('profile'))
self.assertEqual(response.status_code, 302) # 로그인 안 하면 리다이렉트(302)
📌 설명
reverse('profile')
로 요청을 보냄302 Redirect
응답이 올 것임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.client
는 Django 서버를 실행하지 않고도 테스트 환경에서 HTTP 요청을 보낼 수 있는 가짜 클라이언트입니다. 🚀
null=True
vs blank=True
차이점Django의 모델 필드에서 null=True
와 blank=True
는 서로 다른 역할을 합니다.
옵션 | 설명 |
---|---|
null=True | 데이터베이스에서 NULL 값을 허용 |
blank=True | 폼에서 빈 값 입력을 허용 (유효성 검사에서 허용) |
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) # 입력 폼에서 필수 아님
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=True
)CharField
, TextField
에서 null=True
사용하면 안 되는 이유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 대신 빈 문자열 저장
필드 옵션 | 데이터베이스 (DB ) | Django 폼 (Form ) |
---|---|---|
null=True | NULL 값을 저장 가능 | 영향 없음 |
blank=True | "" (빈 문자열) 허용 | 입력 필수 아님 |
null=True, blank=True | NULL 또는 값 저장 가능 | 입력 필수 아님 |
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
@property
데코레이터가 적용되어 있으므로, 메서드가 속성처럼 동작한다.
blog.is_active()
가 아니라 blog.is_active
로 호출 가능self.published_at
값이 없으면 (None
이면) True
를 반환 → 아직 배포되지 않음.
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
객체의 주요 기능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_at
이 NULL
인 게시글 (published_at__isnull=True
) published_at
이 현재 시간보다 과거인 게시글 (published_at__lte=now
) AND
연산 (&
연산자 사용)기본적으로 filter()
는 AND
연산을 수행하지만, 명시적으로 &
연산자를 사용할 수도 있습니다.
Blog.objects.filter(
Q(title__icontains="Django") & Q(author__username="admin") # AND 조건
)
위 코드의 의미:
title
이 "Django"
를 포함하는 글 author
의 username
이 "admin"
인 경우 NOT
연산 (~
연산자 사용)특정 조건을 제외하고 싶을 때 ~
(틸드)를 사용합니다.
Blog.objects.filter(~Q(author__username="guest"))
위 코드의 의미:
author
가 "guest"
가 아닌 글만 가져옵니다.Q
객체의 장점filter()
는 AND
조건만 적용됨.AND
, OR
, NOT
을 자유롭게 섞을 수 있음.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_at
이 None
이면 즉시 활성화된 글로 간주합니다.published_at
이 현재 시간보다 이전이면 이미 배포된 글로 간주합니다.