본문 바로가기
Development/Python

04. 테스트와 리팩토링

by 신군. 2019. 7. 28.
반응형

TDD에 대한 의문점

코드를 구현할수록 테스트 또한 점점 복잡해진다. 그리고 반복적인 노력이 아깝게 생각이 들다보니 아래와 같은 의문이 든다.

  • 테스트가 지나치게 많은 것이 아닌가.
  • 테스트가 일부 중복되는 것 아닌가. 단위 테스트와 기능 테스트 사이에 분명히 중복된 부분이 있다.
  • django.core.urlresolvers 불러오는 건 왜 테스트하는가. 이건 Django 프레임워크 테스트, 서드파티 코드 테스트 아닌가.
  • 지금까지 선언 한 줄 테스트, 상수값 반환 검사 같은 단위 테스트는 너무나 자명한(당연한) 것들 아닌가.
  • home_page = None 같은 코드는 단위 테스트/코딩 반복 과정에서 좀 건너 뛰어도 되는 것 아닌가.
  • 실무적으로 정말 이렇게까지 코딩해야 하는가.

TDD 과정에서 이러한 질문을 하는 것은 충분히 가치가 있는 일이다.

자명한 함수의 자명한 테스트를 하는 이유

  1. 진짜 자명한 함수의 자명한 테스트를 작성하는 것이라면 어차피 입력하는데 얼마 시간 안 걸리니 그냥 입력하자.
  2. 구현을 위해 어떤 생각의 흐름, 사고의 과정을 거쳤는지 흔적을 남기려는 이유이다.

사용자 입력 테스트

다시 한 번 기능 테스트를 실시하면 self.fail('테스트 종료') 코드로 무조건 실패하도록 했기 때문에 아래와 같이 오류를 확인할 수 있다. 테스트 실패보다도 self.fail('테스트 종료') 코드 윗 부분까지는 테스트를 통과시키는데 그 의의가 있다.

python function_tests.py F ====================================================================== FAIL: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest) ---------------------------------------------------------------------- Traceback (most recent call last): File "function_tests.py", line 19, in test_can_start_a_list_and_retrieve_it_later self.fail('테스트 종료') AssertionError: Finish the test! ---------------------------------------------------------------------- Ran 1 test in 3.539s FAILED (failures=1)

이제 다시 기능 테스트 function_tests.py 파일을 다음과 같이 수정한다.

function_tests.py 파일

import time import unittest from selenium import webdriver from selenium.webdriver.common.keys import Keys class NewVisitorTest(unittest.TestCase): def setUp(self): self.browser = webdriver.Firefox() def tearDown(self): self.browser.quit() def test_can_start_a_list_and_retrieve_it_later(self): # 영애씨는 온라인 일정관리 앱을 알게 되어 홈페이지에 방문한다. self.browser.get('http://localhost:8000') # 홈페이지에 방문해 보니 제목이 "일정관리"인 것을 보고 홈페이지에 올바르게 방문한 것을 확인한다. # assert '일정관리' in self.browser.title, "Browser title was " + self.browser.title self.assertIn('일정관리', self.browser.title) header_text = self.browser.find_element_by_tag_name('h1').text self.assertIn('일정목록', 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) 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)) # 영애씨는 추가로 할일 텍스트박스에 입력할 수 있고 # "미역을 물에 불리기"라고 입력한다. self.fail('테스트 종료') # 다시 페이지를 새로고침해서 입력한 일정 두 가지 모두 목록에 표시한다. # 영애씨는 일정 목록이 사이트에 올바로 저장되었는지 궁금해서 # 고유 URL 생성을 확인한다. # 영애씨는 URL을 방문하고 일정 목록이 올바르게 있음을 확인한다. # 영애씨는 이제 만족하고 잠을 자러간다. if __name__ == '__main__': unittest.main(warnings='ignore')

기능 테스트를 실시했을 때 아래와 같이 h1 헤더 태그를 못 찾는 오류가 발생하는지 확인한다.

$ python functional_tests.py [... 생략 ...] selenium.common.exceptions.NoSuchElementException: Message: Unable to locate element: h1

이렇게 아직 오류가 있어 테스트는 실패하지만 git 로컬 저장소에 커밋한다.

커밋하기 전에 파일 변경 사항을 확인한다.

$ git status $ git diff $ git commit -am "Functional test now checks we can input a to-do item"

템플릿을 사용해서 상수값은 더 이상 테스트하지 않기

wibble = 3

위와 같이 상수 선언을 하고 아래와 같이 테스트하는 것은 불필요한 일이다.

from myprogram import wibble assert wibble == 3

템플릿 HTML 처리

리팩토링이란 기능상의 변경 없이 코드를 개선하는 작업이다.

리팩토링 이전에 먼저 단위 테스트가 올바로 동작하는지 확인한다.

$ python manage.py test Creating test database for alias 'default'... System check identified no issues (0 silenced). .. ---------------------------------------------------------------------- Ran 2 tests in 0.002s OK Destroying test database for alias 'default'...

lists/templates/home.html 파일 추가

<html> <title>일정관리</title> </html>

lists/views.py 파일 수정

from django.shortcuts import render def home_page(request): return render(request, 'home.html')

이제 단위 테스트를 해보면 분명히 home.html 파일을 만들었는데도 없다고 에러가 발생한다.

$ python manage.py test 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 "/home/mango/Projects/tdd/superlists/lists/tests.py", line 15, in test_home_page_returns_correct_html response = home_page(request) File "/home/mango/Projects/tdd/superlists/lists/views.py", line 5, in home_page return render(request, 'home.html') File "/home/mango/Projects/tdd/venv/lib/python3.5/site-packages/django/shortcuts.py", line 30, in render content = loader.render_to_string(template_name, context, request, using=using) File "/home/mango/Projects/tdd/venv/lib/python3.5/site-packages/django/template/loader.py", line 67, in render_to_string template = get_template(template_name, using=using) File "/home/mango/Projects/tdd/venv/lib/python3.5/site-packages/django/template/loader.py", line 25, in get_template raise TemplateDoesNotExist(template_name, chain=chain) django.template.exceptions.TemplateDoesNotExist: home.html ---------------------------------------------------------------------- Ran 2 tests in 0.007s FAILED (errors=1) Destroying test database for alias 'default'...

Django에 lists 앱을 등록해야 한다.

superlists/settings.py 파일 수정

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

다시 단위 테스트를 실행해보면 문서가 </html> 태그로 끝나지 않는다고 오류가 발생한다.

$ python manage.py test Creating test database for alias 'default'... System check identified no issues (0 silenced). F. ====================================================================== FAIL: test_home_page_returns_correct_html (lists.tests.HomePageTest) ---------------------------------------------------------------------- Traceback (most recent call last): File "/home/mango/Projects/tdd/superlists/lists/tests.py", line 19, in test_home_page_returns_correct_html self.assertTrue(html.endswith('</html>')) AssertionError: False is not true ---------------------------------------------------------------------- Ran 2 tests in 0.006s FAILED (failures=1) Destroying test database for alias 'default'...

이것은 HTML 문서 뒤에 개행 문자 등이 있기 때문이므로 이를 무시하도록 lists/tests.py 파일의 test_home_page_returns_correct_html()테스트 메소드를 수정한다.

... 생략 ... self.assertTrue(html.strip().endswith('</html>'))

다시 단위 테스트를 실시하면 이제 성공적으로 통과한다.

$ python manage.py test Creating test database for alias 'default'... System check identified no issues (0 silenced). .. ---------------------------------------------------------------------- Ran 2 tests in 0.003s OK Destroying test database for alias 'default'...

render_to_string 사용해보기

단순히 HTML 문서 내용의 비교라면 render_to_string 함수를 이용할 수도 있다.

from django.http import HttpRequest from django.template.loader import render_to_string from django.test import TestCase from django.urls import resolve from .views import home_page 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) html = response.content.decode('utf8') expected_html = render_to_string('home.html') self.assertEqual(html, expected_html)

Django Test Client

위와 같이 테스트를 위해서 decode(), strip() 같은 함수를 이용한다는 건 뭔가 거추장스럽다.

그래서 Django에서는 Django Test Client를 제공한다.

from django.test import TestCase from django.urls import resolve from .views import home_page 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) response = self.client.get('/') html = response.content.decode('utf8') self.assertTrue(html.startswith('<html>')) self.assertIn('<title>일정관리</title>', html) self.assertTrue(html.strip().endswith('</html>')) self.assertTemplateUsed(response, 'home.html')

Django Test Client를 사용하는 코드로 수정했다. 생각한대로 동작하는지 확인하기 위해 다시 예전 코드를 입력했다.

assertTemplateUsed() 메소드는 파이썬 표준 모듈 unittest에서 제공하는 게 아니라 Django에서 제공하는 것이다. 응답을 렌더링하는데 쓰인 템플릿을 확인해준다. 그런데 Django Test Client로 불러온 응답일 경우에만 동작하는 것에 주의한다.

테스트를 실시하면 다음과 같이 아무런 문제 없이 통과한다.

$ python manage.py test Creating test database for alias 'default'... System check identified no issues (0 silenced). .. ---------------------------------------------------------------------- Ran 2 tests in 0.009s OK Destroying test database for alias 'default'... self.assertTemplateUsed(response, 'wrong.html')

위와 같이 일부러 잘못된 HTML 문서를 비교하는 코드로 바꿔서 테스트를 실시해보면 아래와 같이 오류를 확인할 수 있다.

python manage.py test Creating test database for alias 'default'... System check identified no issues (0 silenced). F. ====================================================================== FAIL: test_home_page_returns_correct_html (lists.tests.HomePageTest) ---------------------------------------------------------------------- Traceback (most recent call last): File "/home/mango/Projects/tdd/superlists/lists/tests.py", line 23, in test_home_page_returns_correct_html self.assertTemplateUsed(response, 'wrong.html') File "/home/mango/Projects/tdd/venv/lib/python3.5/site-packages/django/test/testcases.py", line 583, in assertTemplateUsed % (template_name, ', '.join(template_names)) AssertionError: False is not true : Template 'wrong.html' was not a template used to render the response. Actual template(s) used: home.html ---------------------------------------------------------------------- Ran 2 tests in 0.010s FAILED (failures=1) Destroying test database for alias 'default'...

이제 코드를 아래와 같이 간단히 정리할 수 있고 테스트도 올바르게 통과한다.

from django.test import TestCase from django.urls import resolve from .views import home_page 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): response = self.client.get('/') self.assertTemplateUsed(response, 'home.html')

작업 내역 커밋

현재까지 작업 내역 변경 사항을 git 로컬 저장소에 커밋한다.

$ git status # see tests.py, views.py, settings.py, + new templates folder $ git add . # will also add the untracked templates folder $ git diff --staged # review the changes we're about to commit $ git commit -m "Refactor home page view to use a template

리팩토링

리팩토링(refactoring)은 '결과의 변경 없이 코드의 구조를 재조정함'을 뜻한다. 주로 가독성을 높이고 유지보수를 편하게 한다. 버그를 없애거나 새로운 기능을 추가하는 행위는 아니다. 사용자가 보는 외부 화면은 그대로 두면서 내부 논리나 구조를 바꾸고 개선하는 유지보수 행위이다.

시작 페이지 좀 더 개선하기

이제 반복 작업으로 테스트하고 실패하면 해당 오류를 하나씩 해결하는 과정을 살펴본다.

오류

selenium.common.exceptions.NoSuchElementException: Message: Unable to locate element: h1

코드 수정 - lists/templates/home.html 파일

<html> <title>일정관리</title> <body> <h1>일정목록</h1> </body> </html>

오류

selenium.common.exceptions.NoSuchElementException: Message: Unable to locate element: [id="id_new_item"]

코드 수정 - lists/templates/home.html 파일

<html> <title>일정관리</title> <body> <h1>일정목록</h1> <input id="id_new_item" placeholder="할일을 입력하세요" /> </body> </html>

오류

selenium.common.exceptions.NoSuchElementException: Message: Unable to locate element: [id="id_list_table"]

코드 수정 - lists/templates/home.html 파일

<html> <title>일정관리</title> <body> <h1>일정목록</h1> <input id="id_new_item" placeholder="할일을 입력하세요"/> <table id="id_list_table"> </table> </body> </html>

오류

Traceback (most recent call last): File "function_tests.py", line 39, in test_can_start_a_list_and_retrieve_it_later self.assertTrue(any(row.text == '1: 시장에서 미역 사기' for row in rows)) AssertionError: False is not true

코드 수정 - lists/tests.py 파일

self.assertTrue( any(row.text == '1: 시장에서 미역 사기' for row in rows), "New to-do item did not appear in table" )

오류

Traceback (most recent call last): File "function_tests.py", line 41, in test_can_start_a_list_and_retrieve_it_later "New to-do item did not appear in table" AssertionError: False is not true : New to-do item did not appear in table

작업 내역 커밋

현재까지 작업 내역을 커밋한다. 아직까지 오류가 여전히 존재하지만 오류가 존재하는대로 커밋한다.

$ git status $ git diff $ git commit -am "Front page HTML now generated from a template"

요약 및 의견

TDD 절차는 다음 4단계를 끊임 없이 반복하는 것이다.

  1. 기능 테스트
  2. 단위 테스트
  3. 단위 테스트 및 구현 코딩
  4. 리팩토링

어디까지 테스트해야할지 결정하는 것은 쉬운 문제가 아니다.

  • 최소한 주요 로직을 검증하는 테스트는 모두 작성한다.
  • 최대로 구현하는 시간의 2배 이상은 쓰지 않는다.

좋은 테스트란?

  • 한 번에 하나만 테스트한다.
  • 실패가 명확해야 한다.
  • 빠르게 테스트할 수 있어야 한다.
  • 중복된 테스트를 작성하지 않는다.
  • 독립적이어야 한다. (다른 테스트이 영향을 받지 않는다.)
  • 자동화해야 한다.

 

반응형