backend/django

django tutorial: Testing(테스팅) (part5)

seul chan 2017. 3. 3. 17:32

이제부터 우리는 만들어진 poll 앱에 대한 자동화된 테스트를 만들 것이다.


**Introducing automated testing


*What are automated tests?

테스트는 코드의 동작을 체크하기 위한 간단한 routine이다

자동화된 테스트는 시스템상에서 테스트 작업이 진행되는 것! 한 번 설정 해 놓으면 이후게 지속적으로 작업할 수 있다.


*Why you need to create tests

당신은 아마도 파이썬/장고에 대해 한참 배울 게 남았는데 왜 테스트를 하는지 의문이 들것이다. 

poll 애플리케이션은 상당히 괜찮고, 테스트를 만드는게 앱 작업을 더 좋게 만들어 줄 것 같지도 않다.

만일 장고 프로그래밍에서 polls 애플리케이션같은 것만 만들거라면 테스트가 필요 없지만, 그게 아니라면 지금이 테스트를 배우기 가장 좋은 시간이다.


*Tests will save you time

만약 무언가가 잘못되었다면, 테스트는 코드를 분석해서 왜 이상한 작동을 보이는지 알아내는것을 도와준다.


*Tests don’t just identify problems, they prevent them

테스트를 개발의 부정적인 측면으로만 생각하면 안된다.

테스트가 없다면 애플리케이션의 목적이 모호해진다 (?) 

테스트는 코드를 내부에서부터 light up 시켜주고, 무엇인가 잘못된다면 당신이 그것을 알아차리기 전에 포커스를 맞추어준다.


*tests make your code more attractive

테스트가 없다면 아주 훌륭한 작품도 많은 개발자들이 쳐다보지 않는다. 

장고 오리지널 개발자 중 한명인 Jacob Kaplan-Moss는 "Code without tests is broken by design."이라고 말한 바 있다.


*Tests help teams work together

복잡한 애플리케이션은 팀에 의해 유지보수된다. 테스트는 팀원들이 무심코 코드를 망치는 것을 방지해준다. 

만약 당신이 장고 프로그래머로서의 삶을 살고자 한다면, 테스트에 탁월해야한다!


이제 테스트가 왜 필요한지의 설명은 접어두고 본격적인 테스트 전략에 대해 살펴보겠다. 


**Basic testing strategies

테스트를 작성하는 데에는 많은 접근 방법이 있다.


-몇몇 개발자들은 'test-driven development'라는 방식을 따른다.

=> 코드를 작성하기 전에 테스를 먼저 작성


-테스트 초심자들은 대부분 코드를작성하고 테스트를 작성

=> 언제 테스트 작성을 시작해야하나? 수천 줄의 코드를 작성했다면 어디서 시작해야하는지 고르는게 힘들 수 이tek.

=> 버그를 수정하면서 코드를 추가할 때 첫 테스트를 추가하면 좋다.


**Writing our first test


*We identify a bug

다행히도(?) polls 애플리케이션에는 작은 버그가 있다.

Questions.was_published_recently() 메소드가 실제로 correct일 때 말고 pub_date가 미래일 때도 True를 내놓는 것이다! (확실하지는 않다)


이 버그가 실재하는지 보기 위해서 shell을 활용하여 질문을 만들어 보자

mysite(장고 앱) 폴더에 위치한 뒤 

$python manage.py shell # 파이썬 쉘 진입

---------

>>> import datetime

>>> from django.utils import timezone

>>> from polls.models import Question

>>> # create a Question instance with pub_date 30 days in the future

>>> future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30))

>>> # was it published recently?

>>> future_question.was_published_recently()

True

--------

현재 날짜로부터 30일이 지난 question이 True를 반환하는 작은 버그를 확인할 수 있다.

(미래는 recent가 아닌가?)



*Create a test to expose the bug

우리가 shell에서 한 작업은 사실 자동화된 테스트에서 구현할 수 있는 것이므로 테스트를 자동화 해보자


애플리케이션에서 테스트를 하기 편한 장소는 앱 내의 tests.py이다.

=> 테스트 시스템은 자동으로 test로 시작하는 파일을 작동시킨다.


polls 애플리케이션 내의 tests.py에 다음 코드를 작성한다.

polls/tests.py에

-----------

import datetime


from django.utils import timezone

from django.test import TestCase


from .models import Question



class QuestionMethodTests(TestCase):


    def test_was_published_recently_with_future_question(self):

        """

        was_published_recently() should return False for questions whose

        pub_date is in the future.

        """

        time = timezone.now() + datetime.timedelta(days=30)

        future_question = Question(pub_date=time)

        self.assertIs(future_question.was_published_recently(), False)

----------

여기서 우리가 한 것은 

=> pub_date가 미래인 Question인스턴스를 만드는 django.test.TestCase 서브클래스를 만듬

=> 그리고 output (was_published_recently())이 False인지를 체크



*Running tests

터미널에서 다음과 같이 입력한다

$python manage.py test polls

-----------------------

Creating test database for alias 'default'...

F

======================================================================

FAIL: test_was_published_recently_with_future_question (polls.tests.QuestionMethodTests)

----------------------------------------------------------------------

Traceback (most recent call last):

  File "/path/to/mysite/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'...

----------------------

(실패할 경우 해결책 --> http://seulcode.tistory.com/120 참고)


이렇게 뜨면 성공한 것이다. 무슨 일이 있던 것일까?


-$python manage.py test polls는 polls 앱의 테스트를 시행한다

-이는 django.test.TestCase 클래스의 서브클래스를 찾아낸다

-이는 테스트를 목적으로 하는 특별한 DB를 생성한다.

# 그렇기 때문에 아까 DB 생성 권한이 없는 상태에서 계속 오류가 발생했던 것 같다.

-test로 시작하는 테스트 메소드들을 살펴본다.

-test_was_published_recently_with_future_question 안에서 pub_date 필드가 30일 미래로 되어있는 question 인스턴스를 만든다

-그리고 assertIs() 메소드를 사용, was_published_recently()가 False여야 하는데 True로 나온 것을 알아낸다!


그리고 당신에게 테스트가 실패했음을 알려준다. 


*Fixing the bug

Qeustion.was_published_recently()는 미래에 만들어졌을 경우에 False가 나와야 한다'

따라서 과거일 경우에만 True로 models.py를 수정하자

polls/models.py를 열어

-------

def was_published_recently(self):

    now = timezone.now()

    return now - datetime.timedelta(days=1) <= self.pub_date <= now

------

로 수정하고 테스트를 다시 실행해보자

----------

Creating test database for alias 'default'...

.

----------------------------------------------------------------------

Ran 1 test in 0.001s


OK

Destroying test database for alias 'default'...

----------

버그를 알아낸 뒤 코드를 수정하자 테스트를 통과한 것을 볼 수 있다. 



*More comprehensive tests

같은 class에 두 가지 테스트 메소드를 추가해 보자

polls/tests.py에

--------

def test_was_published_recently_with_old_question(self):

    """

    was_published_recently() should return False for questions whose

    pub_date is older than 1 day.

    """

    time = timezone.now() - datetime.timedelta(days=30)

    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() should return True for questions whose

    pub_date is within the last day.

    """

    time = timezone.now() - datetime.timedelta(hours=1)

    recent_question = Question(pub_date=time)

    self.assertIs(recent_question.was_published_recently(), True)

--------

이제 was_published_recently()는 과거, recent, 미래에 만들어진 질문들에 대해 테스트를 실행할 수 있다.




**Test a view
현재 polls 애플리케이션은 미래로 pub_date가 설정된 질문들을 마구 포함해서 무차별적으로 배포한다. 
이를 pub_date가 미래로 되어 있는 질문들은 그 시점에 배포되고, 그 전까지는 보이지 않게 설정 해보자.

*A test for a view
이번 테스트에서는 웹 브라우저에서의 behavior을 체크해보아야 한다. (??)

*The Django test client
장고는 test Client를 제공한다
=> view 레벨에서의 사용자 인터렉팅을 제공
=> tesst.py나 shell에서 사용 가능하다.

tests.py에서 필요없는 몇 가지가 있으므로 shell에서 진행해보자. 
$python manage.py shell #로 쉘을 작동시킨 뒤에
-------------------------
>>> from django.test.utils import setup_test_environment
>>> setup_test_environment()
------------------------
setup_test_environment()는 템플릿 렌더러를 설치해줌
=> response.context같은 추가적인 response 속성을 examine 할 수 있게 도와줌
=> 이 메소드는 test db에 설치되지 않기 때문에 만들어진 questions들에 따라 아웃풋이 다를 수 있다.(??)
=> settings.py의 TIME_ZONE이 정확하지 않으면 기대하지 않은 결과를 받아보 것이다.


다음은 test client class를 불러와서 
--------
>>> from django.test import Client
>>> # create an instance of the client for our use
>>> client = Client()
--------
준비가 되면, client에게 다음과 같은 작업을 명령한다
-------
>>> # get a response from '/'
>>> response = client.get('/')
>>> # we should expect a 404 from that address
>>> response.status_code
404
>>> # on the other hand we should expect to find something at '/polls/'
>>> # we'll use 'reverse()' rather than a hardcoded URL
>>> from django.urls import reverse
>>> response = client.get(reverse('polls:index'))
>>> response.status_code
200
>>> response.content
b'\n    <ul>\n    \n        <li><a href="/polls/1/">What&#39;s up?</a></li>\n    \n    </ul>\n\n'
>>> # If the following doesn't work, you probably omitted the call to
>>> # setup_test_environment() described above
>>> response.context['latest_question_list']
<QuerySet [<Question: What's up?>]>
-------------------           
(사실 정확이 client가 무슨 역할을 해주고 저게 무슨 의미인지는 잘 모르겠다. 추후 더 공부하자)
           
    
*Improving our view           
현재 polls의 리스트들은 아직 배포되지 않은 (pub_date가 미래로 되어있는) poll들도 보여준다. 
튜토리얼 4에서 우리는 class-based-view를 살펴보았다 (ListView)

우리는 get_queryset() 메소드를 timezone.now()과 비교하여 날짜를 체크하는 것이 필요하다.
그래서 polls/views.py의 get_queryset 메소드가
-------
def get_queryset(self):
    """
    Return the last five published questions (not including those set to be
    published in the future).
    """
    return Question.objects.filter(
        pub_date__lte=timezone.now()
    ).order_by('-pub_date')[:5]           
------
로 변경하였다.           
          
Question.objects.filter(pub_date__lte=timezone.now()은 Question을 담은 쿼리셋을 리턴한다. 
(-timezone.now와 같거나 이른 것들만...)       
      
                        
*Testing our new view                        
과거와 미래의 날짜를 가진 Qeustions를 만들고, 이들 중 이미 배포된 것들만 리스트로 만들어져야한다. 
shell session에 기반한 test를 만들어보자
                        
polls/tests.py에서 다음을 추가한다
-----------
from django.urls import reverse                        
-----------                        
그리고 새로운 테스트 클래스의 shortcut function을 만들자
polls/test.py에서
------------
def create_question(question_text, days):
    """
    Creates 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 QuestionViewTests(TestCase):
    def test_index_view_with_no_questions(self):
        """
        If no questions exist, an appropriate message should be 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_index_view_with_a_past_question(self):
        """
        Questions with a pub_date in the past should be 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_index_view_with_a_future_question(self):
        """
        Questions with a pub_date in the future should not be 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_index_view_with_future_question_and_past_question(self):
        """
        Even if both past and future questions exist, only past questions
        should be 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_index_view_with_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.>']
        )                        
-----------                        
-첫 숏컷인 create_question은 질문을 만드는 것의 반복적인 기능                                  
-test_index_view_with_no_questions => 질문을 만들지 않고 No polls are available 메세지를 체크
=> latest_question_list가 비어있는 것을 확인
=> django.test.Testcase 클래스는 추가적인 asertion 모델을 제공: assertContains()와 assertQuerysetEqual()
-test_index_view_with_a_past_question => 질문을 만들어 리스트에 있는지 체크                        
-test_index_view_with_a_future_question => pub_date가 미래인 질문을 만들고 데이터베이스를 리셋하여 index에 아무런 질문도 없게 한다
(??)
                        
*Testing the DetailView
미래 질문들이 index에 뜨지 않지만 사용자들은 맞는 URL인지에 대해 알아야 한다(?)
polls/views.py를 열고
-----------
class DetailView(generic.DetailView):
    ...
    def get_queryset(self):
        """
        Excludes any questions that aren't published yet.
        """
        return Question.objects.filter(pub_date__lte=timezone.now())                        
-------------                        
                        
또한 pub_date가 과거인 질문은 디스플레이 되고, 미래인 질문은 그렇지 않는 테스트를 만든다
polls/tests.py를 열고
---------
class QuestionIndexDetailTests(TestCase):
    def test_detail_view_with_a_future_question(self):
        """
        The detail view of a question with a pub_date in the future should
        return 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_detail_view_with_a_past_question(self):
        """
        The detail view of a question with a pub_date in the past should
        display 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)                        
---------                        
를 추가한다.

*Ideas for more tests 
We ought to add a similar get_queryset method to ResultsView and create a new test class for that view. It’ll be very similar to what we have just created; in fact there will be a lot of repetition.

We could also improve our application in other ways, adding tests along the way. For example, it’s silly that Questions can be published on the site that have no Choices. So, our views could check for this, and exclude such Questions. Our tests would create a Question without Choices and then test that it’s not published, as well as create a similar Question with Choices, and test that it is published.

Perhaps logged-in admin users should be allowed to see unpublished Questions, but not ordinary visitors. Again: whatever needs to be added to the software to accomplish this should be accompanied by a test, whether you write the test first and then make the code pass the test, or work out the logic in your code first and then write a test to prove it.

At a certain point you are bound to look at your tests and wonder whether your code is suffering from test bloat, which brings us to: (읽어보자)                        
                        
                        
**When testing, more is better
테스트가 점점 out of control 되어 가는 것처럼 보인다... 
하지만 상관없다! 이는 계속 프로그램의 발전에 유용하게 사용될 것
가끔은 테스트 업데이트가 필요하다.
최악의 상황에는 불필요한 테스트를 발견할 것이다. (??)

Good rules-of-thumb은 다음을 포함
-model, view별로 나눠진 TestClass
-테스트 하고자 하는 conditions별로 나눠진 테스트 메소드
-기능을 나타내주는 테스트 이름


**Further testing

이 튜토리얼은 테스팅의 기본만을 소개, 하지만 더 유용하고 똑똑한 것들을 도와주는 많은 것들이 있음

Selenium과 같은 브라우저 프레임워크 (HTML이 실제 어떻게 브라우저에서 돌아가는지)

-장고는 LiveServerTestCase(https://docs.djangoproject.com/en/1.10/topics/testing/tools/#django.test.LiveServerTestCase)

를 포함


더 많은 정보는 Testing in Django(https://docs.djangoproject.com/en/1.10/topics/testing/)를 참고하면 된다