RelatedField
투표(Votes) 기능 구현하기 1 - Models
투표(Votes) 기능 구현하기 2 - Serializers & Views
Validation
Testing
Testing Serializers
Testing Views
PrimaryKeyRelatedField
: 모델의 Primary Key 사용
StringRelatedField
: 모델의 __str__()
메서드 사용
SlugRelatedField
: 사용할 모델의 필드 지정 가능
# "pub_date"라는 이름의 필드 사용
q = serializers.SlugRelatedField(..., slug_field = "pub_date")
HyperlinkedRelatedField
: View 이름을 사용해 링크 기능 제공
q = serializers.HyperlinkedRelatedField(..., view_name = "question-detail")
Question 객체에서 Choice 객체의 집합(choice_set) 볼 수 있는 기능 구현
polls/models.py
class Choice(models.Model):
question = models.ForeignKey(Question, related_name = "choices", on_delete = models.CASCADE)
...
polls_api/serializers.py
class ChoiceSerializer(serializers.ModelSerializer):
class Meta:
model = Choice
fields = ["choice_text", "votes"]
class QuestionSerializer(serializers.ModelSerializer):
...
choices = ChoiceSerializer(many = True, read_only = True)
class Meta:
model = Question
fields = ["id", "question_text", "pub_date", "owner", "choices"]
class UserSerializer(serializers.ModelSerializer):
# User의 Question 객체를 HyperLinkedKeyRelatedField로 연결해 링크 제공
questions = serializers.HyperlinkedRelatedField(many = True, read_only = True, view_name = "question-detail")
...
serializers.SerializerMethodField():
정의한 메서드의 반환값 사용
polls/models.py
class Vote(models.Model):
question = models.ForeignKey(Question, on_delete = models.CASCADE)
choice = models.ForeignKey(Choice, on_delete = models.CASCADE)
voter = models.ForeignKey(User, on_delete = models.CASCADE)
class Meta:
# 각 사용자가 하나의 질문에 한 번만 투표할 수 있도록 UNIQUE 제약조건 생성
constraints = [
models.UniqueConstraint(fields = ["question", "voter"], name = "unique_voter_for_questions")
]
polls_api/serializers.py
from rest_framework import serializers
from polls.models import Question, Choice
...
class ChoiceSerializer(serializers.ModelSerializer):
votes_count = serializers.SerializerMethodField()
class Meta:
model = Choice
fields = ["choice_text", "votes_count"]
def get_votes_count(self, obj):
# 각 선택지의 투표 개수
return obj.vote_set.count()
polls_api/serializers.py
...
class VoteSerializer(serializers.ModelSerializer):
voter = serializers.ReadOnlyField(source = "voter.username")
class Meta:
model = Vote
fields = ["id", "question", "choice", "voter"]
...
polls_api/urls.py
...
urlpatterns = [
...
path('vote/', VoteList.as_view()),
path('vote/<int:pk>/', VoteDetail.as_view()),
]
polls_api/views.py
...
from .permission import IsOwnerOrReadOnly, IsVoter
class VoteList(generics.ListCreateAPIView):
serializer_class = VoteSerializer
permission_classes = [permissions.IsAuthenticated]
# 내가 작성한 Vote만 목록으로 출력
def get_queryset(self, *args, **kwargs):
return Vote.objects.filter(voter = self.request.user)
# 투표 시 투표자 저장
def perform_create(self, serializer):
serializer.save(voter = self.request.user)
class VoteDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = Vote.objects.all()
serializer_class = VoteSerializer
permission_classes = [permissions.IsAuthenticated, IsVoter]
...
polls_api/permission.py
...
# 투표자가 아니라면, 수정과 읽는 요청 모두 불가능
class IsVoter(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
return obj.voter == request.user
사용자가 올바르게 검증했는지 검증
polls_api/serializers.py
...
from rest_framework.validators import UniqueTogetherValidator
class VoteSerializer(serializers.ModelSerializer):
# 질문에 대해 유효한 답변을 선택했는지 검증
def validate(self, attrs):
if attrs["choice"].question.id != attrs["question"].id:
raise serializers.ValidationError("Question과 Choice의 조합이 맞지 않습니다.")
return attrs
class Meta:
model = Vote
fields = ["id", "question", "choice", "voter"]
# 각 사용자가 하나의 질문에 하나의 답변만 했는지 검증
validators = [
UniqueTogetherValidator(
queryset = Vote.objects.all(),
fields = ["question", "voter"]
)
]
...
polls_api/views.py
...
class VoteList(generics.ListCreateAPIView):
...
# 유효성 검사 하기 전에 voter를 설정하기 위해 perform_create()가 아닌 create()를 재정의
def create(self, request, *args, **kwargs):
new_data = request.data.copy()
new_data["voter"] = request.user.id
serializer = self.get_serializer(data = new_data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
class VoteDetail(generics.RetrieveUpdateDestroyAPIView):
...
def perform_update(self, serializer):
serializer.save(voter = self.request.user)
...
테스트는 개발 시간을 줄이고, 문제를 예방하거나 발견할 수 있으며, 협업하기 좋은 코드를 만들 수 있다는 장점이 있음
python manage.py test
polls_api/tests.py
from django.test import TestCase
from polls_api.serializers import QuestionSerializer
# Create your tests here.
class QuestionSerializerTestCase(TestCase):
# QuestionSerializer의 is_valid() 메서드와 save() 메서드가 제대로 동작하는지 테스트
def test_with_valid_data(self):
serializer = QuestionSerializer(data = {"question_text": "abc"})
self.assertEqual(serializer.is_valid(), True)
new_question = serializer.save()
self.assertIsNotNone(new_question.id)
def test_with_invalid_test(self):
serializer = QuestionSerializer(data = {"question_text": ""})
self.assertEqual(serializer.is_valid(), False)
테스트 메서드의 이름은 테스트 내용을 파악할 수 있도록 짓는 것이 좋음
테스트 실행 시, 테스트를 위한 임시 데이터베이스를 생성해서 테스트한 뒤 삭제
polls_api/tests.py
...
from polls_api.serializers import QuestionSerializer, VoteSerializer
from polls.models import Question, Choice, Vote
class VoteSerializerTest(TestCase):
# 각 테스트 메서드가 실행되기 전에 실행되는 설정 메서드
# 각 테스트 메서드마다 새로운 데이터 생성
def setUp(self):
self.user = User.objects.create(username = "testUser")
self.question = Question.objects.create(
question_text = "abc",
owner = self.user
)
self.choice = Choice.objects.create(
question = self.question,
choice_text = "1"
)
# 새로운 Vote 객체가 잘 생성되는지 테스트
def test_vote_serializer(self):
data = {
"question": self.question.id,
"choice": self.choice.id,
"voter": self.user.id
}
serializer = VoteSerializer(data = data)
self.assertTrue(serializer.is_valid())
vote = serializer.save()
self.assertEqual(vote.question, self.question)
self.assertEqual(vote.choice, self.choice)
self.assertEqual(vote.voter, self.user)
# User가 이미 투표한 상태에서 또 투표하려고 할 때 ValidationError가 잘 발생하는지 테스트
def test_vote_serializer_with_duplicate_vote(self):
choice1 = Choice.objects.create(
question = self.question,
choice_text = "2"
)
Vote.objects.create(question = self.question, choice = self.choice, voter = self.user)
data = {
"question": self.question.id,
"choice": self.choice.id,
"voter": self.user.id
}
serializer = VoteSerializer(data = data)
self.assertFalse(serializer.is_valid())
# Question과 Choice의 조합이 맞지 않을 경우 ValidationError가 잘 발생하는지 테스트
def test_vote_serializer_with_unmatched_question_and_choice(self):
question2 = Question.objects.create(
question_text = "abc",
owner = self.user
)
choice2 = Choice.objects.create(
question = question2,
choice_text = "1"
)
data = {
"question": self.question.id,
"choice": choice2.id,
"voter": self.user.id
}
serializer = VoteSerializer(data = data)
self.assertFalse(serializer.is_valid())
Serializer를 테스트할 때는 Django의 TestCase 라이브러리를 사용하지만, View를 테스트할 때는 DRF의 APITestCase 라이브러리 사용
reverse()
는 reverse_lazy()
와 같은 역할을 하며, 메서드 내에서 사용
python manage.py test app_name.tests.test_name
: 특정 테스트 메서드만 실행
polls_api/tests.py
...
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from rest_framework.test import APITestCase
class QuestionListTest(APITestCase):
def setUp(self):
self.question_data = {"question_text": "some question"}
self.url = reverse("question-list")
# Question을 생성하는 요청을 보냈을 때 잘 처리되는지 테스트
def test_create_question(self):
user = User.objects.create(username = "testuser", password = "testpass")
# 강제 인증
self.client.force_authenticate(user = user)
response = self.client.post(self.url, self.question_data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Question.objects.count(), 1)
question = Question.objects.first()
self.assertEqual(question.question_text, self.question_data["question_text"])
self.assertLess((timezone.now() - question.pub_date).total_seconds(), 1)
# 인증되지 않은 사용자가 POST 요청을 보낼 경우 403에러가 잘 발생하는지 테스트
def test_create_question_without_authentication(self):
response = self.client.post(self.url, self.question_data)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
# 테스트 목록을 요청했을 때 잘 처리되는지 테스트
def test_list_question(self):
question = Question.objects.create(question_text = "Question1")
choice = Choice.objects.create(question = question, choice_text = "choice1")
Question.objects.create(question_text = "Question2")
response = self.client.get(self.url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 2)
self.assertEqual(response.data[0]["choices"][0]["choice_text"], choice.choice_text)