[TDD] Test-Driven Development with Python 5장

SUNGJIN KIM·2022년 7월 9일
0

tdd-with-python

목록 보기
5/7
post-thumbnail

˗ˋˏ♡ˎˊ˗ 사용자 입력 저장하기

  • POST 요청을 전송하기 위한 폼(Form) 연동
  • 서버에서 POST 요청 처리
  • 파이썬 변수를 전달해서 템플릿에 출력하기
  • 스트라이크 세 개면 리팩터
  • Django ORM과 첫 모델
  • POST를 데이터베이스에 저장하기
  • POST 후에 리디렉션
  • 템플릿에 있는 아이템 렌더링
  • 마이그레이션을 이용한 운영 데이터베이스 생성하기

Post 요청을 전송하기 위한 폼(Form) 연동

이전 테스트 결과에서는 "사용자 입력을 저장할 수 없다"는 메시지가 출력되었기에,
Post 요청을 통해 사용자 입력을 저장하도록 한다.

lists/templates/home.html

<html>

<head>
    <title>To-Do lists</title>
</head>

<body>
    <h1>Your To-Do list</h1>
    <form method="POST">
        <input name="item_text" id="id_new_item" placeholder="작업 아이템 입력" />
    </form>
    <table id="id_list_table">
    </table>
</body>

</html>

POST 요청을 보내기 위해 input 요소에 name 속성을 지정하고, form 태그로 감싼다.
위와 같이 코드를 수정한다.

이렇게 변경하고 코드를 실행하면 아래와 같이 결과가 나온다. (테스트 오류)

  File "/selenium/webdriver/remote/errorhandler.py", line 247, in check_response
    raise exception_class(message, screen, stacktrace)
selenium.common.exceptions.StaleElementReferenceException: Message: The element reference of <table id="id_list_table"> is stale; either the element is no longer attached to the DOM, it is not in the current frame context, or the document has been refreshed
Stacktrace:

오류가 나오는 부분을 확인하기 위해, time.sleep을 코드에 추가해본다.
Functional_test.py

 		# 엔터키를 치면 페이지가 갱신되고 작업 목록에
        # "1: 공작깃털 사기" 아이템이 추가된다
        inputbox.send_keys(Keys.ENTER)
        time.sleep(10)

        table = self.browser.find_element(By.ID, 'id_list_table')
        rows = table.find_elements(By.TAG_NAME, 'tr')```
코드를 입력하세요
    self.assertTrue(
        any(row.text == '1: 공작깃털 사기' for row in rows),
        "신규 작업이 테이블에 표시되지 않는다"
    )
코드를 변경하고 실행해보게되면 아래와 같이 페이지가 노출된다.
![](https://velog.velcdn.com/images/woonmong/post/ee575d01-a830-4b33-ae77-76f2b55e6fdd/image.png)

이전까지는 순수 HTML로만 이루어진 템플릿을 사용했기에 위와 같은 오류가 발생하지 않았다.
CSRF 토큰을 추가하기 위해 템플릿 태그를 사용한다. html에 아래 코드를 추가한다.
```html
    <form method="POST">
        <input name="item_text" id="id_new_item" placeholder="작업 아이템 입력" />
        {% csrf_token %}
    </form>
    <table id="id_list_table">
    </table>

CSRF 토큰을 포함하는 <input type="hidden">요소로 변경해서 렌더링 하고 기능 테스트를 실행하면 예측한 결과를 확인할 수 있다.

F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/functional_test.py", line 47, in test_can_start_a_list_and_retrieve_it_later
    self.assertTrue(
AssertionError: False is not true : 신규 작업이 테이블에 표시되지 않는다

----------------------------------------------------------------------
Ran 1 test in 14.461s

FAILED (failures=1)

동작이 정상적으로 작동했음을 확인했다면 아까추가한 time.sleep을 삭제해준다.

서버에서 POST 요청 처리

폼에 아직 action 속성을 지정하지 않아 동일 URL을 전달해서 기보 설정된 페이지를 다시 표시한다.
이때 사용되는 것이 home_page 함수인데, 이를 수정해서 POST 요청을 이용해본다.

lists/test.py 내 HomePageTest 에 새로운 메소드를 추가한다.

  • POST 요청 처리와 반환된 HTML이 신규 아이템 텍스트를 포함하고 있는지 확인하는 로직 추가

lists/test.py

    def test_home_page_can_save_a_POST_request(self):
        request = HttpRequest()
        request.method = 'POST'
        request.POST['item_text'] = '신규 작업 아이템'

        response = home_page(request)
        self.assertIn('신규 작업 아이템', response.content.decode())

해당 테스트를 추가하고, 테스트를 진행해보면 아래와 같이 결과가 나온다.

$ python3 manage.py test
Found 3 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F..
======================================================================
FAIL: test_home_page_can_save_a_POST_request (lists.tests.HomePageTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/superlists/lists/tests.py", line 31, in test_home_page_can_save_a_POST_request
    self.assertIn('신규 작업 아이템', response.content.decode())
AssertionError: '신규 작업 아이템' not found in '<html>\n\n<head>\n    <title>To-Do lists</title>\n</head>\n\n<body>\n    <h1>Your To-Do list</h1>\n    <form method="POST">\n        <input name="item_text" id="id_new_item" placeholder="작업 아이템 입력" />\n        <input type="hidden" name="csrToken" value="value_code">\n    </form>\n    <table id="id_list_table">\n    </table>\n</body>\n\n</html>'

----------------------------------------------------------------------
Ran 3 tests in 0.003s

FAILED (failures=1)
Destroying test database for alias 'default'...

해당 테스트를 통과시키기 위해서는 if 분기를 추가하여 POST 요청을 위한 별도 처리를 기술한다.

lists/views.py

from django.shortcuts import render
from django.http import HttpResponse


def home_page(request):
    if request.method == 'POST':
        return HttpResponse(request.POST['item_text'])
    return render(request, 'home.html')

이렇게 바꾸고 다시 test를 진행해보면 테스트가 통과됨을 확인할 수 있다.

파이썬 변수를 전달해서 템플릿에 출력하기

lists/templates/home.html

<html>

<head>
    <title>To-Do lists</title>
</head>

<body>
    <h1>Your To-Do list</h1>
    <form method="POST">
        <input name="item_text" id="id_new_item" placeholder="작업 아이템 입력" />
        {% csrf_token %}
    </form>
    <table id="id_list_table">
        <tr>
            <td>{{new_item_text}}</td>
        </tr>
    </table>
</body>

</html>

lists/tests.py 에 전달할 변수를 추가해준다.
lists/tests.py

    def test_home_page_can_save_a_POST_request(self):
        request = HttpRequest()
        request.method = 'POST'
        request.POST['item_text'] = '신규 작업 아이템'

        response = home_page(request)
        self.assertIn('신규 작업 아이템', response.content.decode())
        expected_html = render_to_string(
            'home.html',
            {'new_item_text': '신규 작업 아이템'}
        )
        self.assertEqual(response.content.decode(), expected_html)

단위 테스트를 진행하면 render_to_string이 신규 작업 아이템으로 교체해서 td 내에 배치하는데,
아직 뷰 처리가 없기에 테스트가 실패하게 된다.

Traceback (most recent call last):
  File "/lists/tests.py", line 34, in test_home_page_returns_correct_html
    response = home_page(request)
  File "/lists/views.py", line 12, in home_page
    'new_item_text' : request.POST['item_text'],
  File "/datastructures.py", line 78, in __getitem__
    raise MultiValueDictKeyError(key)
django.utils.datastructures.MultiValueDictKeyError: 'item_text'

여기까지 진행했으면 views.py 내 코드를 POST.get 으로 수정해준다.

lists/views.py

def home_page(request):
    return render(request, 'home.html', {
        'new_item_text': request.POST.get('item_text', ''),
    })

이렇게 변경하고 단위테스트와 기능 테스트 진행 시 단위테스트는 통과, 기능 테스트는 아래와 같이 실패되는 것을 확인할 수 있다.

F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/functional_test.py", line 47, in test_can_start_a_list_and_retrieve_it_later
    self.assertTrue(
AssertionError: False is not true : 신규 작업이 테이블에 표시되지 않는다

----------------------------------------------------------------------
Ran 1 test in 3.685s

FAILED (failures=1)

해당 에러 메시지는 도움이 안되므로 에러 메시지를 개선해보도록 한다.
에러 메시지 개선은 가장 구조적인 기술로, 발생하게 될 모든 에러에 적용할 수 있다.

functional_test.py

  • 중간에 sleep()를 넣지 않으면 자꾸 오류가 발생하여 넣어주었다.
# "공작깃털 사기" 라고 텍스트 상자에 입력한다
        # (에디스의 취미는 날치 잡이용 그물을 만드는 것이다)
        inputbox.send_keys('공작깃털 사기')
        

        # 엔터키를 치면 페이지가 갱신되고 작업 목록에
        # "1: 공작깃털 사기" 아이템이 추가된다
        inputbox.send_keys(Keys.ENTER)

        time.sleep(5)
        table = self.browser.find_element(By.ID, 'id_list_table')
        rows = table.find_elements(By.TAG_NAME, 'tr')
        self.assertTrue(
            any(row.text == '1: 공작깃털 사기' for row in rows),
            "신규 작업이 테이블에 표시되지 않는다 -- 해당 텍스트 :\n%s" % (
                table.text,
            )
        )

결과값은 아래와 같다.

F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "superlists/functional_test.py", line 50, in test_can_start_a_list_and_retrieve_it_later
    self.assertTrue(
AssertionError: False is not true : 신규 작업이 테이블에 표시되지 않는다 -- 해당 텍스트 :
공작깃털 사기

----------------------------------------------------------------------
Ran 1 test in 8.511s

FAILED (failures=1)

조금 더 개선을 해보자면 아래와 같이 개선하면 된다.

        self.assertIn('1: 공작깃털 사기', [row.text for row in rows])

이렇게 했을 경우 결과는 아래와 같이 나온다.

F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/superlists/functional_test.py", line 56, in test_can_start_a_list_and_retrieve_it_later
    self.assertIn('1: 공작깃털 사기', [row.text for row in rows])
AssertionError: '1: 공작깃털 사기' not found in ['공작깃털 사기']

----------------------------------------------------------------------
Ran 1 test in 8.639s

FAILED (failures=1)

이외에도 편법을 쓰자면, html 내 new_item_text 를 지정할때 아예 "1:" 을 넣어주도록 한다. 현재 테스트는 그렇게 진행해도 통과된다.
<td>1: {{new_item_text}}</td>

두 번째 아이템을 테이블에 추가하는 처리를 하면 해당 편법이 듣지 않는 것을 확인할 수 있다.
functional_test.py

from selenium import webdriver
from selenium.webdriver.common.keys import Keys
import unittest
from selenium.webdriver.common.by import By
# from selenium.webdriver.support.ui import WebDriverWait
# from selenium.webdriver.support import expected_conditions
import time


class NewVisitorTest(unittest.TestCase):

    def setUp(self):
        self.browser = webdriver.Firefox()
        self.browser.implicitly_wait(3)

    def tearDown(self):
        self.browser.quit()

    def test_can_start_a_list_and_retrieve_it_later(self):
        # 에디스(Edith)는 멋진 작업 목록 온라인 앱이 나왔다는 소식을 듣고
        # 해당 웹 사이트를 확인하러 간다.
        self.browser.get('http://localhost:8000')

        # 웹 페이지 타이틀과 헤더가 'To-Do'를 표시하고 있다.
        self.assertIn('To-Do', self.browser.title)
        header_text = self.browser.find_elements(By.TAG_NAME, 'h1')
        for i in header_text:
            self.assertIn('To-Do', i.text)

        # 그녀는 바로 작업을 추가하기로 한다
        # inputbox = self.browser.find_element_by_id('id_new_item')
        inputbox = self.browser.find_element(By.ID, 'id_new_item')
        self.assertEqual(
            inputbox.get_attribute('placeholder'),
            '작업 아이템 입력'
        )

        # "공작깃털 사기" 라고 텍스트 상자에 입력한다
        # (에디스의 취미는 날치 잡이용 그물을 만드는 것이다)
        inputbox.send_keys('공작깃털 사기')
        

        # 엔터키를 치면 페이지가 갱신되고 작업 목록에
        # "1: 공작깃털 사기" 아이템이 추가된다
        inputbox.send_keys(Keys.ENTER)

        time.sleep(1)
        table = self.browser.find_element(By.ID, 'id_list_table')
        rows = table.find_elements(By.TAG_NAME, 'tr')
        # self.assertTrue(
        #     any(row.text == '1: 공작깃털 사기' for row in rows),
        #     "신규 작업이 테이블에 표시되지 않는다 -- 해당 텍스트 :\n%s" % (
        #         table.text,
        #     )
        # )
        self.assertIn('1: 공작깃털 사기', [row.text for row in rows])

        # 추가 아이템을 입력할 수 있는 여분의 텍스트 상자가 존재한다.
        # 다시 "공작깃털을 이용해서 그물 만들기" 라고 입력한다 
        # (에디스는 매우 체계적인 사람이다)
        inputbox = self.browser.find_element(By.ID,'id_new_item')
        inputbox.send_keys('공작깃털을 이용해서 그물 만들기')
        inputbox.send_keys(Keys.ENTER)

        # 페이지는 다시 갱신되고, 두 개 아이템이 목록에 보인다
        time.sleep(1)
        table = self.browser.find_element(By.ID, 'id_list_table')
        rows = table.find_elements(By.TAG_NAME,'tr')
        self.assertIn('1: 공작깃털 사기', [row.text for row in rows])
        self.assertIn(
            '2: 공작깃털을 이용해서 그물 만들기',
            [row.text for row in rows]
        )
        # 에디스는 사이트가 입력한 목록을 저장하고 있는지 구감하다
        # 사이트는 그녀를 위한 특정 URL을 생성해준다
        # 이때 URL에 대한 설명도 함께 제공된다

        # 해당 URL에 접속하면 그녀가 만든 작업 목록이 그대로 있는 것을 확인할 수 있다
        self.fail('Finish the test!')
        # 만족하고 잠자리에 든다.


if __name__ == '__main__':
    unittest.main(warnings='ignore')

테스트를 실행하면 다음과 같은 에러를 반환한다.

F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "functional_test.py", line 69, in test_can_start_a_list_and_retrieve_it_later
    self.assertIn('1: 공작깃털 사기', [row.text for row in rows])
AssertionError: '1: 공작깃털 사기' not found in ['1: 공작깃털을 이용해서 그물 만들기']

----------------------------------------------------------------------
Ran 1 test in 5.649s

FAILED (failures=1)

스트라이크 세 개면 리팩터 (Three strikes and Refactor)

해당 말은 한 번 정도 복사-붙여 넣기를 해줄 수 있지만, 같은 코드가 세 번 등장하게 되면 중복을 제거해야한다는 이론이다.

tearDown 아래에 코드를 추가한다.
functional_test.py

    def tearDown(self):
        self.browser.quit()

    def check_for_row_in_list_table(self, row_text):
        table = self.browser.find_element(By.ID, 'id_list_table')
        rows = table.find_elements(By.TAG_NAME,'tr')
        self.assertIn(row_text,[row.text for row in rows])

해당 코드는 어떤 코드냐면, 바로 이전에 작성한 코드를 위에 "check_for_row_in_list_table(self,row_text)로 만들어 준 것으로 보인다.

그리고 functional_test.py 코드를 수정한다.

from selenium import webdriver
from selenium.webdriver.common.keys import Keys
import unittest
from selenium.webdriver.common.by import By
# from selenium.webdriver.support.ui import WebDriverWait
# from selenium.webdriver.support import expected_conditions
import time


class NewVisitorTest(unittest.TestCase):

    def setUp(self):
        self.browser = webdriver.Firefox()
        self.browser.implicitly_wait(3)

    def tearDown(self):
        self.browser.quit()

    def check_for_row_in_list_table(self, row_text):
        table = self.browser.find_element(By.ID, 'id_list_table')
        rows = table.find_elements(By.TAG_NAME,'tr')
        self.assertIn(row_text,[row.text for row in rows])

    def test_can_start_a_list_and_retrieve_it_later(self):
        # 에디스(Edith)는 멋진 작업 목록 온라인 앱이 나왔다는 소식을 듣고
        # 해당 웹 사이트를 확인하러 간다.
        self.browser.get('http://localhost:8000')

        # 웹 페이지 타이틀과 헤더가 'To-Do'를 표시하고 있다.
        self.assertIn('To-Do', self.browser.title)
        header_text = self.browser.find_elements(By.TAG_NAME, 'h1')
        for i in header_text:
            self.assertIn('To-Do', i.text)

        # 그녀는 바로 작업을 추가하기로 한다
        # inputbox = self.browser.find_element_by_id('id_new_item')
        inputbox = self.browser.find_element(By.ID, 'id_new_item')
        self.assertEqual(
            inputbox.get_attribute('placeholder'),
            '작업 아이템 입력'
        )

        # "공작깃털 사기" 라고 텍스트 상자에 입력한다
        # (에디스의 취미는 날치 잡이용 그물을 만드는 것이다)
        inputbox.send_keys('공작깃털 사기')
        

        # 엔터키를 치면 페이지가 갱신되고 작업 목록에
        # "1: 공작깃털 사기" 아이템이 추가된다
        inputbox.send_keys(Keys.ENTER)
        time.sleep(1)
        self.check_for_row_in_list_table('1: 공작깃털 사기')

        # table = self.browser.find_element(By.ID, 'id_list_table')
        # rows = table.find_elements(By.TAG_NAME, 'tr')
        # # self.assertTrue(
        # #     any(row.text == '1: 공작깃털 사기' for row in rows),
        # #     "신규 작업이 테이블에 표시되지 않는다 -- 해당 텍스트 :\n%s" % (
        # #         table.text,
        # #     )
        # # )
        # self.assertIn('1: 공작깃털 사기', [row.text for row in rows])

        # 추가 아이템을 입력할 수 있는 여분의 텍스트 상자가 존재한다.
        # 다시 "공작깃털을 이용해서 그물 만들기" 라고 입력한다 
        # (에디스는 매우 체계적인 사람이다)
        inputbox = self.browser.find_element(By.ID,'id_new_item')
        inputbox.send_keys('공작깃털을 이용해서 그물 만들기')
        inputbox.send_keys(Keys.ENTER)

        # 페이지는 다시 갱신되고, 두 개 아이템이 목록에 보인다
        time.sleep(1)
        self.check_for_row_in_list_table('1: 공작깃털 사기')
        self.check_for_row_in_list_table('2: 공작깃털을 이용해서 그물 만들기')
        # time.sleep(1)

        # time.sleep(1)
        # table = self.browser.find_element(By.ID, 'id_list_table')
        # rows = table.find_elements(By.TAG_NAME,'tr')
        # self.assertIn('1: 공작깃털 사기', [row.text for row in rows])
        # self.assertIn(
        #     '2: 공작깃털을 이용해서 그물 만들기',
        #     [row.text for row in rows]
        # )
        # 에디스는 사이트가 입력한 목록을 저장하고 있는지 구감하다
        # 사이트는 그녀를 위한 특정 URL을 생성해준다
        # 이때 URL에 대한 설명도 함께 제공된다

        # 해당 URL에 접속하면 그녀가 만든 작업 목록이 그대로 있는 것을 확인할 수 있다
        self.fail('Finish the test!')
        # 만족하고 잠자리에 든다.


if __name__ == '__main__':
    unittest.main(warnings='ignore')

한가지 참고 사항은, FT 실행 시, 책에서 나오는 결과값과 같지가 않아서 확인해보니 이전에 실행했을 때는 "1. 공작깃털 사기"가 먼저 assertIn되었고, 그다음에 "2. 공작깃털을 이용해서 그물 만들기"를 assertIn 하였다.
이에 나도 똑같이 순서를 바꿔서 기재했다.

Django ORM 과 첫 모델

객체 관계형 맵핑 (Object-Relational Mapper, ORM)은 데이터베이스의 테이블, 레코드, 칼럼 형태로 저장돼 있는 데이터를 추상화 한 것이다.
데이터베이스는 클래스로 표현하고, 칼럼은 속성, 레코드는 각 클래스의 인스턴스로 표현한다.

lists/tests.py

from lists.models import Item

class ItemModelTest(TestCase):
    def test_saving_and_retrieving_items(self):
        first_item = Item()
        first_item.text = '첫 번째 아이템'
        first_item.save()
        second_item = Item()
        second_item.text = '두 번째 아이템'
        second_item.save()

        saved_items = Item.objects.all()
        self.assertEqual(saved_items.count(),2)

        first_saved_item = saved_items[0]
        second_saved_item = saved_items[1]
        self.assertEqual(first_saved_item.text, '첫 번째 아이템')
        self.assertEqual(second_saved_item.text,'두 번째 아이템')

코드를 이렇게 작성하고 단위 테스트를 실행해보면 아래와 같이 에러가 발생한다.

$ python3 manage.py test
System check identified no issues (0 silenced).
E
======================================================================
ERROR: lists.tests (unittest.loader._FailedTest)
----------------------------------------------------------------------
ImportError: Failed to import test module: lists.tests
Traceback (most recent call last):
  File "python3.9/unittest/loader.py", line 436, in _find_test_path
    module = self._get_module_from_name(name)
  File "python3.9/unittest/loader.py", line 377, in _get_module_from_name
    __import__(name)
  File "superlists/lists/tests.py", line 8, in <module>
    from lists.models import Item
ImportError: cannot import name 'Item' from 'lists.models' (superlists/lists/models.py)


----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (errors=1)

이렇게 오류가 나왔으면 정상적으로 진행한 것이다.
lists/models.py에 Item을 import해서 해당 문제를 개선하면 된다.

lists/models.py

from django.db import models

# Create your models here.
class Item(object):
    pass

단위 테스트 실행 시, 아래와 같이 결과가 나온다.

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...E
======================================================================
ERROR: test_saving_and_retrieving_items (lists.tests.ItemModelTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "superlists/lists/tests.py", line 56, in test_saving_and_retrieving_items
    first_item.save()
AttributeError: 'Item' object has no attribute 'save'

----------------------------------------------------------------------
Ran 4 tests in 0.006s

FAILED (errors=1)
Destroying test database for alias 'default'...

Item 클래스에 save 메소드를 부여해야 하는데, 이때 등장하는 것이 Django 모델(model) 이다. Model 클래스로부터 상속해서 사용한다.

lists/models.py

from django.db import models

# Create your models here.
class Item(models.Model):
    pass

다시 단위테스트를 진행하면 DB 에러가 발생하게 된다.

Django에선 ORM의 역할이 데이터베이스 모델을 만드는 것인데, 실제로 데이터베이스 구축을 담당하는 두 번째 시스템은 "마이그레이션(Migration)"이다.

models.py 파일에 적용된 내용을 기반으로 사용자가 테이블과 칼럼을 삭제 및 추가할 수 있도록 해준다.

데이터베이스 마이그레이션 구축하기 위해선 아래 명령어를 사용해서 구축한다.

$python3 manage.py makemigrations
Migrations for 'lists':
  lists/migrations/0001_initial.py
    - Create model Item

이후 테스트를 진행해보면 아래와 같이 노출된다.

$ python3 manage.py test lists
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...E
======================================================================
ERROR: test_saving_and_retrieving_items (lists.tests.ItemModelTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/superlists/lists/tests.py", line 66, in test_saving_and_retrieving_items
    self.assertEqual(first_saved_item.text, '첫 번째 아이템')
AttributeError: 'Item' object has no attribute 'text'

----------------------------------------------------------------------
Ran 4 tests in 0.006s

FAILED (errors=1)
Destroying test database for alias 'default'...

아이템 두 개의 저장 처리와 제대로 데이터베이스에 저장됐는지 확인하는 것은 통과되었다.
하지만 Django가 .text 속성을 기억하지 못하는 듯 하다.

models.Model로 부터 상속한 클래스는 데이터베이스의 테이블 역할을 한다.
클래스가 생성될 때 키 역할을 하는 ID 속성이 기본으로 생성된다.
다른 칼럼들은 직접 정의해야한다.

텍스트 칼럼을 정의하는 방법은 다음과 같다.

lists.models.py

from django.db import models

# Create your models here.
class Item(models.Model):
    text = models.TextField()
    

여기까지 진행하고 다시 테스트를 실행하면 다른 종류의 데이터베이스 에러가 발생한다.

django.db.utils.OperationalError: no such column: lists_item.text

데이터베이스에 새 필드를 추가했기 때문으로, 새로운 마이그레이션을 생성해야 한다.

You are trying to add a non-nullable field 'text' to item without a default; we can't do that (the database needs something to populate existing rows).
Please select a fix:
 1) Provide a one-off default now (will be set on all existing rows with a null value for this column)
 2) Quit, and let me add a default in models.py
Select an option: 2

초깃값 없이 칼럼을 추가 할 수 없다. 2번을 선택해서 models.py에 초깃값을 설정해준다.

lists.models.py

from django.db import models

# Create your models here.
class Item(models.Model):
    text = models.TextField(default='')

이후 다시 마이그레이션을 생성하고, 테스트를 하면 테스트가 통과하게 된다.

$ python3 manage.py test lists
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....
----------------------------------------------------------------------
Ran 4 tests in 0.006s

OK
Destroying test database for alias 'default'...

POST를 데이터베이스에 저장하기

POST 요청을 위한 테스트를 조정하고, 뷰가 단순히 응답을 반환하는 것이 아니라 신규 아이템을 데이터베스에 저장하도록 수정한다.

lists/tests.py

    def test_home_page_can_save_a_POST_request(self):
        request = HttpRequest()
        request.method = 'POST'
        request.POST['item_text'] = '신규 작업 아이템'

        response = home_page(request)
        self.assertEqual(Item.objects.count(),1)
        new_item = Item.objects.first()
        # expected_html = render_to_string(
        #     'home.html',
        #     {'new_item_text': '신규 작업 아이템'}
        # )
        self.assertEqual(new_item.text,'신규 작업 아이템')
        # self.assertEqualExceptCSRF(response.content.decode(),expected_html)

        self.assertIn('신규 작업 아이템', response.content.decode())
        expected_html = render_to_string(
            'home.html',
            {'new_item_text': '신규 작업 아이템'}
        )
        self.assertEqualExceptCSRF(response.content.decode(), expected_html)

위와 같이 작성하고 테스트 실행 시 오류가 발생하는 것을 확인할 수 있다.

F...
======================================================================
FAIL: test_home_page_can_save_a_POST_request (lists.tests.HomePageTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "superlists/lists/tests.py", line 45, in test_home_page_can_save_a_POST_request
    self.assertEqual(Item.objects.count(),1)
AssertionError: 0 != 1

----------------------------------------------------------------------
Ran 4 tests in 0.006s

FAILED (failures=1)
Destroying test database for alias 'default'...

views.py를 수정한다.
lists/views.py

from django.shortcuts import render
from django.http import HttpResponse
from lists.models import Item

# Create your views here.
# home_page = None


def home_page(request):
    item = Item()
    item.text = request.POST.get('item_text','')
    item.save()
    # if request.method == 'POST':
    #     return HttpResponse(request.POST['item_text'])
    # return render(request, 'home.html',{
    #     'new_item_text' : request.POST['item_text'],
    # })
    return render(request, 'home.html', {
        'new_item_text': request.POST.get('item_text', ''),
    })
    # return render(request,'home.html')

이렇게 수정하고 단위 테스트를 진행하면 테스트가 성공하게 된다!

작업 메모장에 내용을 작성하고 해당 작업 몇 가지를 진행해본다.

1) 모든 요청에 대한 비어 있는 요청은 저장하지 않는다.
2) 코드 냄새 : POST 테스트가 너무 긴가?
3) 테이블에 아이템 여러 개 표시하기
4) 하나 이상의 목록 지원하기

첫 번째 작업부터 진행해보면 아래와 같이 진행해보면된다.
lists/tests.py

    def test_home_page_only_saves_item_when_necessary(self):
        request = HttpRequest()
        home_page(request)
        self.assertEqual(Item.objects.count(),0)

lists/views.py

def home_page(request):
    if request.method == 'POST':
        new_item_text = request.POST['item_text']
        Item.objects.create(text=new_item_text)
    else:
        new_item_text = ''
        
    return render(request, 'home.html', {
        'new_item_text': new_item_text,
    })

이렇게 수정을 할 경우, 테스트에 통과하게 된다.

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.....
----------------------------------------------------------------------
Ran 5 tests in 0.007s

OK

POST 후에 리디렉션

POST를 한 후에는 리디렉션을 해주는 것이 좋다.

lists/tests.py

def test_home_page_can_save_a_POST_request(self):
        request = HttpRequest()
        request.method = 'POST'
        request.POST['item_text'] = '신규 작업 아이템'

        response = home_page(request)
        self.assertEqual(Item.objects.count(),1)
        new_item = Item.objects.first()

        self.assertEqual(new_item.text,'신규 작업 아이템')

        self.assertEqual(response.status_code, 302)
        self.assertEqual(response['location'],'/')

더 이상 응답 .content가 템플릿에 의해 렌더링 되지 안힉 때문에 이것을 확인하는 어셜션을 제거했고, 대신 HTTP 리디렉션을 하기 때문에 상태 코드가 302가 되며, 브라우저는 새로운 위치를 가르킨다.

이후 뷰를 수정하면된다.

수정하고 난 후, 테스트 진행하게되면 정상적으로 테스트 결과가 성공으로 나오게 된다.

lists/views.py

from django.shortcuts import redirect,render
from django.http import HttpResponse
from lists.models import Item

def home_page(request):
    if request.method == 'POST':
        Item.objects.create(text=request.POST['item_text'])
        return redirect('/')

    return render(request,'home.html')
Found 5 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.....
----------------------------------------------------------------------
Ran 5 tests in 0.007s

OK
Destroying test database for alias 'default'...

여기까지 진행을 했지만, 아직 더 테스트를 개선할 여지가 있다.
좋은 단위 테스트는 각 테스트가 한 가지만 테스트하는 것을 의미한다.
이를 통해 버그 추적이 더 용이해지기 때문이다.

아래 코드를 수정하고 한번더 진행해 본다.
lists/tests.py

def test_home_page_can_save_a_POST_request(self):
        request = HttpRequest()
        request.method = 'POST'
        request.POST['item_text'] = '신규 작업 아이템'

        response = home_page(request)

        self.assertEqual(Item.objects.count(),1)
        new_item = Item.objects.first()
        self.assertEqual(new_item.text,'신규 작업 아이템')

    def test_home_page_redirects_after_POST(self):
        request = HttpRequest()
        request.method = 'POST'
        request.POST['item_text'] = '신규 작업 아이템'

        response = home_page(request)

        self.assertEqual(response.status_code, 302)
        self.assertEqual(response['location'],'/')

이렇게 변경하면 여섯 개 테스트가 성공하는 것을 볼 수 있다.

템플릿에 있는 아이템 렌더링

현재 작업 목록을 보게되면, 1~2는 완료하였고 남은 사항은 아래 두개이다.

3) 테이블에 아이템 여러 개 표시하기
4) 하나 이상의 목록 지원하기

세 번째 작업인 여러 아이템을 출력할 수 있는지 확인하느 단위 테스트를 추가해본다.

lists/test.py

  • 단위테스트를 실행했을때, 결과값에 token값이 나오고있어서, 이전에 AssertEqual 사용한 것처럼 이것도 똑같이 함수를 만들어주고 사용했다.
    def test_home_page_displays_all_list_times(self):
        Item.objects.create(text='itemey 1')
        Item.objects.create(text='itemey 2')
        request = HttpRequest()
        response = home_page(request)

        self.assertInExceptCSRF('itemey 1', response.content.decode())
        self.assertInExceptCSRF('itemey 2', response.content.decode())

템플릿 구문은 리스트 반복 처리를 위한 태그를 제공하기에, 코드를 수정한다.

lists/templates/home.html

    <table id="id_list_table">
        {% for item in items %}
        <tr>
            <td>1: {{item.text}}</td>
        </tr>
        {% endfor %}
    </table>

이후 views.py 코드도 같이 수정해준다.
lists/views.py

def home_page(request):
    if request.method == 'POST':
        Item.objects.create(text=request.POST['item_text'])
        return redirect('/')

    items = Item.objects.all()
    return render(request,'home.html',{'items' : items})

이렇게 코드를 수정하고 단위 테스트를 진행해보면 성공하게 된다.
기능테스트도 같이 진행해보면 결과는 다음과 같이 나온다.

F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/superlists/functional_test.py", line 30, in test_can_start_a_list_and_retrieve_it_later
    self.assertIn('To-Do', self.browser.title)
AssertionError: 'To-Do' not found in 'OperationalError at /'

----------------------------------------------------------------------
Ran 1 test in 3.606s

다른 기능 테스트 디버깅 기술을 이용해보면, 에러를 표시하고 있는 것을 확인할 수 있다.

마이그레이션을 이용한 운영 데이터베이스 생성하기

단위테스트는 통과했는데, 왜 문제가 발생하는 것일까? 라는 의문의 답은 Django가 단위 테스트를 위해 특수한 데이터베이스를 생성하기 때문이다.

이에 진짜 데이터베이스를 구축할 필요가 있다.
SQLite 데이터베이스는 디스크상에 있는 파일 형태의 데이터베이스로, 기본 프로젝트 디렉터리에 db.sqlite3라는 파일로 저장하도록 settings.py 에 설정되어있다.

마이그레이션을 진행하고 아까 들어간 페이지를 새로고침 하게되면 에러 메시지가 사라진 것을 확인할 수 있다.

기능 테스트를 실행해보면 어떻게 될까?

아까와는 다른 에러메시지를 출력해주고 있다.

F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/superlists/functional_test.py", line 74, in test_can_start_a_list_and_retrieve_it_later
    self.check_for_row_in_list_table('2: 공작깃털을 이용해서 그물 만들기')
  File "/superlists/functional_test.py", line 22, in check_for_row_in_list_table
    self.assertIn(row_text,[row.text for row in rows])
AssertionError: '2: 공작깃털을 이용해서 그물 만들기' not found in ['1: 공작깃털 사기', '1: 공작깃털을 이용해서 그물 만들기']

----------------------------------------------------------------------
Ran 1 test in 5.719s

FAILED (failures=1)

이제 아이템 번호만 바로 잡으면 된다.

    <table id="id_list_table">
        {% for item in items %}
        <tr>
            <td>{{ forloop.counter }} : {{item.text}}</td>
        </tr>
        {% endfor %}
    </table>

다시 기능 테스트를 실행하게 되면 성공하는 것을 확인할 수 있다.

기능 테스트를 돌리다 보면 이상한 점을 확인할 수 있다.
이전에 테스트에서 사용된 작업 아이템에 데이터베이스가 남아있어, 계속 쌓이는 것을 발견할 수 있다.

해당 방법을 해결하는 것은 다음장에서 진행해도록 한다.
일단 migate 명령어를 통해서 수작업으로 데이터베이스를 지운 후 재생성하도록 한다.

$ rm db.sqlite3
$ python3 manage.py migrate --nopoint

마무리

진행 중 특이 사항에 대해 작성하였다.

1) AssertionError: 'To-Do' not found in 'MultiValueDictKeyError at /' Error

기능 테스트 중, 계속 아래의 오류가 발생하는 것을 확인했다.
책에 나오는 오류가 아닌 해당 오류가 계속 발생하여 관련하여 원인을 좀 찾아봤는데 이는 request.POST 형식으로 받아서 발생하는 오류였다.
이를 해소하려면 request.POST.get 으로 변경해서 사용해야한다.
이는 책에서도 기술되어있는 부분이다. 오류 메시지가 약간 다를뿐 같은 맥락의 오류라고 생각된다.

2) AssertionError: '<htm[201 chars] <input type="hidden" name="csrfmiddleware[216 chars]tml>' != '<htm[201 chars] \n \n <table id="id_list_tab[95 chars]tml>'

진행을 하다보니, 계속 이런 오류가 발생하며 단위테스트가 실패하여 원인을 확인해보니, 전달해오는 값에 token값이 붙어서 일치하지 않는다는 결과가 나오고 있는 것을 확인했다.
여러가지 구글링을 통해 얻은 결과 해당 csrf token값을 제거해주는 수 밖에 없다.

아래 사이트를 참고하여 tests.py 내에 해당 코드를 추가해주고 변경해주면 단위 테스트 통과!

https://stackoverflow.com/questions/34629261/django-render-to-string-ignores-csrf-token/39859042#39859042

from urllib import response
from django.urls import resolve
from django.test import TestCase
from lists.views import home_page
from django.http import HttpRequest
from django.template.loader import render_to_string
import re

class HomePageTest(TestCase):

    @staticmethod
    def remove_csrf(html_code):
        csrf_regex = r'<input[^>]+csrfmiddlewaretoken[^>]+>'
        return re.sub(csrf_regex, '', html_code)

    def assertEqualExceptCSRF(self, html_code1, html_code2):
        return self.assertEqual(
            self.remove_csrf(html_code1),
            self.remove_csrf(html_code2)
        )

    def test_root_url_resolves_to_home_page_view(self):
        found = resolve('/')
        self.assertEqual(found.func, home_page)

    def test_home_page_returns_correct_html(self):
        request = HttpRequest()
        response = home_page(request)
        expected_html = render_to_string('home.html')
        self.assertTrue(response.content.decode(), expected_html)

    def test_home_page_can_save_a_POST_request(self):
        request = HttpRequest()
        request.method = 'POST'
        request.POST['item_text'] = '신규 작업 아이템'

        response = home_page(request)
        self.assertIn('신규 작업 아이템', response.content.decode())
        expected_html = render_to_string(
            'home.html',
            {'new_item_text': '신규 작업 아이템'}
        )
        self.assertEqualExceptCSRF(response.content.decode(),expected_html)

3) The element reference of <tr> is stale; either the element is no longer attached to the DOM, it is not in the current frame context, or the document has been refreshed

공작 깃털 사기를 정상적으로 출력하고 단위테스트를 통과했는데 기능테스트 진행 시 계속 아래와 같은 오류가 발생하였다.
분명 신규 작업 아이템 뭐시기 하면서 AssertionError가 노출되어야 하는데.. 갑자기 왜이러는지..

이는 어떻게 해결하면 좋을까?

일단 해당 오류의 뜻을 살펴보면 아래와 같다.

<tr>의 요소 참조가 오래되었습니다. 요소가 더 이상 DOM에 연결되지 않았거나 현재 프레임 컨텍스트에 있지 않거나 문서가 새로 고쳐졌습니다.

구글링을 조금 해봤는데, 해결 방법은 아래와 같다고 한다.

https://stackoverflow.com/questions/45178817/selenium-with-python-stale-element-reference-exception

소스코드를 아래와 같이 수정하면 테스트는 통과된다.

        # 엔터키를 치면 페이지가 갱신되고 작업 목록에
        # "1: 공작깃털 사기" 아이템이 추가된다
        inputbox.send_keys(Keys.ENTER)
        WebDriverWait(self.browser, 10).until(
            expected_conditions.text_to_be_present_in_element(
                (By.ID, 'id_list_table'), '공작깃털 사기'))

그런데 이렇게 변경할 경우에 다음 table을 어떻게 받아야할지 잘 모르겠어서 고민을 좀 해본 결과, 중간 중간에 time.sleep(1)를 넣어주었는데, 이후 정상적으로 테스트 결과가 노출되는 것을 확인했다.

4) TypeError: context must be a dict rather than set.

똑같이 진행했는데 해당 오류가 발생하길래 검색해봤는데 코드를 잘못 작성해서였다.

return render(request,'home.html',{'items : items'})

이렇게 작성을 해서 발생한 문제였고, 아래와 같이 수정하면 발생하지 않는다.

return render(request,'home.html',{'items' : items})

참고 도서 : 클린 코드를 위한 테스트 주도 개발 (비제이퍼블릭,2015)

profile
#QA #woonmong

0개의 댓글