Django 09편 - TDD 2편
들어가기 전…
위 프로젝트에서 다룬 앱을 이용해 TDD를 구성해보자.
뷰 테스트
앞에서 만든 설문조사 어플리케이션은 상당히 대충대충 만들어져 있다. 이 어플리케이션은 pub_date필드가 미래에있는 질문 까지도 포함하여 게시한다. 이것을 개선 해야한다. 미래로 pub_date를 설정한 것은 그 시기가 되면 질문이 게시되지만 그때까지는 보이지 않아야한다.
tests.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
from django.urls import reverse def create_question(question_text, days): """ Create a question with the given `question_text` and published the given number of `days` offset to now (negative for questions published in the past, positive for questions that have yet to be published). """ time = timezone.now() + datetime.timedelta(days=days) return Question.objects.create(question_text=question_text, pub_date=time) class QuestionIndexViewTests(TestCase): def test_no_questions(self): """ If no questions exist, an appropriate message is displayed. """ response = self.client.get(reverse('polls:index')) self.assertEqual(response.status_code, 200) self.assertContains(response, "No polls are available.") self.assertQuerysetEqual(response.context['latest_question_list'], []) def test_past_question(self): """ Questions with a pub_date in the past are displayed on the index page. """ create_question(question_text="Past question.", days=-30) response = self.client.get(reverse('polls:index')) self.assertQuerysetEqual( response.context['latest_question_list'], ['<Question: Past question.>'] ) def test_future_question(self): """ Questions with a pub_date in the future aren't displayed on the index page. """ create_question(question_text="Future question.", days=30) response = self.client.get(reverse('polls:index')) self.assertContains(response, "No polls are available.") self.assertQuerysetEqual(response.context['latest_question_list'], []) def test_future_question_and_past_question(self): """ Even if both past and future questions exist, only past questions are displayed. """ create_question(question_text="Past question.", days=-30) create_question(question_text="Future question.", days=30) response = self.client.get(reverse('polls:index')) self.assertQuerysetEqual( response.context['latest_question_list'], ['<Question: Past question.>'] ) def test_two_past_questions(self): """ The questions index page may display multiple questions. """ create_question(question_text="Past question 1.", days=-30) create_question(question_text="Past question 2.", days=-5) response = self.client.get(reverse('polls:index')) self.assertQuerysetEqual( response.context['latest_question_list'], ['<Question: Past question 2.>', '<Question: Past question 1.>'] )
- django.urls.reverse() 코드에서 url 템플릿 태그와 유사한 것을 사용해야하는 경우 사용한다.
- SimpleTestCase.assertContains(response, text, count=None, status_code=200, msg_prefix=’’, html=False) Response 인스턴스가 지정된 status*code를 생성하고 해당 텍스트가 응답의 내용에 표시되는지 확인합니다. count가 제공되면 텍스트가 응답에서 정확히 count 번 나타나야합니다. 텍스트를 HTML로 처리하려면 html을 True로 설정하십시오. 응답 내용과의 비교는 문자 별 동등성 대신 HTML 의미 체계를 기반으로합니다. 대부분의 경우 공백은 무시되며 속성 순서는 중요하지 않습니다. 자세한 내용은 _assertHTMLEqual()*을 참조하십시오.
- TransactionTestCase.assertQuerysetEqual(qs, values, transform=repr, ordered=True, msg=None) queryset qs가 특정 값 목록 값을 반환하도록 지정합니다. qs의 내용과 값의 비교는 qs에 변환을 적용하여 수행됩니다. 기본적으로 이것은 qs에있는 각 값의 repr()와 비교됨을 의미합니다. 만약 repr()이 고유하거나 유용한 비교를 제공하지 않는 경우 다른 모든 콜러블을 사용할 수 있습니다. 기본적으로 비교도 순서에 따라 다릅니다. qs가 암시적으로 순서를 제공하지 않는 경우 ordered 매개 변수를 False로 설정하여 비교를 collections.Counter 비교로 전환 할 수 있습니다. 순서가 정의되지 않은 경우 (주어진 q가 순서가 지정되지 않고 둘 이상의 순서가 지정된 값과 비교되는 경우) ValueError가 발생합니다. 오류 발생시 출력은 msg 인수로 사용자 정의 할 수 있습니다.
테스트 결과
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
Creating test database for alias 'default'... System check identified no issues (0 silenced). FF...... ====================================================================== FAIL: test_future_question (apps.polls.tests.QuestionIndexViewTests) Questions with a pub_date in the future aren't displayed on Traceback (most recent call last): File "/Users/-/Desktop/github/django-demo/django_demo/apps/polls/tests.py", line 80, in test_future_question self.assertContains(response, "No polls are available.") File "/opt/miniconda3/envs/django_env/lib/python3.9/site-packages/django/test/testcases.py", line 470, in assertContains self.assertTrue(real_count != 0, msg_prefix + "Couldn't find %s in response" % text_repr) AssertionError: False is not true : Couldn't find 'No polls are available.' in response ====================================================================== FAIL: test_future_question_and_past_question (apps.polls.tests.QuestionIndexViewTests) Even if both past and future questions exist, only past questions Traceback (most recent call last): File "/Users/-/Desktop/github/django-demo/django_demo/apps/polls/tests.py", line 91, in test_future_question_and_past_question self.assertQuerysetEqual( File "/opt/miniconda3/envs/django_env/lib/python3.9/site-packages/django/test/testcases.py", line 1052, in assertQuerysetEqual return self.assertEqual(list(items), values, msg=msg) AssertionError: Lists differ: ['<Question: Future question.>', '<Question: Past question.>'] != ['<Question: Past question.>'] First differing element 0: '<Question: Future question.>' '<Question: Past question.>' First list contains 1 additional elements. First extra element 1: '<Question: Past question.>' - ['<Question: Future question.>', '<Question: Past question.>'] + ['<Question: Past question.>'] Ran 8 tests in 0.018s FAILED (failures=2) Destroying test database for alias 'default'...
test_future_question, test_future_question_and_past_question 테스트가 실패했다.
뷰 코드 개선하기
views.py
1 2 3 4 5 6 7 8 9 10 11
from django.utils import timezone class IndexView(generic.ListView): template_name = 'polls/index.html' context_object_name = 'latest_question_list' def get_queryset(self): """Return the last five published questions.""" # return Question.objects.order_by('-pub_date')[:5] return Question.objects.filter(pub_date__lte=timezone.now()).order_by('-pub_date')[:5]
IndexView의 get_queryset()을 위와 같이 수정해준다. Question.objects.filter(pub_date__lte = timezone.now())는 timezone.now()보다 pub_date가 작거나 같은 Question을 포함하는 queryset을 반환한다.
재테스트 결과
1 2 3 4 5 6 7 8
Creating test database for alias 'default'... System check identified no issues (0 silenced). ........ ---------------------------------------------------------------------- Ran 8 tests in 0.019s OK Destroying test database for alias 'default'...
DetailView 테스트하기
tests.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
class QuestionDetailViewTests(TestCase): def test_future_question(self): """ The detail view of a question with a pub_date in the future returns a 404 not found. """ future_question = create_question(question_text='Future question.', days=5) url = reverse('polls:detail', args=(future_question.id,)) response = self.client.get(url) self.assertEqual(response.status_code, 404) def test_past_question(self): """ The detail view of a question with a pub_date in the past displays the question's text. """ past_question = create_question(question_text='Past Question.', days=-5) url = reverse('polls:detail', args=(past_question.id,)) response = self.client.get(url) self.assertContains(response, past_question.question_text)
테스트 결과
```text Creating test database for alias ‘default’… System check identified no issues (0 silenced). F……… ====================================================================== FAIL: test_future_question (apps.polls.tests.QuestionDetailViewTests) The detail view of a question with a pub_date in the future ———————————————————————- Traceback (most recent call last): File “/Users/-/Desktop/github/django-demo/django_demo/apps/polls/tests.py”, line 118, in test_future_question self.assertEqual(response.status_code, 404) AssertionError: 200 != 404
Ran 10 tests in 0.026s
FAILED (failures=1) Destroying test database for alias ‘default’…
1
2
3
4
5
6
7
8
9
10
11
12
미래 날짜로 된 detail 페이지에 접근하면 404 not found를 띄워야하는 테스트다. 테스트 결과 의도한 바와 다르게 200 status code가 떴으니 views.py에서 DetailView의 코드를 수정해줘야한다.
- views.py
```python
class DetailView(generic.DetailView):
model = Question
template_name = 'polls/detail.html'
def get_queryset(self):
return Question.objects.filter(pub_date__lte=timezone.now())
기존 DetailView 코드에서 get_queryset을 위와 같이 오버라이드 해준다.
재테스트 결과
1 2 3 4 5 6 7 8
Creating test database for alias 'default'... System check identified no issues (0 silenced). .......... ---------------------------------------------------------------------- Ran 10 tests in 0.027s OK Destroying test database for alias 'default'...
지금까지 만든 10개의 테스트가 모두 통과되었다. :)
더 많은 테스트를 위한 아이디어
우리는 비슷한 get_queryset 메소드를 ResultsView에 추가하고 그 뷰에 대한 새로운 테스트 클래스를 생성해야합니다. 그것은 우리가 방금 만든 것과 매우 유사합니다.
테스트를 추가하면서 다른 방법으로 애플리케이션을 개선 할 수도 있습니다. 예를 들어, 선택 사항이 없는 사이트에 설문을 게시 할 수 있다는 것은 바보같은 일입니다. 그래서 우리의 뷰는 이를 확인하고 그러한 질문을 배제 할 것입니다. 우리의 테스트는 선택사항이 없는 설문을 생성 한 다음, 실제로 게시되지 않는지 테스트하고, 선택사항이 있는 설문을 작성하고 게시 여부를 테스트합니다.
아마도 일반 사용자가 아닌 로그인 한 관리자는 게시되지 않은 설문을 볼 수 있어야합니다. 다시 말하면: 소프트웨어를 추가하기 위해 필요한 것은 무엇이든 테스트를 수반해야합니다, 먼저 테스트를 작성한 다음 코드가 테스트를 통과하게 만들 것인지, 아니면 먼저 코드에서 로직을 처리 한 다음 이를 증명할 테스트를 작성하십시오.
테스트 코드가 너무 비대해졌다! X(
테스트 할 때는, 많이 할 수록 좋습니다. 비대해지는것은 중요하지 않습니다. 대부분의 경우 테스트를 한 번 작성한 다음 신경을 끄게 됩니다. 그래도 이 테스트 코드의 유용한 기능들은 프로그램을 개발하는 동안 계속 해서 작동할것입니다.
아래와 같이, 테스트들이 현명하게 배열되어있는 한 관리가 어려워지지 않을 것입니다.
- 각 모델이나 뷰에 대한 별도의 TestClass
- 테스트하려는 각 조건 집합에 대해 분리된 테스트 방법
- 기능를 설명하는 테스트 메소드 이름