본격적인 To-Do 웹 에플리케이션 개발을 위해 단위 테스트를 만들어본다.
$ python manage.py startapp lists
실행하면 superlists/superlists 와 동일 위치에 spuerlists/lists 라는 폴더가 생성된다.
종류 | 단위 테스트 | 기능 테스트 |
---|---|---|
관점 | 프로그래머 관점 | 사용자 관점 |
목표 | 애플리케이션 내부 | 애플리케이션 외부 |
레이어 | 하위 레벨 | 상위 레벨 |
즉 상위단(기능테스트) 를 먼저 작성후에 그 하위단(단위 테스트)을 잘게 쪼게서 테스트 함을 알수 있다. 번거로워 보일 수 있으나, 실질적인 설계와 이후에 검증할수 있는 자동화된 툴까지 만들어지기 때문에 합리적인 프로세스인거 같다.
선 실패 -> 테스트를 통과할 코드 작성 -> 후 통과 -> 새 테스트 코드 작성 -> 선 실패..(계속 반복)
Django는 기본 TestCase class를 확장한 django.test.TestCase 클래스를 기본 단위 테스트로 사용하도록 권하고 있다. django 프로젝트에 맞는 여러 확장 기능들이 있다. 그 중에 manage.py 에서 test 커맨드로 전체 testcase를 실행하는 기능이 포함되어 있다. 고의적인 실패 테스트를 작성하여 이를 확인해 보자.
$ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_bad_maths (lists.tests.SmokeTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "workspace/superlists/lists/tests.py", line 7, in test_bad_maths
self.assertEqual(1 + 1, 3)
AssertionError: 2 != 3
----------------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (failures=1)
Destroying test database for alias 'default'...
Django는 대체로 MVC(Model-View-Cotroller) 패턴을 따름
이 MVC 패턴을 차용하여 Django는
MVC패턴 | Django |
---|---|
Model(Data) | Model |
View | Template |
Controller | View |
라고 표현하여 MTV(model-template-view)패턴이라고 명명함
저기 중에 http client - view 사이에서 일어나는 일을 좀더 자세히 설명하면 이렇다.
따라서 우리가 테스트 할 2가지
$ python 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 "/Users/pilhwankim/.pyenv/versions/3.7.1/lib/python3.7/unittest/loader.py", line 434, in _find_test_path
module = self._get_module_from_name(name)
File "/Users/pilhwankim/.pyenv/versions/3.7.1/lib/python3.7/unittest/loader.py", line 375, in _get_module_from_name
__import__(name)
File "/Users/pilhwankim/Github/books/test_driven_development_with_python/ch03_Testing_a_Simple_Home_Page_with_Unit_Tests/03-03/superlists/lists/tests.py", line 3, in <module>
from lists.views import home_page
ImportError: cannot import name 'home_page' from 'lists.views' (/Users/pilhwankim/Github/books/test_driven_development_with_python/ch03_Testing_a_Simple_Home_Page_with_Unit_Tests/03-03/superlists/lists/views.py)
----------------------------------------------------------------------
Ran 1 test in 0.000s
FAILED (errors=1)
예상하던 대로 에러가 발생한다. 대략 에러의 내용은 veiws 의 home_page 라는 존재하지 않는 것을 임포트 하려고 했기 때문에 발생했다. 예측된 실패이고 TDD 관점으로는 거쳐가야 할 첫 과정이다.
실패 테스트를 해결해 보자. 한번에 하나씩! 일단 import 에러를 해결한다.
너무 단순하고 허무한 코드이기 한데, 저자의 의도는 이런거 같다.
한 번에 한 가지의 당면한 문제를 해결해라!
다음과 같이 구현하고 다시 테스트를 돌려보면 다른 에러가 발생한다.
python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
E
======================================================================
ERROR: test_root_url_resolves_to_home_page_view (lists.tests.HomePageTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/pilhwankim/Github/books/test_driven_development_with_python/ch03_Testing_a_Simple_Home_Page_with_Unit_Tests/03-04/superlists/lists/tests.py", line 8, in test_root_url_resolves_to_home_page_view
found = resolve('/')
File "/Users/pilhwankim/.pyenv/versions/tdd-with-python-env/lib/python3.7/site-packages/django/urls/base.py", line 24, in resolve
return get_resolver(urlconf).resolve(path)
File "/Users/pilhwankim/.pyenv/versions/tdd-with-python-env/lib/python3.7/site-packages/django/urls/resolvers.py", line 567, in resolve
raise Resolver404({'tried': tried, 'path': new_path})
django.urls.exceptions.Resolver404: {'tried': [[<URLResolver <URLPattern list> (admin:admin) 'admin/'>]], 'path': ''}
----------------------------------------------------------------------
Ran 1 test in 0.004s
FAILED (errors=1)
Destroying test database for alias 'default'...
참고 사항) 2판 기준 책이 django 1.7 기준이다. 굳이 예전버전을 쓸 필요는 없으므로 최신의 2.2 버전으로 최대한 예제를 따라 진행한다. 크게 바뀐 내용은 3가지 정도 요약됨
책의 내용과 비슷하게 아래와 같이 urls.py 를 변경해 진행해보면
from django.contrib import admin
from django.urls import path
from lists import views as home_views
urlpatterns = [
# path('admin/', admin.site.urls),
path('', home_views.home_page, name='home'),
]
$ python manage.py test
Creating test database for alias 'default'...
Destroying test database for alias 'default'...
Traceback (most recent call last):
(...생략...)
File "/test_driven_development_with_python/ch03_Testing_a_Simple_Home_Page_with_Unit_Tests/03-05/superlists/superlists/urls.py", line 22, in <module>
path('/', lists.views.home_page, name='home'),
File "~/.pyenv/versions/tdd-with-python-env/lib/python3.7/site-packages/django/urls/conf.py", line 73, in _path
raise TypeError('view must be a callable or a list/tuple in the case of include().')
TypeError: view must be a callable or a list/tuple in the case of include().
urls.py 에 view와 url 매핑은 했으나,아직 lists.views.home_page 가 None 값으로 아직 미구현 되어서 나타나는 에러이다.
다시 lists/views.py 로 돌아가서 실제 view 를 함수로 구현해보자.
테스트를 실행해보면
python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.001s
OK
Destroying test database for alias 'default'...
첫 테스트가 성공했다! 사이트 루트(”/”) 요청을 django view 까지 연결하는 것이 성공했다는 의미이다.
추가된 테스트는 요약하면 response body 텍스트가
<html>
<title>To-Do lists</title>
</html>
내용으로 오는지 검증하는 내용이다.
python manage.py test
======================================================================
ERROR: test_home_page_returns_correct_html (lists.tests.HomePageTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/Users/pilhwankim/Github/books/test_driven_development_with_python/ch03_Testing_a_Simple_Home_Page_with_Unit_Tests/03-06/superlists/lists/tests.py", line 15, in test_home_page_returns_correct_html
response = home_page(request)
TypeError: home_page() takes 0 positional arguments but 1 was given
----------------------------------------------------------------------
Ran 2 tests in 0.001s
FAILED (errors=1)
의도적으로 테스트를 돌려보면 실패한다. 이제 구현해야 한다.
코드 품질을 높이고자 한다면 코드 변경은 최소화 한다. 즉 이 케이스에서는
home_page() takes 0 positional arguments but 1 was given
라고 했기 때문에 home_page 함수에 argument를 1개 추가하는 최소한의 코드를 변경하고 다시 테스트 해야한다.
def home_page(request):
pass
======================================================================
ERROR: test_home_page_returns_correct_html (lists.tests.HomePageTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/superlists/lists/tests.py", line 16, in test_home_page_returns_correct_html
self.assertTrue(response.content.startswith(b'<html>'))
AttributeError: 'NoneType' object has no attribute 'content'
----------------------------------------------------------------------
home_page 에 리턴값 없이 None 으로 오기 때문이다. 그래서 코드 변경은?
from django.http import HttpResponse
def home_page(request):
return HttpResponse()
======================================================================
FAIL: test_home_page_returns_correct_html (lists.tests.HomePageTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/superlists/lists/tests.py", line 16, in test_home_page_returns_correct_html
self.assertTrue(response.content.startswith(b'<html>'))
AssertionError: False is not true
----------------------------------------------------------------------
Ran 2 tests in 0.007s
리턴값 객체는 맞게 왔으나 contents 가 없기 때문이다. contents를 채울 코드 변경은?
from django.http import HttpResponse
def home_page(request):
return HttpResponse('<html><title>To-Do lists</title></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'...
드디어 하나의 단위 테스트가 완결되었다!
이전에 개발했었던 기능 테스트(functional_test.py)를 다시 실행해보자!
# shell을 2개 띄우고 한쪽에는 django 서버를 띄움
$ python manage.py runserver
# 다른 한쪽에서 기능 테스트 실행
$ python functional_test.py
F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "functional_test.py", line 21, in test_can_start_a_list_and_retrieve_it_later
self.fail('Finish the test!')
AssertionError: Finish the test!
----------------------------------------------------------------------
Ran 1 test in 2.256s
FAILED (failures=1)
unittest 결과는 실패로 나오지만 self.fail()가 실행되어 asserionError 가 난 것이므로 이전 test 는 통과한 것이다. 첫번째 기능 테스트도 성공이다!
이 장에서 저자가 독자들이 깨닫기 원하는 핵심적인 내용은 단위 테스트-코드 주기 와 최소한의 코드 변경 이 아닌가 싶다. 사실 기능이나 코드 양으로 따지면 되게 적은 양인데 저렇게 까지 자주 테스트 해야하나? 라는 생각도 든다. 하지만 여기서 이렇게 세밀한 스텝을 언급하는 이유는 아마도 TDD가 어떤 것인지 실제 경험해 보기 위함이라는 생각이 든다. 한번에 한 가지씩! 한번에 아주 작은 스텝을 밟아나가기! 실제 TDD를 개발하는 방법이 이렇다는걸 잘 깨닫게 된 계기가 된 것 같다.