애플리케이션의 품질을 유지하고 버그를 사전에 예방하기 위해서 테스트 코드를 작성한다. 물론 처음 테스트 코드를 다뤄보고 이것저것 많은 시행착오와 어려움이 있었지만, 현재까지 사용해 본 프레임워크들을 기반으로 작성해 보려고 한다.
pytest-django를 활용한 테스트 환경 구축
장고는 기본적으로 unittest모듈을 사용하지만, 더 효율적으로 테스트를 하기 위해 pytest를 사용한다. pytest-django는 pytest와 django를 통합해 테스트 코드의 가독성과 확장성을 높여준다.
pytest-django 설치 및 설정
pip install pytest pytest-django
설치가 완료되면 pytest.ini 파일을 생성해 pytest 설정을 추가해 준다.
[pytest]
DJANGO_SETTINGS_MODULE = TodoList.settings
python_files = test_*.py
testpaths = ./*/tests
addopts = --testdox
이제 pytest를 통해서 모든 테스트를 실행할 수 있으나 아직 생성한 테스트가 없으므로 바로 완료될 것이다.
factory-boy & Facker
factory-boy
factory-boy는 테스트 코드에서 사용할 객체를 쉽게 생성해 주는 도구이다. 객체를 펙토리로 정의해 테스트 데이터를 반복적으로 생성해주는 매우 편한 라이브러리이다.
Facker
facker은 테스트 용으로 사용할 가짜 데이터를 자동으로 생성해 준다.
둘의 차이점이 살짝 헷갈릴 수도 있는데 객체(모델 인스턴스)를 생성하는 것과 더미 데이터(다양한 입력 값)을 생성해 주는 것이다.
처음에 많이 헷갈려서 두 개 중에 하나만 쓰면 안 되는 건가 했지만, 실습을 하다 보니 다른지 이해할 수 있었다.
pip install factory-boy Faker
factory로 UserFactory 정의하기
class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = User
django_get_or_create = ("username",)
skip_postgeneration_save = True
username = factory.Sequence(lambda n: f"user{n}")
email = factory.LazyAttribute(lambda user: f"{user.username}@example.com")
raw_password = factory.Faker("password")
@classmethod
def _create(cls, model_class, *args, **kwargs):
raw_password = kwargs.pop("raw_password", None)
if raw_password:
kwargs["password"] = make_password(raw_password)
return super()._create(model_class, *args, **kwargs)
천천히 뜯어보면서 이해해 보면 쉽다.
- django_get_create는 팩토리가 객체를 생성할 때, 중복된 username이 있으면 새로 생성하지 않고 기존 객체를 반환하도록 한다.
- skip_postgeneration_save는 불필요한 객체 저장을 방지하는 역할을 한다, 경고 메시지를 해결하는 데 사용한 코드이다.
- Sequence를 사용해 user0, user1, user2와 같은 고유한 사용자 이름을 자동으로 생성해 주도록 했다.
- LazyAttribute를 이용해서 객체가 만들어지고 email을 username을 바탕으로 생성하도록 했다.
- facker는 앞서 이야기했듯 더미 데이터를 생성하는 라이브러리로 여기서는 평문의 비밀번호를 생성하도록 했다.
- _create에서는 앞서 만든 더미데이터인 패스워드를 해싱처리하는 과정이다, Django에서 비밀번호는 해시된 비밀번호만 저장하기 때문에 이렇게 해두었다.
팩토리를 사용해 User 생성 테스트
user = UserFactory()
print(user.username) # 예: user0
print(user.email) # 예: user0@example.com
print(user.password) # 해시된 비밀번호 출력
# 커스텀 비밀번호로 User 생성
user = UserFactory(raw_password="mysecretpassword")
print(user.password) # 해시된 비밀번호 출력
API 클라이언트 준비
이제 API 테스트를 작성하기에 앞서, API 클라이언트 설정을 자동화해보자.
아래는 어디까지나 프로젝트를 진행하면서 만든 예제이다.
Create_user
def create_user(raw_password):
return UserFactory(raw_password=raw_password)
- UserFactory를 이용해 새로운 유저를 생성한다.
- 비밀번호는 평문으로 전달된 후 팩토리 내부에서 해시 처리된다.
- 이 함수를 통해 유저 생성 로직을 재 사용할 수 있다.
get_api_client_with_basic_auth: Basic Auth 인증 설정
import base64
from rest_framework.test import APIClient
def get_api_client_with_basic_auth(user, raw_password):
base64_data = f"{user.username}:{raw_password}".encode()
authorization_header = base64.b64encode(base64_data).decode()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Basic {authorization_header}")
return client
- username과 비밀번호를 조합해 Basic Authentication 헤더를 생성한다.
- base64 인코딩을 사용해 username:password 형식의 문자열을 인코딩한 후, API 요청의 인증 헤더로 추가한다.
- APIClient는 DRF에서 제공하는 테스트 클라이언트로, 인증된 API를 호출한다.
unauthenticated_api_client
@pytest.fixture
def unauthenticated_api_client():
return APIClient()
- 인증되지 않은 API 클라이언트를 반환한다.
api_client_with_new_user_basic_auth
@pytest.fixture
def api_client_with_new_user_basic_auth(faker):
raw_password = faker.password() # Faker로 임의의 비밀번호 생성
user = create_user(raw_password) # 새로운 유저 생성
api_client = get_api_client_with_basic_auth(user, raw_password) # 인증된 API 클라이언트 생성
return api_client
- 새로운 유저를 생성하고 해당 유저의 인증 API 클라이언트를 반환한다.
- 매 테스트마다 새로운 인증된 유저를 생성할 수 있어 유용하다.
new_user
@pytest.fixture
def new_user():
return create_user()
- 비밀번호가 필요 없는 기본 유저를 생성한다.
- 여러 테스트에서 재 사용할 수 있는 유저 객체를 찍어내는 데 사용한다.
사용 예시 - API 테스트 코드
def test_authenticated_user_can_access_todo_list(api_client_with_new_user_basic_auth):
response = api_client_with_new_user_basic_auth.get('/api/todos/')
assert response.status_code == 200 # 인증된 유저는 접근 가능
def test_unauthenticated_user_cannot_access_todo_list(unauthenticated_api_client):
response = unauthenticated_api_client.get('/api/todos/')
assert response.status_code == 401 # 인증되지 않은 유저는 접근 불가
아래는 프로젝트에서 사용한 테스트 코드이다. 자세한 설명 없이 코드만 추가하고 추후, 입맛에 맞게 쓰도록 하자.
import base64
import pytest
from django.urls import reverse
from django.core.exceptions import ObjectDoesNotExist
from rest_framework import status
from rest_framework.response import Response
from rest_framework.test import APIClient
from accounts.tests.factories import UserFactory
from todo.models import Todo
from todo.tests.factories import TodoFactory, AlarmFactory
def create_user(raw_password):
return UserFactory(raw_password=raw_password)
def get_api_client_with_basic_auth(user, raw_password):
base64_data = f"{user.username}:{raw_password}".encode()
authorization_header = base64.b64encode(base64_data).decode()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Basic {authorization_header}")
return client
@pytest.fixture
def unauthenticated_api_client():
return APIClient()
@pytest.fixture
def api_client_with_new_user_basic_auth(faker):
raw_password = faker.password()
user = create_user(raw_password)
api_client = get_api_client_with_basic_auth(user, raw_password)
return api_client
@pytest.fixture
def new_user():
return create_user()
@pytest.fixture
def new_todo():
return TodoFactory()
@pytest.mark.describe("투두 조회 API 테스트")
class TestTodoRetrieveGroup:
@pytest.mark.it(
"투두 목록 조회: 비인증 조회가 가능해야하며, 생성한 투두의 갯수만큼 응답"
)
@pytest.mark.django_db
def test_todo_list(self, unauthenticated_api_client):
todo_list = [TodoFactory() for _ in range(10)]
url = reverse("todo:todo-api-v1:todo-list")
response: Response = unauthenticated_api_client.get(url)
assert status.HTTP_200_OK == response.status_code
assert len(todo_list) == len(response.data)
@pytest.mark.describe("투두 생성 API 테스트")
class TestTodoCreateGroup:
@pytest.mark.it(
"투두 생성: 인증되지 않은 사용자는 투두 생성 불가"
)
@pytest.mark.django_db
def test_unauthenticated_user_cannot_create_todo(self, unauthenticated_api_client):
url = reverse("todo:todo-api-v1:todo-list")
response = unauthenticated_api_client.post(url, data={})
assert status.HTTP_403_FORBIDDEN == response.status_code
@pytest.mark.it(
"투두 생성: 인증된 사용자는 투두 생성 가능"
)
@pytest.mark.django_db
def test_authenticated_user_can_create_todo(self, api_client_with_new_user_basic_auth, faker):
url = reverse("todo:todo-api-v1:todo-list")
data = {"content":faker.paragraph(),
"deadline_data": faker.date_between(start_date="today", end_date="+5y"),
"is_finished":False}
response = api_client_with_new_user_basic_auth.post(url, data=data)
assert status.HTTP_201_CREATED == response.status_code
assert data['content'] == response.data['content']
assert data['deadline_data'].strftime("%Y-%m-%dT%H:%M:%S") == response.data['deadline_data']
assert data['is_finished'] == response.data['is_finished']
@pytest.mark.describe("투두 수정 API 테스트")
class TestTodoUpdateGroup:
@pytest.mark.it(
"투두 수정: 작성자가 아닌 유저가 수정 요청하면 거부"
)
@pytest.mark.django_db
def test_non_author_cannot_update_todo(self, new_todo, api_client_with_new_user_basic_auth):
url = reverse("todo:todo-api-v1:todo-detail", args=[new_todo.pk])
response = api_client_with_new_user_basic_auth.patch(url, data={})
assert status.HTTP_403_FORBIDDEN == response.status_code
@pytest.mark.it(
"투두 수정: 작성자가 수정 요청하면 성공 "
)
@pytest.mark.django_db
def test_author_can_uodate_todo(self, faker):
raw_password=faker.password()
author = create_user(raw_password=raw_password)
created_todo = TodoFactory(author=author)
url = reverse("todo:todo-api-v1:todo-detail", args=[created_todo.pk])
api_client = get_api_client_with_basic_auth(author, raw_password)
data = {'content': faker.paragraph()}
response=api_client.patch(url, data=data)
assert status.HTTP_200_OK == response.status_code
assert data['content'] == response.data['content']
@pytest.mark.describe("투두 삭제 API 테스트")
class TestTodoDeleteGroup:
@pytest.mark.it(
"투두 삭저: 작성자가 아닌 유저가 삭제 요청하면 거부"
)
@pytest.mark.django_db
def test_non_author_cannot_delete_todo(self, new_todo, api_client_with_new_user_basic_auth):
url = reverse("todo:todo-api-v1:todo-detail", args=[new_todo.pk])
response = api_client_with_new_user_basic_auth.delete(url, data={})
assert status.HTTP_403_FORBIDDEN == response.status_code
@pytest.mark.it(
"투두 삭저: 작성자가 삭제 요청하면 성공"
)
@pytest.mark.django_db
def test_author_can_delete_todo(self, faker):
raw_password = faker.password()
author = create_user(raw_password=raw_password)
created_todo = TodoFactory(author=author)
url = reverse("todo:todo-api-v1:todo-detail", args=[created_todo.pk])
api_client = get_api_client_with_basic_auth(author, raw_password)
response: Response = api_client.delete(url)
assert status.HTTP_204_NO_CONTENT == response.status_code
with pytest.raises(ObjectDoesNotExist):
Todo.objects.get(pk=created_todo.pk)
@pytest.mark.describe("투두 응원 API 테스트")
class TestToggleSupportGroup:
@pytest.mark.it("투두 응원: 인증되지 않은 사용자는 응원 불가")
@pytest.mark.django_db
def test_unauthenticated_user_cannot_support_todo(self, unauthenticated_api_client):
new_todo = TodoFactory()
url = reverse("todo:support", args=[new_todo.pk])
response = unauthenticated_api_client.post(url, data={})
assert status.HTTP_403_FORBIDDEN == response.status_code
@pytest.mark.it("투두 응원: 인증된 사용자는 응원 가능 / 처음에는 생성, 이후 토글")
@pytest.mark.django_db
def test_authenticated_user_can_support_todo(self, faker):
raw_password = faker.password()
send_user = create_user(raw_password=raw_password)
created_todo = TodoFactory()
url = reverse("todo:support", args=[created_todo.pk])
api_client = get_api_client_with_basic_auth(send_user, raw_password)
response = api_client.post(url, data={})
assert status.HTTP_201_CREATED == response.status_code
assert response.data['detail'] == "SupportTodo가 생성되었습니다."
response = api_client.post(url, data={})
assert status.HTTP_200_OK == response.status_code
assert response.data['detail'] == "지원 상태가 False로 업데이트되었습니다."
response = api_client.post(url, data={})
assert status.HTTP_200_OK == response.status_code
assert response.data['detail'] == "지원 상태가 True로 업데이트되었습니다."
@pytest.mark.describe("알림 AIP 테스트")
class TestAlarmGroup:
@pytest.mark.it("알림: 인증되지 않은 사용자가 알림 목록 조회 불가")
@pytest.mark.django_db
def test_unauthenticated_user_cannot_alarm_list(self, unauthenticated_api_client):
url = reverse("todo:alarm-list")
response = unauthenticated_api_client.get(url, data={})
assert status.HTTP_403_FORBIDDEN == response.status_code
@pytest.mark.it("알림: 인증된 사용자 알림 목록 조회 가능")
@pytest.mark.django_db
def test_authenticated_user_can_alarm_list(self, api_client_with_new_user_basic_auth):
url = reverse("todo:alarm-list")
response = api_client_with_new_user_basic_auth.get(url)
assert status.HTTP_200_OK == response.status_code
assert isinstance(response.data, list)
@pytest.mark.it("알림: 다른 사람의 알림 읽음 처리 불가")
@pytest.mark.django_db
def test_other_user_alarm_cannot_patch(self, api_client_with_new_user_basic_auth):
alarm = AlarmFactory()
url = reverse("todo:alarm-read", args=[alarm.pk])
response = api_client_with_new_user_basic_auth.post(url, data={})
assert status.HTTP_404_NOT_FOUND == response.status_code
@pytest.mark.it("알림: 인증된 사용자가 알림 읽음 처리 가능")
@pytest.mark.django_db
def test_authenticated_user_can_patch_alarm(self, faker):
raw_password = faker.password()
receiver_user = create_user(raw_password=raw_password)
alarm = AlarmFactory(receiver=receiver_user)
url = reverse("todo:alarm-read", args=[alarm.pk])
api_client = get_api_client_with_basic_auth(receiver_user, raw_password)
response = api_client.post(url, data={})
assert status.HTTP_200_OK == response.status_code
assert response.data['detail'] == f"{alarm.content}를 읽음 처리"
'BackEnd > Django, DRF' 카테고리의 다른 글
[DRF] 쿼리 성능 향상 (Feat. ORM 최적화 기법) (0) | 2024.10.29 |
---|---|
[Django] 읽기 전용 데이터베이스 설정 및 테스트 (0) | 2024.10.11 |
[Django] 랜덤 객체를 가지고 오는 방법 (1) | 2024.09.13 |
[DRF] Django REST framework의 권한 관리 (0) | 2024.09.12 |
[Django] 캐시 API (0) | 2024.08.21 |