[TDD] Test-Driven Development with Python 4장

SUNGJIN KIM·2022년 7월 2일
0

tdd-with-python

목록 보기
4/7
post-thumbnail

˗ˋˏ♡ˎˊ˗ 왜 테스트를 하는 것인가?

  • selenium을 이용한 사용자 반응 테스트
  • 코드 리팩토링
  • 메인 페이지 추가 수정

selenium을 이용한 사용자 반응 테스트

코드를 아래와 같이 수정한다.

  • find_element_by ... : 하나의 요소만 반환하며 요소가 없는 경우 예외를 발생 시킴
  • find_elements_by ... : 리스트를 반환하며 리스트가 비어도 무관

functional_test.py

from selenium import webdriver
from selenium.webdriver.common.keys import Keys
import unittest


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_element_by_tag_name('h1').text
        self.assertIn('To-Do', header_text)

        # 그녀는 바로 작업을 추가하기로 한다
        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)
    
        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),
        )

        # 추가 아이템을 입력할 수 있는 여분의 텍스트 상자가 존재한다.
        # 다시 "공작깃털을 이용해서 그물 만들기" 라고 입력한다 (에디스는 매우 체계적인 사람이다)
        self.fail('Finish the test!')

        # 페이지는 다시 갱신되고, 두 개 아이템이 목록에 보인다
        # 에디스는 사이트가 입력한 목록을 저장하고 있는지 구감하다
        # 사이트는 그녀를 위한 특정 URL을 생성해준다
        # 이때 URL에 대한 설명도 함께 제공된다

        # 해당 URL에 접속하면 그녀가 만든 작업 목록이 그대로 있는 것을 확인할 수 있다

        # 만족하고 잠자리에 든다.

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

위 코드를 실행하면 결과는 실패가 된다.
현재 h1 요소가 없기때문에 발생하는 오류이며, 이를 수정할 방법을 찾아본다.

단위 테스트 시의 일반적인 규칙 중 하나는
"상수는 테스트하지 마라" 이다.
HTML을 문자열로 테스트 하는 것 = 상수 테스트 하는 것

템플릿을 사용하기 위한 리팩토링

  • 리팩토링 (Refactoring) : 기능(결과물)은 바꾸지 않고, 코드 자체를 개선하는 작업

첫 번째 규칙은 테스트 없이 리팩토링 할 수 없다는 것이다.
일단 테스트를 진행해보고 리팩토링 여부를 확인 해 본다.

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

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

테스트를 통과했으면, 이후 lists/templates라는 폴더를 만들고, 템플릿을 저장하도록 한다.

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

이후 뷰 함수를 수정한다.

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

# Create your views here.
# home_page = None

def home_page(request):
    return render(request, 'home.html')

HttpResponse 를 만드는 대신, render 함수를 사용하고 있다.
Django는 앱 폴더 내에 있는 templates이라는 폴더를 자동으로 검색하고, 템플릿 콘텐츠를 기반으로 HttpResponse를 만들어 준다.

동작하는지 확인해보면 다음과 같다.
이대로 코드를 실행해보면 다음과 같이 에러가 발생한다.

Found 2 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
E.
======================================================================
ERROR: test_home_page_returns_correct_html (lists.tests.HomePageTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "lists/tests.py", line 20, in test_home_page_returns_correct_html
    response = home_page(request)
  File "/views.py", line 8, in home_page
    return render(request, 'home.html')
  File "tddenv/lib/python3.9/site-packages/django/shortcuts.py", line 24, in render
    content = loader.render_to_string(template_name, context, request, using=using)
  File "template/loader.py", line 61, in render_to_string
    template = get_template(template_name, using=using)
  File "template/loader.py", line 19, in get_template
    raise TemplateDoesNotExist(template_name, chain=chain)
django.template.exceptions.TemplateDoesNotExist: home.html

----------------------------------------------------------------------
Ran 2 tests in 0.002s

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

해당 에러를 살펴보면, 템플릿을 발견할 수 없어서 에러가 발생하고 있다.

File "lists/tests.py", line 20, in test_home_page_returns_correct_html
    response = home_page(request)

템플릿이 있는데도, 못찾는 이유는 현재 아직 해당 앱을 Django에 등록하지 않아서이다.
프로젝트 폴더에 있는 settings.py 내 앱에 추가해야한다.
INSTALLED_APPS 항목에 찾아 추가한다.

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'lists',
]

추가 후 테스트 실행 시, 정상적으로 진행되는 것을 확인할 수 있다...?
책에서는 해당 스텝까지 진행했을때 마지막 라인의 \n으로 인해 오류가 발생해야하는데, 현재 결과는 오류가 아닌 테스트 성공으로 노출되고 있었다.

이는 근데 크게 상관할 부분은 아니라고 생각하는게 문자열 라인 추가로 발생하는 것이지, 테스트 코드 자체가 문제가 있거나 빠져서 성공하는 것은 아닌 것으로 판단된다.

대신 HTML 파일 마지막 부분에 빈 공간으로 인해 문제가 발생할 수 있으니 해당 코드를 추가하여 테스트를 다시 실행해본다.

lists/tests.py

    def test_home_page_returns_correct_html(self):
        request = HttpRequest()
        response = home_page(request)
        self.assertTrue(response.content.startswith(b'<html>'))
        self.assertIn(b'<title>To-Do lists</title>', response.content)
        self.assertTrue(response.content.strip().endswith(b'</html>'))

마지막으로는 상수를 테스트 하지 않고 템플릿을 이용해서 렌더링 하는 것을 테스트 하도록 수정해준다.
이는 Django 의 render_to_string 함수를 이용하면 간단히 구현할 수 있다.

lists/tests.py

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


# Create your tests here.
# class SmokeTest(TestCase):
#     def test_bad_maths(self):
#         self.assertEqual(1+1,3)

class HomePageTest(TestCase):
    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)

.decode() 를 이용해서 response.content 바이트 데이터를 파이썬 유니코드 문자열로 변환한 다음, 문자열과 문자열을 서로 비교할 수 있도록 한다.
(구현 결과물을 비교한다.)

메인 페이지 추가 수정

템플릿 형태이기 때문에 이제는 html만 수정하면 된다.
html에 내용을 추가하면서 테스트를 진행해본다.

1) h1 확인

<html>
    <head>
        <title>To-Do lists</title>
    </head>
    <body>
        <h1>Your To-Do list</h1>
    </body>
</html>
E
======================================================================
ERROR: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/functional_test.py", line 29, in test_can_start_a_list_and_retrieve_it_later
    inputbox = self.browser.find_element(By.ID,'id_new_item')
  File "/lib/python3.9/site-packages/selenium/webdriver/remote/webdriver.py", line 857, in find_element
    return self.execute(Command.FIND_ELEMENT, {
  File "lib/python3.9/site-packages/selenium/webdriver/remote/webdriver.py", line 435, in execute
    self.error_handler.check_response(response)
  File "/lib/python3.9/site-packages/selenium/webdriver/remote/errorhandler.py", line 247, in check_response
    raise exception_class(message, screen, stacktrace)
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate element: [id="id_new_item"]
Stacktrace:
WebDriverError@chrome://remote/content/shared/webdriver/Errors.jsm:183:5
NoSuchElementError@chrome://remote/content/shared/webdriver/Errors.jsm:395:5
element.find/</<@chrome://remote/content/marionette/element.js:300:16


----------------------------------------------------------------------
Ran 1 test in 6.062s

FAILED (errors=1)

2) id_new_item 추가

<html>
    <head>
        <title>To-Do lists</title>
    </head>
    <body>
        <h1>Your To-Do list</h1>
        <input id="id_new_item" placeholder ="작업 아이템 입력" />
    </body>
</html>
E
======================================================================
ERROR: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/functional_test.py", line 43, in test_can_start_a_list_and_retrieve_it_later
    table = self.browser.find_element(By.ID,'id_list_table')
  File "/lib/python3.9/site-packages/selenium/webdriver/remote/webdriver.py", line 857, in find_element
    return self.execute(Command.FIND_ELEMENT, {
  File "/lib/python3.9/site-packages/selenium/webdriver/remote/webdriver.py", line 435, in execute
    self.error_handler.check_response(response)
  File "/lib/python3.9/site-packages/selenium/webdriver/remote/errorhandler.py", line 247, in check_response
    raise exception_class(message, screen, stacktrace)
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate element: [id="id_list_table"]
Stacktrace:
WebDriverError@chrome://remote/content/shared/webdriver/Errors.jsm:183:5
NoSuchElementError@chrome://remote/content/shared/webdriver/Errors.jsm:395:5
element.find/</<@chrome://remote/content/marionette/element.js:300:16


----------------------------------------------------------------------
Ran 1 test in 6.133s

FAILED (errors=1)

3) table 추가

  • 오류 코드는 False is not rue 로 기능 테스트에 사용한 함수에 문제가 있는 것을 알 수 있다.
<html>
    <head>
        <title>To-Do lists</title>
    </head>
    <body>
        <h1>Your To-Do list</h1>
        <input id="id_new_item" placeholder ="작업 아이템 입력" />
        <table id = "id_list_table">
        </table>
    </body>
</html>
F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/functional_test.py", line 45, in test_can_start_a_list_and_retrieve_it_later
    self.assertTrue(
AssertionError: False is not true

----------------------------------------------------------------------
Ran 1 test in 5.861s

FAILED (failures=1)

4) functional_test.py 텍스트 추가

lists/functional_test.py

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

----------------------------------------------------------------------
Ran 1 test in 6.119s

FAILED (failures=1)

해당 문제를 해결하려면 사용자 폼(form) 제출 처리를 구현해야 하는데 이는 다음 장에서 진행할 예정이다.

정리 : TDD 프로세스

  • 기능 테스트 (Functional tests)
  • 단위 테스트 (Unit tests)
  • 단위 테스트-코드 주기 (Unit test-code cycle)
  • 리팩터링 (Refactoring)

마무리

4장을 진행하며 발생한 특이사항에 대한 공유는 아래와 같다.

1) AttributeError: 'WebDriver' object has no attribute 'find_element_by_tag_name

$ python3 funtional_test.py 
E
======================================================================
ERROR: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "functional_test.py", line 22, in test_can_start_a_list_and_retrieve_it_later
    header_text = self.browser.find_element_by_tag_name('h1').text
AttributeError: 'WebDriver' object has no attribute 'find_element_by_tag_name'

----------------------------------------------------------------------
Ran 1 test in 3.249s

FAILED (errors=1)

이것 저것 찾아봤을때, 사용되는 문법이 조금 달라진 것으로 보인다.
https://stackoverflow.com/questions/69875125/find-element-by-commands-are-deprecated-in-selenium

작성한 코드는 아래와 같은데 이를 이렇게 바꿔줘야 한다.

#header_text = self.browser.find_element_by_tag_name('h1').text
header_text = self.browser.find_element(By.TAG_NAME,'h1')

이렇게 변경하고 코드를 실행했을때, 또 다른 오류가 발생했다.

E
======================================================================
ERROR: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "functional_test.py", line 26, in test_can_start_a_list_and_retrieve_it_later
    self.assertIn('To-Do', header_text)
  File "/python3.9/unittest/case.py", line 1101, in assertIn
    if member not in container:
TypeError: argument of type 'WebElement' is not iterable

----------------------------------------------------------------------
Ran 1 test in 3.427s

FAILED (errors=1)
TypeError: argument of type 'WebElement' is not iterable

해당 오류가 왜 발생하는지 하나씩 짚어보고 있는데, 일단 해당 오류에 대해서 확인을 해보자면 WebElement는 반복될 수 없다고 하는 것이 가장 수상하다.

일단 코드의 경우에는 "assertIn" 구문만 제외하면 책에서 나오는 것과 같이 동일하게 결과값이 노출되고 있다.

assertIn()

assertIn(찾으려는 문장, 전체 문장)

전체 문장에서 내가 찾으려는 문장이 들어 포함되었는지 확인합니다.

전체 문장에서 내가 찾으려는 문장이 있다면 Pass 입니다.

책에서는 해당 코드를 그대로 사용해도 크게 무방한데, 왜 그대로 되지 않을까?
일단 하나씩 시도해보기로 했다.

01. element => elements로 변경해서 사용해보기

self.browser.find_element(By.TAG_NAME,'h1') 로 사용 시, 오류가 반복될 수 없다는 말은 결국 반환되는 값이 여러개일 확률이 높다고 생각했다. 그래서 일단 list형태로 받는 elements로 사용을 했다.
이렇게 바꿔서 결과값을 확인해보니 이전과 조금 다르게 노출되는 것을 확인했다.

AssertionError: 'To-Do' not found in [<selenium.webdriver.remote.webelement.WebElement (session="93d2d496-d59c-4c18-a366-a3ac974b62d2", element="6dbe02ef-42ad-4322-8106-b24af8d351fe")>]

현재 header_text는 2개의 값을 반환하는 것을 확인할 수 있다.
원래 의도라면 <h1> Your To-Do list </h1> 이기때문에, "Your To-Do list" 가 반환이 되어야 할텐데, 해당 값이 오는게 아니라고 생각이 들었다.
이에 이를 for 문을 사용하여 text로 받아주니 정상적으로 테스트 통과하였다.

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

코드를 이와 같이 수정하고 결과를 확인해보니, 드디어 원하는 기대결과값을 얻을 수 있었다. (해당 테스트 통과!)

  File "/webdriver/remote/errorhandler.py", line 247, in check_response
    raise exception_class(message, screen, stacktrace)
selenium.common.exceptions.NoSuchElementException: Message: Unable to locate element: [id="id_new_item"]
Stacktrace:
WebDriverError@chrome://remote/content/shared/webdriver/Errors.jsm:183:5
NoSuchElementError@chrome://remote/content/shared/webdriver/Errors.jsm:395:5
element.find/</<@chrome://remote/content/marionette/element.js:300:16

과장 좀 보태서 2일을 고민한 것 같다.
다시 1일 1장 할 수 있도록 열심히 해야지

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

profile
#QA #woonmong

0개의 댓글