오늘(사실 어제)은 잠깐 drf-spectacular에서 벗어나 Django의 앱을 테스트하는 법에 대해 공부하였다.
원래 내가 블로그에 TIL을 쓸 때에는, 옵시디언에 간단히 정리한 내용을 바탕으로 공식 문서 등을 읽고, 실습해보며 최대한 상세하고 정확하게 쓰고자 한다.
그런데, 오늘은 실습도 너무 오래 걸리고, 정보도 너무 많아서 블로그용 글이 도저히 써지질 않는다. 그래서 옵시디언의 내용을 그대로 복사 붙여넣기하고, 나중에 시간이 나면 살을 덧붙이고자 한다.
shellshell을 이용해 수동으로 테스트하는 방법
$ python manage.py shell
shell은 파이썬의 interactive interpreter를 실행하며 프로젝트에 설치된 앱의 모든 모델을 자동으로 불러옴
각 앱 폴더에 앱에 대한 테스트를 작성하고 테스트 명령어를 입력하면 자동으로 테스트를 진행
import datetime
from django.test import TestCase
from django.utils import timezone
from .models import Question
class QuestionModelTests(TestCase):
def test_was_published_recently_with_future_question(self):
time = timezone.now() + datetime.timedelta(days=30)
future_question = Question(pub_date=time)
self.assertIs(future_question.was_published_recently(), False)
django.test.TestCase를 상속TestCase는 그림과 같은 상속 구조를 가짐$ python manage.py test 앱이름
터미널에 위와 같이 입력하면 tests.py의 내용에 따라 자동으로 테스트 진행
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_was_published_recently_with_future_question (polls.tests.QuestionModelTests)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/path/to/djangotutorial/polls/tests.py", line 16, in test_was_published_recently_with_future_question
self.assertIs(future_question.was_published_recently(), False)
AssertionError: True is not False
----------------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (failures=1)
Destroying test database for alias 'default'...
이런 식으로 테스트 결과가 출력됨
Client를 이용한 테스트django.test.Client는 테스트를 위한 클라이언트로 사용할 수 있음
Client에서 사용 가능한 메소드는 Testing tools | Django documentation | Django 참고
setup_test_environment테스트 전에 테스트에 필요한 설정을 수행함 (예: 테스트용 템플릿 Renderer 사용, 허용 호스트에 testserver 추가 등)
$ python manage.py shell
>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()
셸을 종료하면 해당 설정은 무효하기 때문에, 셸을 실행하면 다시 실행시켜야 함
Client를 이용한 테스트setup_test_environment를 실행한 후, Client를 불러와 클라이언트 인스턴스를 생성
>>> from django.test import Client
>>> client = Client()
미니 해커톤 프로젝트에서 get을 이용해 영화의 전체 리스트를 가져오는 예시
>>> api_movies = client.get('/api/movies/')
>>> api_movies
<Response status_code=200, "application/json">
>>> api_movies.data
[{'id': 148, 'title_kor': '대부', 'poster_url': 'https://image.tmdb.org/t/p/original/I1fkNd5CeJGv56mhrTDoOeMc2r.jpg', 'rate': 8.7, 'detail_url': 'http://testserver/api/movies/list/148/'}, {'id': 189, 'title_kor': '대부 2', 'poster_url': 'https://image.tmdb.org/t/p/original/bhqvqYuAgrTGwyNAmMR0ZVmjXel.jpg', 'rate': 8.577, 'detail_url': 'http://testserver/api/movies/list/189/'}, ...]
>>> api_movies.data[0].get('title_kor)
'대부'
api_movies의 값은 DRF의 Response 객체임api_movies에 DRF의 Response에 있는 속성, 메소드를 사용 가능teardown_test_environment테스트 후에 설정을 원래대로 돌려놓음
셸에서 테스트 종료 후 셸에서 이어서 다른 작업을 하고 싶을 때 사용 가능
>>> from django.test.utils import teardown_test_environment
>>> teardown_test_environment()
TestCase에서 ClientTestCase의 상속 구조에서 SimpleTestCase를 상속하는데, SimpleTestCase를 상속하는 테스트케잇들은 self.client로 Client 인스턴스에 접근할 수 있음
coveragecoverage는 code coverage를 측정하는 파이썬 라이브러리
$ pip install coverage
Django의 테스트와 함께 사용할 수 있음
$ coverage run --source='.' manage.py test 앱이름
code coverage에 대한 보고서를 보려면 아래와 같이 입력
$ coverage report
code coverage에 대한 HTML 페이지를 생성하기 위해 아래와 같이 입력
$ coverage html
Wrote HTML report to htmlcov/index.html
HTML 페이지에서 각 파일을 클릭하면 라인별로 coverage에 대한 정보를 표시해줌
Django 애플리케이션을 디버깅할 때 사용할 수 있는 툴
$ pip install django-debug-toolbar
settings.py에서 아래 사항 확인
INSTALLED_APPS에 django.contrib.staticfiles가 있는지 확인TEMPLATES에 백엔드가 django.template.backends.django.DjangoTemplates인 템플릿이 있으며 해당 템플릿의 APP_DIRS가 True로 설정되어 있는지 확인debug_toolbar를 INSTALLED_APPS에 추가
INSTALLED_APPS = [
# ...
"debug_toolbar",
# ...
]
Django Debug Toolbar의 URL을 프로젝트의 URL 패턴과 결합
urlpatterns = [
...
] + debug_toolbar_urls()
미들웨어 목록에 debug_toolbar.middleware.DebugToolbarMiddleware를 응답의 컨텐츠를 인코딩하는 미들웨어의 바로 다음에 추가 (원문: You should include the Debug Toolbar middleware as early as possible in the list. However, it must come after any other middleware that encodes the response’s content, such as GZipMiddleware.)
MIDDLEWARE = [
# GZipMiddleware 등의 미들웨어...
"debug_toolbar.middleware.DebugToolbarMiddleware",
# ...
]
settings.py에서 INTERNAL_IPS에 등록되어 있는 IP에서만 디버깅 툴바가 보여지므로 INTERNAL_IPS를 설정해야 함
INTERNAL_IPS = [
'127.0.0.1',
]
서버를 실행해보면 오른쪽에 툴바가 노출되는 것을 확인할 수 있음
Testing - Django REST framework
APIRequestFactoryDjango의 RequestFactory를 상속함
RequestFactory테스트 클라이언트와 같은 API를 공유하나, RequestFactory는 요청 인스턴스를 만들어 주는 역할이고,
뷰에 요청을 넘기는 것은 직접 해야 함
from django.contrib.auth.models import AnonymousUser, User
from django.test import RequestFactory, TestCase
from .views import MyView, my_view
class SimpleTest(TestCase):
def setUp(self):
# Every test needs access to the request factory.
self.factory = RequestFactory()
self.user = User.objects.create_user(
username="jacob", email="jacob@…", password="top_secret"
)
def test_details(self):
# Create an instance of a GET request.
request = self.factory.get("/customer/details")
# Recall that middleware are not supported. You can simulate a
# logged-in user by setting request.user manually.
request.user = self.user
# Or you can simulate an anonymous user by setting request.user to
# an AnonymousUser instance.
request.user = AnonymousUser()
# Test my_view() as if it were deployed at /customer/details
response = my_view(request)
# Use this syntax for class-based views.
response = MyView.as_view()(request)
self.assertEqual(response.status_code, 200)
RequestFactory의 인스턴스가 해주나,my_view(request)를 통해 하고 있음format 인자format 인자로 요청을 생성하는 데에 사용할 포맷을 지정할 수 있음
기본값은 Django의 RequestFactory와 호환을 위해 multipart로 되어 있음 (Form에서 사용하는 데이터타입)
factory = APIRequestFactory()
request = factory.post('/notes/', {'title': 'new idea'}, format='json')
content_type요청 본문을 직접 인코딩한다면, content_type을 설정
request = factory.post('/notes/', yaml.dump({'title': 'new idea'}), content_type='application/yaml')
Django의 RequestFactory는 데이터를 multipart 형식으로 직접 인코딩해서 PUT이나 PATCH해야 하지만, DRF의 APIRequestFactory는 put이나 patch 메소드에서 딕셔너리인 데이터를 바로 넣으면 됨
factory = APIRequestFactory()
request = factory.put('/notes/547/', {'title': 'remember to email dave'})
put 메소드에 바로 {'title': 'remember to email dave'}라는 데이터를 넣었음from django.test.client import encode_multipart, RequestFactory
factory = RequestFactory()
data = {'title': 'remember to email dave'}
content = encode_multipart('BoUnDaRyStRiNg', data)
content_type = 'multipart/form-data; boundary=BoUnDaRyStRiNg'
request = factory.put('/notes/547/', content, content_type=content_type)
put 메소드에 {'title': 'remember to email dave'}가 바로 들어가지 못하고, encode_multipart를 통해 인코딩되어야 함요청에서 인증을 강제할 수 있음
from rest_framework.test import force_authenticate
factory = APIRequestFactory()
user = User.objects.get(username='olivia')
view = AccountDetail.as_view()
# Make an authenticated request to the view...
request = factory.get('/accounts/django-superstars/')
force_authenticate(request, user=user)
response = view(request)
force_authenticate는 request, user, token 인자를 취하며 user와 token의 기본값은 Noneuser = User.objects.get(username='olivia')
request = factory.get('/accounts/django-superstars/')
force_authenticate(request, user=user, token=user.auth_token)
기본적으로 APIRequestFactory로 만들어지는 요청엔 뷰에서 사용되는 CSRF 검증이 없음
CSRF 검증을 켜야 할 필요가 있다면, APIRequestFactory의 인스턴스를 생성할 때, enforce_csrf_checks를 True로 설정
factory = APIRequestFactory(enforce_csrf_checks=True)
HttpRequest임에 유의DRF의 Request 객체는 뷰가 호출될 때 생성되므로, APIRequestFactory로 생성되는 요청은 Django의 HttpRequest 객체이기 때문에,
DRF의 Request를 생각하며 생성된 요청의 속성을 변경하면 기대하는 결과가 나오지 않을 수도 있음
APIClientDjango의 Client를 상속함
login# Make all requests in the context of a logged in session.
client = APIClient()
client.login(username='lauren', password='secret')
세션 방식의 인증
credentialsfrom rest_framework.authtoken.models import Token
from rest_framework.test import APIClient
# Include an appropriate `Authorization:` header on all requests.
token = Token.objects.get(user__username='lauren')
client = APIClient()
client.credentials(HTTP_AUTHORIZATION='Token ' + token.key)
토큰 방식의 인증
인증을 우회하고 테스트 클라이언트가 인증된 것으로 취급되어야 할 때 사용
user = User.objects.get(username='lauren')
client = APIClient()
client.force_authenticate(user=user)
강제 인증을 해제하려면 user=None으로 지정
client.force_authenticate(user=None)
APIClient에서 기본적으로 CSRF 검증은 적용되지 않지만, CSRF 검증이 필요하다면 클라이언트의 인스턴스를 만들 때 enforce_csrf_checks를 True로 지정
client = APIClient(enforce_csrf_checks=True)
⚠️ CSRF 검증은 세션 방식의 인증을 사용하는 뷰에서만 적용됨 (다시 말해, 클라이언트가 login을 사용했을 때만 적용)
RequestsClient, CoreAPIClient이 클라이언트들은 각각 파이썬 라이브러리 requests와 coreapi를 기반으로 만들어진 클래스들임
requests: requests · PyPICustomer.objects.count() == 3 대신 고객 목록과 관련된 엔드포인트에서 3개의 레코드가 저장되어 있는 것을 확인coreapi (Deprecated): core-api/core-api: A Document Object Model for Web APIs.coreapi는 DRF 3.9 버전에서 Deprecated되었음 (참고: 현재 DRF의 최신버전은 3.16.0)Django에 테스트 케이스가 있는 것처럼, DRF에도 테스트 케이스가 존재
APISimpleTestCaseAPITransactionTestCaseAPITestCaseAPILiveServerTestCase이 테스트케이스에서 self.client는 APIClient가 됨
URLPatternsTestCaseDRF에서는 테스트케이스에 URLPatternsTestCase를 상속하면 urlpatterns를 분리해서 지정할 수 있음
from django.urls import include, path, reverse
from rest_framework.test import APITestCase, URLPatternsTestCase
class AccountTests(APITestCase, URLPatternsTestCase):
urlpatterns = [
path('api/', include('api.urls')),
]
def test_create_account(self):
"""
Ensure we can create a new account object.
"""
url = reverse('account-list')
response = self.client.get(url, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
data 속성을 통해 확인할 수 있음APIRequestFactory를 통해 요청을 생성하고 요청을 뷰에 전달했을 때, 반환되는 응답은 아직 렌더링되지 않은 상태content 속성에 접근하려면 render 메소드를 먼저 실행해야 함