KS blog

killins.egloos.com

포토로그



django 튜토리얼 #7/7 Test 자동화 by KillinS

* django 공식 홈페이지의 튜토리얼 작성법을 통해 간단한 설문조사 웹 프로젝트를 작성한다.
* 예제에서는 django에서 제공하는 개발용 웹서버를 이용하지만, 실제 서비스시에는 반드시 아파치와 같은 웹서버를 별도로 이용해야 한다.

1. Automated Test of Django


  - 장고는 대부분의 최신(?) 프레임워크들이 그렇듯 자동화된 테스트를 지원한다.
    shell 에서의 테스트는 물론 별도의 파이썬 코드를 통해 자동화된 테스트가 가능하고, 이에 따라 TDD도 적용 가능하다.

  - 테스트를 위한 test.py는 장고로 앱을 생성하면 자동으로 생성된다. 이 파일을 수정하여 원하는 테스트를 수행할 수 있다.

2. Command Shell 테스트

  - 파이썬의 command shell을 통해 오브젝트를 생성하고 원하는 테스트를 수행할 수 있다.
    예제로 생성한 polls 앱을 shell로 테스트하기 위해 프로젝트의 루트 디렉토리에서 아래와 같이 실행한다.

        python manage.py shell

  - Model Test
    shell에서 poll 오브젝트를 하나 생성하고 최근에 등록된 poll인지를 확인해본다.

        >>> import datetime
        >>> from django.utils import timezone
        >>> from polls.models import Poll
        >>> future_poll = Poll(pub_date=timezone.now() +  datetime.timedelta(days=10))
        >>> future_poll = was_published_recently()            # True

    사실 future_poll은 미래에 등록할 poll이기 때문에 위 결과는 잘못된 것이다. 이것은 다음에 수정하도록 한다.

  - View Test
    이번에는 view가 정상적으로 동작하는지 shell에서 테스트해본다.
    뷰를 테스트하기 위해서는 HTML을 처리할 수 있는 django.test.client 클래스가 필요하다.
    이 클래스를 이용해서 아래와 같이 polls 앱의 index 뷰를 테스트해본다.

        >>> from django.test.utils import setup_test_environment
        >>> setup_test_environment()
        >>> from django.test.client import Client
        >>> client = Client()
        >>> response = client.get('/')                # URL '/' 에서 response를 얻어온다
        >>> response.status_code
        404              # 해당 URL은 정의된 뷰가 없으므로 404 에러가 발생
        >>> from django.core.urlresolvers import reverse
        >>> response = client.get(reverse('polls:index'))        # /polls에서 response를 얻어온다
        >>> response.status_code
        200             # 해당 URL은 존재하므로 정상
        >>> response.content
        '\n\n\n    <p>No polls are available.</p>\n\n'    # 현재 등록된 poll은 없는 상태이다
        >>> from polls.models import Poll                             # 이제 poll 오브트를 등록하고 테스트를 진행할것이다.
        >>> from django.utils import timezone
        >>> p = Poll(question="Who is your favorite Beatle?", pub_date=timezone.now())
        >>> p.save()
        >>> response = client.get('/polls/')                     # /polls 에서 response를 얻어온다. 이번에는 URL을 하드코딩했다
        >>> response.content
        '\n\n\n    <ul>\n    \n        <li><a href="/polls/1/">Who is your favorite Beatle?
        </a></li>\n    \n    </ul>\n\n'                       # poll이 정상적으로 등록되었음을 알 수 있다.
        >>> response.context['latest_poll_list']
        [<Poll: Who is your favorite Beatle?>]                   # 최근 poll 함수도 정상 동작한다.

  - 위의 테스트 예제에서 볼 수 있듯 shell을 이용한 테스트는 간단한 테스트가 아닐경우 굉장히 복잡해진다.
    따라서 대부분의 장고 테스트는 tests.py 파일 작성을 통해 이루어진다.

3. tests.py로 테스트 : Model 테스트

  - shell 테스트의 단점을 보완하는 완전히 자동화된 테스트는 TestCase의 서브 클래스로 수행 할 수 있다.
    장고 시스템은 테스트를 수행하는 앱의 서브 디렉토리를 뒤져 TestCase의 서브 클래스들을 읽어온 뒤,
    테스트를 위함 임시 DB를 만들고 test로 시작하는 모든 함수들을 테스트하여 그 결과를 화면에 출력한다.
    장고는 TestCase를 찾기위해 앱 하위 디렉토리의 모든 파일들을 뒤지지만,
    보통은 테스트를 위해 앱의 루트 디렉토리에 tests.py 파일을 작성한다.

  - 우선 poll 모델의 was_published_recently() 함수를 테스트해보자.
    이 함수는 최근에 등록된 poll인지를 확인하는 함수이므로, 미래에 등록될 poll은 출력되면 안된다.
    아래와 같이 polls/tests.py 파일을 작성한다. (기본적으로 존재하는 dummy 테스트는 삭제하면 된다.)

      import datetime
      from django.utils import timezone
      from django.test import TestCase
      from polls.models import Poll
      class PollMethodTests(TestCase):
          def test_was_published_recently_with_future_poll(self):
              future_poll = Poll(pub_date=timezone.now() + datetime.timedelta(days=30))
              self.assertEqual(future_poll.was_published_recently(), False)

    작성한 polls 앱의 TestCase를 테스트하기 위해 아래 명령어를 실행한다.

      python manage.py test polls

    테스트 결과는 당연히 실패이다. 현재 Poll 모델은 1일 전 이후의 모든 poll을 recent로 취급하기 때문인다.

  - 정상 동작하도록 모델을 수정한다.
    polls/model.py 파일에 선언된 Poll 모델의 was_published_recentely() 함수를 아래와 같이 수정한다.

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

    수정 후 다시 테스트를 수행하면 이번에는 테스트 결과가 OK 인 것을 확인할 수 있다.

  - 몇가지 테스트를 좀 더 추가해본다.
    하루가 더 전인 과거에 등록된 poll에 대한 결과가 FALSE이고, 실제 하루 안에 작성된 pol의 결과는 TRUE인지 확인하기위해
    아래의 테스트 함수를 polls/test.py의 PollMethodTests 클래스에 추가하고 결과를 확인해본다.

      def test_was_published_recently_with_old_poll(self):
          old_poll = Poll(pub_date=timezone.now() - datetime.timedelta(days=30))
          self.assertEqual(old_poll.was_published_recently(), False)
     
      def test_was_published_recently_with_recent_poll(self):
          recent_poll = Poll(pub_date=timezone.now() - datetime.timedelta(hours=1))
          self.assertEqual(recent_poll.was_published_recently(), True)

4. View 테스트 #1 : ListView

  - 뷰를 테스트하기 위해서는 결과로 생성되는 웹 페이지에 대한 검증이 필요하다.
    뷰에서 결과로 리턴하는 response의 속성(변수 등)을 이용하면 되고,
    이것을 얻기위해 shell에서 테스트할때와 마찬가지로 Client 클래스를 이용하면 된다.
    (TestCase 클래스는 Client 클래스 오브젝트인 class를 멤버 변수로 갖고 있다)

  - 다양한 상황에서 index 뷰가 정상적으로 동작하는지 테스트하는 코드를 작성한다.
    polls/test.py 파일에 아래와 같은 함수와 클래스를 추가한다.

      from django.core.urlresolvers import reverse
      # 특정 질문과 날짜로 Poll 오브젝트를 생성하는 함수
      def create_poll(question, days):
          return Poll.objects.create(question=question, pub_date=timezone.now() + datetime.timedelta(days=days))

      # 테스트 클래스
      class PollViewTests(TestCase):
          # 아무런 poll이 없을때를 테스트
          def test_index_view_with_no_polls(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_poll_list'], [])

          # 오래된 poll이 하나 있을때를 테스트. 화면에 출력되어야 함
          def test_index_view_with_a_past_poll(self):
              create_poll(question="Past poll.", days=-30)
              response = self.client.get(reverse('polls:index'))
              self.assertQuerysetEqual(
                  response.context['latest_poll_list'], ['<Poll: Past poll.>'])

          # 미래의 poll이 하나 있을때를 테스트. 화면에 출력되면 안됨
          def test_index_view_with_a_future_poll(self):
              create_poll(question="Future poll.", days=30)
              response = self.client.get(reverse('polls:index'))
              self.assertContains(response, "No polls are available.", status_code=200)
              self.assertQuerysetEqual(response.context['latest_poll_list'], [])

          # 과거의 poll, 미래의 poll이 각각 하나씩 있을때. 과거만 출력되어야 함
          def test_index_view_with_future_poll_and_past_poll(self):
              create_poll(question="Past poll.", days=-30)
              create_poll(question="Future poll.", days=30)
              response = self.client.get(reverse('polls:index'))
              self.assertQuerysetEqual( response.context['latest_poll_list'], ['<Poll: Past poll.>'] )

          # 과거의 poll이 두개 있을때를 테스트. 둘 다 출력되어야 함     
          def test_index_view_with_two_past_polls(self):
              create_poll(question="Past poll 1.", days=-30)
              create_poll(question="Past poll 2.", days=-5)
              response = self.client.get(reverse('polls:index'))
              self.assertQuerysetEqual( response.context['latest_poll_list'], ['<Poll: Past poll 2.>', '<Poll: Past poll 1.>'] )


    TestCase 클래스의 client 멤버 변수를 통해 뷰의 결과값(response)를 얻어오고,
    그 결과에서 파라미터 등의 값을 얻어와 assertXXXX 함수를 통해 검증하는 과정을 응용하면 된다.
    실제 테스트 결과 실패하면 어떤 이유로 인해 실패했는지도 화면에 출력되기 때문에
    정확한 결과값을 모르더라도 테스트 과정에서 테스트 케이스에 대한 보정이 가능하다.

  - 위와 같은 테스트 코드를 작성하고 테스트 했을 때 미래 날짜로 등록된 poll도 출력이 된다.
    이것을 보완하기 위해 View를 수정한다.
    기존에 뷰파일을 사용하던 방식에서 URLconfs에서 ViewList를 사용하도록 변경했으므로
    URLconfs 파일의 ListView를 아래와 같이 수정한다.

      from django.utils import timezone
      ....
      url(r'^$',
          ListView.as_view(
              queryset=Poll.objects.filter(pub_date__lte=timezone.now).order_by('-pub_date')[:5],
              context_object_name='latest_poll_list',
              template_name='polls/index.html'),
              name='index'),


    수정후 다시 테스트해보면 모두 통과함을 알 수 있다.

5. View 테스트 #2 : DetailView

  - 이번에는 DetailView로 정의된 뷰를 테스트하도록 한다.
    기본적으로 뷰의 response를 얻어오는 것은 동일하다.
    다만 DetailView의 경우 특정 오브젝트에 대한 정보를 가져오는 것이므로 파라미터를 패싱하는것에 주의하면 된다.

  - 테스트 코드는 아래와 같다.

      class PollIndexDetailTests(TestCase):

          # 미래 poll의 detail 뷰 테스트. 페이지를 찾을 수 없어야 한다
          def test_detail_view_with_a_future_poll(self):
              future_poll = create_poll(question='Future poll.', days=5)
              response = self.client.get(reverse('polls:detail', args=(future_poll.id,)))
              self.assertEqual(response.status_code, 404)

          # 지난 뷰에 대한 테스트. 질문 내용 및 응답상태만 확인한다.
          def test_detail_view_with_a_past_poll(self):
              past_poll = create_poll(question='Past Poll.', days=-5)
              response = self.client.get(reverse('polls:detail', args=(past_poll.id,)))
              self.assertContains(response, past_poll.question, status_code=200)


    ListView를 테스트할때와 비슷하지만, response를 얻어올 때 파라미터로 poll id를 전달하는것에 주의한다.

  - 현재 Detail 뷰에서 poll id가 유효한 것인지 검증하는 부분은 없으므로, 위 첫번째 테스트는 실패하게 된다.
    유효하지 않은 poll id에 대한 Detail view 접근은 차단하도록 URLconfs 파일을 아래와 같이 수정한다.

      url(r'^(?P<pk>\d+)/$',
          DetailView.as_view(
              queryset=Poll.objects.filter(pub_date__lte=timezone.now),
              model=Poll,
              template_name='polls/detail.html'),
          name='detail'),


    뷰 수정 후 다시 테스트 해보면 정상적으로 통과됨을 알 수 있다.

6. 테스트 주의사항

  - 자동화된 테스트를 위해서는 위에도 언급한것처럼 어디서든 TestCase의 서브 클래스를 정의하고,
    test_로 시작하는 멤버함수를 정의하면 장고가 알아서 테스트를 수행해준다.
    그렇다고해서 여기저기에 테스트 코드를 정의하면 나중에 프로젝트가 점점 커지게 되었을때 감당할 수 없으므로,
    아래와 같은 원칙을 테스트에 적용할 것을 권장한다.

    1) 테스트 코드는 앱별로 하나의 파이썬 파일에 작성한다.

    2) 각 모델이나 뷰별로 테스트 클래스를 별도로 정의한다.

    3) 테스트하고자 하는 조건별로 별도의 함수를 정의한다.

    4) 테스트 함수 명은 어떤 조건을 테스트하는지 알 수 있도록 정의한다.


* 참고 : https://docs.djangoproject.com/en/1.5/intro/tutorial05/