본문 바로가기
파이썬/장고(django)

[Django] 튜토리얼5. 테스팅(Testing)

by 책 읽는 개발자_테드 2022. 6. 26.
반응형

이 글은 장고 공식 홈페이지의 튜토리얼을 번역하고, 직접 실습하는 과정을 정리합니다.

네 번째 튜토리얼(https://scshim.tistory.com/597)과 이어지는 다섯 번째 글입니다.

 

목차

· 자동 테스팅 도입하기

· generic views 작성하기: 코드는 짧을 수록 좋다

 

· view 테스트하기

 

· 테스트는 많을 수록 좋다

 

· 더 나은 테스트


자동 테스팅 도입하기


· 테스트는 코드의 작동을 확인하는 루틴이다.

· 테스트는 다양한 수준에서 작동한다. 일부 테스트는 아주 작은 세부 사항에 적용 될 수 있고, 어떤 테스트는 소프트웨어 전체 작동을 검사한다.

 

테스트를 생성해야하는 이유

1. 시간을 절약할 수 있다.

- 응용 프로그램에서 구성 요소 간에 복잡한 상호 작용이 있는 상황에서, 구성 요소가 변경될 수 있다. 이런 상황에서 변경한 코드로 문제가 발생하지 않는지 자동으로 확인할 수 있다.

2. 문제를 예방한다.

- 테스트를 통해 애플리케이션의 목적이나 의도된 동작이 올바르게 작동하는지 확인하면서 코드를 작성할 수 있다.

3. 코드를 더 매력적으로 만든다.

- 테스트가 부족하면, 다른 개발자들이 신뢰할 수 없는 코드로 생각하여 해당 코드를 보기 싫을 수 있다.

4. 팀이 함께 일하는 걸 도와준다.

- 복잡한 응용 프로그램은 팀에서 유지 관리된다. 이때 테스트는 동료가 실수로 코드를 깨뜨리지 않도록 보장한다.



첫 번째 테스트 작성하기

설문조사 애플리케이션에는 버그가 있다. 앞서 작성한 Question.was_published_recently() 메서드(https://scshim.tistory.com/593)는 Question이 하루 이내에 작성된 경우 True를 반환해야하지만, Question의 pub_date 필드가 미래인 경우에도 True를 반환한다.

 

shell을 사용하여 해당 버그를 확인하자.

 

$ python manage.py shell



버그 노출을 위한 테스트 생성하기

문제를 테스트하기 위해 방금 shell에서 수행한 작업을 자동화된 테스트로 변환할 수 있다.

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)

 

코드를 작성한 후 터미널에서 테스트를 실행해보자.

 

$ python manage.py test polls

 

위와 같이 테스트가 실패하고, 실패가 발생한 라인도 알 수 있다.

 

테스트를 실행하면 다음과 같은 일이 발생한다.

- manage.py test polls 명령은 polls 애플리케이션에서 테스트를 찾는다.

- django.test.TestCase 클래스의 하위 클래스를 찾는다.

- 테스트를 위한 특별한 데이터베이스를 만든다.

- 이름이 test로 시작하는 테스트 메서드를 찾는다.  

- test_was_published_recently_with_future_question에서 pub_date 필드가 30일 미래인 Question 인스턴스를 생성한다. 

- assertIs() 메서드로 was_published_recently()가 True를 반환한다는 것을 발견한다.

 

버그 수정하기 

날짜가 과거인 경우에만 True를 반환하도록 models.py 메서드를 수정한다.

 

polls/models.py

def was_published_recently(self):
    now = timezone.now()
    return now - datetime.timedelta(days=1) <= self.pub_date <= now

 

그리고 다시 테스트를 수행하면, 다음과 같이 테스트가 성공한다.

 

더 포괄적인 테스트 작성하기 

메서드의 동작을 보다 포괄적으로 테스트하기 위해 동일한 클래스에 두 개의 테스트 메서드를 추가한다.

def test_was_published_recently_with_old_question(self):
   # was_published_recently()는 pub_date가 하루 보다 오래된 question에 대해  False를 반환
   time = timezone.now() - datetime.timedelta(days=1, seconds=1)
   old_question = Question(pub_date=time)
   self.assertIs(old_question.was_published_recently(), False)
 
def test_was_published_recently_with_recent_question(self):
   # was_published_recently()는 pub_date가 하루 이내인 question에 대해  True를 반환
   time = timezone.now() - datetime.timedelta(hours=23, minutes=59, seconds=59)
   recent_question = Question(pub_date=time)
   self.assertIs(recent_question.was_published_recently(), True)

 

이제 Question.was_published_recently()가 과거, 최근 및 미래 질문에 대해 합리적인 값을 반환하는지 확인하는 세 가지 테스트가 있다.

 

즉, polls 애플리케이션이 미래에 복잡해지고 상호 작용하는 다른 코드가 추가되어도 테스트가 작성한 메서드가 예상한 방식으로 작동할 것이라는 보장이 생겼다.

 

view 테스트하기


polls 응용 프로그램은 문제가있다. pub_date 필드가 미래에 있는 질문을 포함하여 모든 질문을 포함한다. 이 문제를 개선하자. 문제를 해결하기 전 사용할 수 있는 도구를 살펴보자.

 

장고 테스트 Client 

장고는 view 수준에서 코드와 상호 작용하는 사용자를 시뮬레이션하는 테스트 클라이언트를 제공한다. 해당 클라이언트를 tests.py 또는 셸에서 사용할 수 있다. 첫 번째로 셸에서 테스트 환경을 설정하자.

 

$ python manage.py shell
>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()

 

· setup_test_environment()는 템플릿 렌더러를 설치하여 response.context 같은 응답에 대한 몇 가지 추가 속성을 검사할 수 있다.

- 해당 메서드는 테스트 데이터베이스를 설치하지 않고, 기존 데이터베이스에 대해 실행되므로 이미 생성한 질문에 따라 출력이 조금씩 다를 수 있다.

- 만약 settings.py의 TIME_ZONE이 올바르지 않다면 예상치 못한 결과를 얻을 수 있으므로 주의하자.

 

다음으로 test client 클래스를 import한다. 

- 이후 tests.py에서 django.test.TestCase 클래스를 사용하는데, 해당 클래스는 자체 클라이언트를 사용하므로 이 작업이 필요하지 않다.

 

>>> from django.test import Client
>>> client = Client()

 

준비가 되면 클라이언트에게 다음 작업을 수행하도록 요청할 수 있다. ‘/’ 경로로 부터 응답을 받는다.

>>> response = client.get('/')

결과

 

해당 주소로 부터는 404 에러를 받게된다.

>>> response.status_code

결과

 

‘/polls/’ 주소로 요청을하면, 다음과 같은 결과를 얻을 수 있다.

>>> from django.urls import reverse
>>> response = client.get(reverse('polls:index'))
>>> response.status_code

결과

 

>>> response.content

결과

 

>>> response.context['latest_question_list']

결과

 

기존 view 향상시키기

앞서 말한 pub_date 필드가 미래에 있는 질문을 포함하여 모든 질문을 포함하는 문제를 해결해보자.

앞선 튜토리얼에서 Listview을 기반으로 다음과 같은 view를 작성했다.

 

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]

 

get_queryset() 메서드를 수정하고, timezone_now()와 비교하여 날짜를 확인하도록 변경해야한다.

 

from django.utils import timezone
 
def get_queryset(self):
   ""”미래에 출시된 것을 제외한 최신의 다섯 개 질문을 반환한다"""
   return Question.objects.filter(pub_date__lte=timezone.now()).order_by('-pub_date')[:5]

 

- Question.objects.filter(pub_date_lte=timezone.now())는 pub_date가 timezone.now보다 작거나 같은 질문이 포함된 쿼리 세트를 반환한다.

 

view 테스트하기

앞서 수정한 view가 정상적으로 작동하는지 테스트를 만들어보자. 

 from django.urls import reverse


def create_question(question_text, days):
   """
   주어진 question_text로 질문을 생성하고, 현재의 오프셋인 주어진 days 숫자를 게시한다.
   """
   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):
       """
       질문이 존재하지 않으면, 적절한 메세지를 보여준다.
       """
       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):
       """
       pub_date가 과거인 질문이 index 페이지에 노출된다.
       """
       question = create_question(question_text="Past question.", days=-30)
       response = self.client.get(reverse('polls:index'))
       self.assertQuerysetEqual(
           response.context['latest_question_list'],
           [question],
       )
 
   def test_future_question(self):
       """
       pub_date가 미래인 질문은 index 페이지에 노출되지 않는다.
       """
       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):
       """
       과거와 미래의 질문이 모두 존재해도, 과거의 질문만 노출된다.
       """
       question = 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],
       )
 
   def test_two_past_questions(self):
       """
       질문 index 페이지에는 여러 질문이 표시될 수 있다.
       """
       question1 = create_question(question_text="Past question 1.", days=-30)
       question2 = create_question(question_text="Past question 2.", days=-5)
       response = self.client.get(reverse('polls:index'))
       self.assertQuerysetEqual(
           response.context['latest_question_list'],
           [question2, question1],
       )

 

· create_question는 질문 생성 과정의 일부 반복을 제거하기 위한 shortcut 함수다.

· test_no_questions은 질문을 생성하지 않고, “No polls are available” 메시지를 체크한다. 또한 latest_question_list가 비어있는지 검증한다.

· django.test.TestCase 클래스는 추가적인 assertion 메서드를 제공한다.

ex) assertContains(), assertQuerysetEqual()

 

· test_past_question에서는 질문을 만들고 목록을 나타나는지 확인한다.

· test_future_question에서는 미래의 pub_date로 질문을 만든다. 각 테스트 방법에 대해 데이터베이스가 재설정되므로 첫 번째 질문은 더 이상 존재하지 않으므로 인덱스에는 질문이 없어야 한다.

 

DetailView 테스트하기

pub_date가 미래인 질문이 index에 표시되지 않더라도 사용자가 URL을 알고 있거나 추측하면, 해당 질문에 접근할 수 있다. 따라서 DetailView에 유사한 제약 조건을 추가한다.

 

polls/views.py

class DetailView(generic.DetailView):
….
 
   def get_queryset(self):
       """아직 출시되지 않은 질문은 제외한다."""
       return Question.objects.filter(pub_date__lte=timezone.now())

 

다음으로 pub_date가 과거인 질문은 표시되고, pub_date가 미래인 질문은 표시되지 않는지 확인하기 위해 몇 가지 테스트를 추가한다.

 

polls/test.py

class QuestionDetailViewTests(TestCase):
   def test_future_question(self):
       """
       pub_date가 미래인 질문의 detail view는 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):
       """
       pub_date가 과거인 질문의 detail view는 질문의 텍스트를 노출한다.
       """
       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)



테스트는 많을 수록 좋다


테스트가 통제 불능 상태로 성장하는 것처럼 보일 수 있다. 이 속도로는 곧 애플리케이션보다 테스트에 더 많은 코드가 포함될 것이며, 나머지 코드의 우아한 간결함에 비해 반복은 멋스럽지 않을 수 있다.

 

이는 문제가 되지 않는다. 그대로 성장하게두자. 대부분의 경우 테스트를 한 번 작성하고 잊을 수 있다. 하지만 이러한 유용한 함수는 프로그램을 계속 개발함에 따라 계속해서 수행될 것이다.

 

때때로 테스트를 업데이트해야한다. 예를 들어 Choices를 포함한 Question만 게시되도록 view를 수정한다고 가정하자. 이 경우 기존 테스트 중 많은 부분이 실패할 것이다. 테스트를 최신 상태로 유지하려면 어떤 테스트를 수정해야 하는지 정확히 알려주므로 테스트가 스스로를 돌보는데 도움이 된다.

최악의 경우 개발을 계속하다보면 중복되는 테스트가 있다는 것을 알게 될 수 있다. 그것조차 문제가 되지 않는다. 테스트에서 중복성은 좋은 것이다.

 

테스트가 현명하게 배열되어 있는 한, 관리할 수 없게 되지는 않는다. 좋은 경험 법칙에는 다음이 포함된다.

1. 각 모델 또는 view에 대한 별도의 TestClass

2. 테스트하려는 각 조건 세트에 대한 별도의 테스트 방법

3. 기능을 설명하는 테스트 메서드 이름

 

더 나은 테스트


 · 이 튜토리얼에서 테스트는 모델의 내부 로직과 view가 정보를 게시하는 방식을 다루었지만, Selenium과 같은 in-browser 프레임워크를 사용하여 HTML이 브라우저에서 실제로 렌더링되는 방식을 테스트할 수 있다.

- 이러한 도구를 사용하면 장고 코드의 동작뿐만 아니라 JavaScript의 동작도 확인할 수 있다.

- 장고에는 Selenium과 같은 도구와의 통합을 용이하게하는 LiveServerTestCase가 포함되어 있다.

 

· 복잡한 응용 프로그램이 있는 경우 지속적인 통합을 위해 커밋할 때마다 테스트를 자동으로 실행하여 품질 관리 자체가 최소한 부분적으로 자동화되도록 할 수 있다.

 

· 애플리케이션의 테스트되지 않은 부분을 찾는 좋은 방법은 코드 커버리지를 확인하는 것이다. 

- 이는 취약하거나 죽은 코드를 식별하는 데 도움이 된다. 코드를 테스트할 수 없다면 일반적으로 코드를 리팩토링 하거나 제거해야 함을 의미한다.

- 장고는 파이썬 프로그램의 코드 커버리지를 측정하기 위한 도구인 Coverage.py와 쉽게 통합할 수 있다.


출처

https://docs.djangoproject.com/en/4.0/intro/tutorial05/

반응형

댓글