지난 챕터의 마지막 기능 테스트의 결과로 다음의 에러 메세지를 받았다.
AssertionError: 'To-Do' not found in 'Welcome to Django'
이제 이 에러를 해결하기 위해서 새로운 앱을 시작한다.
./manage.py startapp lists
lists
라는 폴더가 새로 생성되며 그 안에는 여러가지 Python 파일들이 들어있다.
그 중 우리가 봐야할 파일은 tests.py
이다. 지금부터 이 파일에 유닛 테스트
를 작성할 것이다.
Unit Test
유닛 테스트(unit test)는 컴퓨터 프로그래밍에서 소스 코드의 특정 모듈이 의도된 대로 정확히 작동하는지 검증하는 절차다. 즉, 모든 함수와 메소드에 대한 테스트 케이스(Test case)를 작성하는 절차를 말한다. 이를 통해서 언제라도 코드 변경으로 인해 문제가 발생할 경우, 단시간 내에 이를 파악하고 바로 잡을 수 있도록 해준다. 이상적으로, 각 테스트 케이스는 서로 분리되어야 한다. 이를 위해 가짜 객체(Mock object)를 생성하는 것도 좋은 방법이다. 유닛 테스트는 (일반적인 테스트와 달리) 개발자(developer) 뿐만 아니라 보다 더 심도있는 테스트를 위해 테스터(tester)에 의해 수행되기도 한다.
출처: 위키피디아
기능 테스트가 사용자의 시점으로 외부에서 어플리케이션을 테스트하는 것이라면,
유닛 테스트는 개발자의 시점으로 내부에서 어플리케이션을 테스트하는 것이다.
TDD에서는 이 두 가지를 모두 활용해서 테스트를 수행해야 한다. 그 과정을 요약하면 다음과 같다.
-
사용자의 관점에서 어플리케이션의 어떤 기능이 수행해야할 일에 대한 내용을 기능 테스트로 작성한다.
-
실패하는 기능 테스트가 생기면, 실제 코드를 어떻게 작성해서 이 실패를 해결할 것인지를 생각한다. 실제 코드가 동작해야할 방식을 유닛 테스트에 정의한다. 실제로 작성하는 모든 코드는 반드시 최소한 하나 이상의 유닛 테스트 코드에 의해 테스트 되어야한다.
-
실패하는 유닛 테스트가 생기면, 그 유닛 테스트만을 해결하는 최소한의 실제 어플리케이션 코드를 작성한다. 다음 기능 테스트로 넘어갈 때까지 2번과 3번 과정을 반복한다.
-
기능 테스트를 실행해서 실패하던 테스트가 통과되는지를 확인한다. 새로운 기능 테스트의 실패가 발생하면, 그것을 해결하기 위해 새로운 유닛 테스트를 작성하고, 그 유닛 테스트들을 통과하기 위해 어플리케이션 코드를 작성하는 과정을 반복한다.
기능 테스트는 사용자가 필요로하는 기능을 제대로 구현하고 있는지를 지속적으로 체크하고,
유닛 테스트는 깔끔하고 버그가 없는 코드를 작성할 수 있도록 도와준다.
Django 유닛 테스트
각 앱의 tests.py
파일에 해당 앱을 검사하는 유닛 테스트 코드를 작성한다.
유닛 테스트 코드는 기능 테스트와 마찬가지로 클래스의 형태로 작성하며, TestCase
를 상속받는다.
우선 유닛 테스트가 잘 작동하는지 확인하기 위해 다음과 같이 실패할 것이 뻔한 테스트를 작성해보자.
# tests.py
from django.test import TestCase
class SmokeTest(TestCase):
def test_bad_maths(self):
self.assertEqual(1 + 1, 3)
Creating test database for alias 'default'...
F
======================================================================
FAIL: test_bad_maths (lists.tests.SmokeTest)
---------------------------------------------------------------------
Traceback (most recent call last):
File "/workspace/superlists/lists/tests.py", line 6, 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'...
당연히 1 + 1 은 3이 아니므로 테스트가 실패한다. 실패 내용이 자세히 나타나는 것을 보아 유닛 테스트가 잘 작동하는 것을 알 수 있다.
이제 실패하는 기능테스트를 통과하기 위한 유닛 테스트를 작성해보자.
현재 실패하는 기능 테스트를 통과하려면 To-Do
라는 문자열이 메인 페이지의 타이틀과 헤더에 나타나야한다.
메인 페이지의 타이틀과 헤더를 수정하려면 메인 페이지를 나타낼 html
파일이 필요하다.
그리고 그 html
파일을 사용자가 메인 페이지의 URL에 접속했을 때 띄워주어야한다.
장고는 사용자가 특정 URL에 접속했을 때 무엇을 할지를 결정한다. 장고는 다음과 같은 프로세스를 통해 이 역할을 수행한다.
-
사용자에 의해 특정 URL로
HTTPrequest
가 보내진다. -
장고는 몇가지 규칙 (URL resolving) 을 통해 어떤 뷰를 사용해서 전달된 요청을 처리할 것인지를 결정한다.
-
결정된 뷰에서
request
요청을 처리하여HTTPresponse
를 사용자에게 돌려준다.
따라서 우리가 유닛 테스트로 검사해야할 사항은 다음과 같다.
-
루트 URL (
/
) 이 특정 뷰 함수를 실행할 수 있는가? -
실행된 뷰 함수가 현재의 기능 테스트를 통과할 수 있는
html
파일을 리턴할 수 있는가?
우선 첫 번째 사항부터 해결해보자. tests.py
를 열고 위에서 작성한 내용을 지운 뒤, 아래와 같이 작성한다.
from django.core.urlresolvers import resolve
from django.test import TestCase
from lists.views import home_page #1
class HomePageTest(TestCase):
def test_root_url_resolves_to_home_page_view(self):
found = resolve('/') #2
self.assertEqual(found.func, home_page) #3
#1: home_page
home_page
는 루트 URL로 request
요청이 전달되었을 때 작동할 뷰 함수이다. 아직은 없지만 앞으로 작성하게될 함수이다.
#2: resolve(‘/’)
resolve()
는 장고에서 URL을 처리하여 실행해야할 뷰 함수를 찾는 함수이다. /
에 접속했을 때 실행되는 뷰를 찾아 리턴한다.
#3: self.assertEqual(found.func, home_page)
이 부분에서는 resolve('/')
명령의 결과로 리턴된 함수 (found.func
) 가 home_page
뷰 함수와 같은 함수인지를 검사한다. 즉, 루트 URL /
로 접속했을 때, home_page
뷰가 작동하는지를 검사하는 것이다.
유닛 테스트는 실제 어플리케이션 코드들이 어떤 식으로 작동하게 될 것이다는 시나리오를 미리 짜놓고 그 시나리오 대로 실제 코드들이 작성되도록 돕는다.
이제 유닛 테스트를 실행해보자. 유닛 테스트는 아래의 명령으로 실행한다.
./manage.py test
...
ImportError: cannot import name 'home_page'
----------------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (errors=1)
하나의 테스트에서 에러가 발생하였다는 로그가 뜨고, home_page
를 가져올 수 없다는 ImportError
가 발생했다.
당연히 home_page
함수를 아직 만들지 않았기 때문에 발생하는 에러이다.
이제 이 에러를 바로잡기 위한 최소한의 코드를 작성한다.
# views.py
from django.shortcuts import render
home_page = None
좀 어이없어 보일지 몰라도 위의 코드만으로도 한 단계의 유닛 테스트를 통과할 수 있다. 다시 유닛 테스트를 실행해보면 다음과 같이 다른 에러메세지를 받게 된다.
에러메세지 확인하기
======================================================================
ERROR: test_root_url_resolves_to_home_page_view (lists.tests.HomePageTest) #2
---------------------------------------------------------------------
Traceback (most recent call last):
File "/workspace/superlists/lists/tests.py", line 8, in
test_root_url_resolves_to_home_page_view
found = resolve('/') #3
File "/usr/local/lib/python3.4/dist-packages/django/core/urlresolvers.py",
line 522, in resolve
return get_resolver(urlconf).resolve(path)
File "/usr/local/lib/python3.4/dist-packages/django/core/urlresolvers.py",
line 388, in resolve
raise Resolver404({'tried': tried, 'path': new_path})
django.core.urlresolvers.Resolver404: {'tried': [[<RegexURLResolver
<RegexURLPattern list> (admin:admin) ^admin/>]], 'path': ''} #1
---------------------------------------------------------------------
[...]
에러 메세지를 보는 방법을 알면 에러가 발생했을 때 바로 어떤 부분에서 에러가 발생하는지를 알 수 있게 된다.
#1: django.core.urlresolvers.Resolver404
보통은 에러 자체에서 보내는 메세지를 확인하면 어떤 부분이 잘못된 것인지 확인할 수 있다. 그러나 이 에러메세지의 경우와 같이 에러 메세지 자체만으로는 그 의미를 쉽게 파악하지 못할 때가 있다.
#2: ERROR: test_root_url_resolves_to_home_page_view
(lists.tests.HomePageTest)
에러메세지만으로는 문제를 알기 어려운 경우, 어떤 테스트 클래스가 에러를 일으키는지를 같이 확인하면 도움이 된다. 이 경우, 우리가 방금 작성한 테스트 메서드에서 에러가 발생하고 있음을 알 수 있다. 즉, 예상치 못한 에러는 아닌 것이다.
#3: found = resolve(‘/’)
다음으로는 테스트 코드의 어떤 부분이 에러를 발생시키는지를 확인한다. 이번 에러의 경우에는 tests.py
파일의 8번째 줄인 found=resolve('/')
에서 처음 문제가 발생한 것을 볼 수 있다. 다음으로 이어지는 로그들은 resolve()
를 통해 실행되는 장고 내부의 코드들에서 발생한 에러들이다. 이 로그들을 거꾸로 타고 올라가보면 어떤 부분에서 최초로 에러가 발생하는지 알 수 있으며 그 부분을 수정하면 에러를 바로잡을 수 있다.
에러를 요약하면 resolve('/')
가 실행되어서 /
URL로 접속을 시도했지만 404 에러가 발생, 즉, 해당 URL을 찾지 못했다는 내용이다.
URL 설정하기
이제 다시 이 에러를 해결하기 위한 최소한의 코딩을 수행한다.
urls.py
파일을 열어 루트 URL /
를 설정해준다.
# urls.py
from django.conf.urls import include, url
from django.contrib import admin
from lists.views import home_page
urlpatterns = [
url(r'^admin/', include(admin.site.urls)),
url(r'^$', home_page, name='home_page')
]
그런 다음 다시 유닛 테스트를 실행하면 다음의 에러 메세지를 받는다.
TypeError: view must be a callable or a list/tuple in the case of include().
뷰는 호출가능한 객체이거나, include()
를 사용하는 경우 리스트 혹은 튜플 타입이어야 한다는 내용이다.
에러 메세지가 이렇게 바뀌었다는 것은, resolve('/')
를 통해 루트 URL에 접속하였을 때, 이전과는 달리 어떤 값을 리턴받았다는 것을 뜻한다. 비록 그 리턴 값이 올바른 값이 아니어서 다른 에러 메세지가 발생했지만, 적어도 이를 통해 이제 루트 URL에 접속을 할 수 있게 되었다는 것을 알게 되었다.
TDD의 핵심은 모든 코딩이 테스트의 주도하에 진행되는 것이다. 이제 루트 URL에 접속했을 때 호출된 home_page
의 타입이 잘못되었다는 것을 알았으니 이를 고치기 위한 최소한의 코딩을 시작할 수 있게 되었다.
views.py
를 열어 home_page
를 다음과 같이 수정하자.
# views.py
def home_page():
pass
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'...
OK
라는 메세지를 돌려받았다. 처음으로 유닛 테스트 하나를 통과한 순간이다.
뷰 함수 유닛 테스트하기
지금까지의 진행사항을 다시 정리해보면…
실패한 기능 테스트:
사용자가 홈페이지의 `타이틀` 과 `헤더` 에 `To-Do list` 라고 적혀있는 것을 확인.
└── 이 실패를 해결하기 위한 유닛 테스트:
├── 루트 URL (`/`) 이 특정 뷰 함수를 실행할 수 있는가? -- 해결!
│ └── self.assertEqual(found.func, home_page) 테스트 결과: OK
└── 실행된 뷰 함수가 현재의 기능 테스트를 통과할 수 있는 `html` 파일을 리턴할 수 있는가?
└── 지금부터 작성할 부분
유닛 테스트로 검사해야할 두 가지 항목 중 하나를 해결하였다.
-
루트 URL (
/
) 이 특정 뷰 함수를 실행할 수 있는가? -
실행된 뷰 함수가 현재의 기능 테스트를 통과할 수 있는
html
파일을 리턴할 수 있는가?
이제 home_page
뷰 함수가 타이틀과 헤더에 To-Do
라는 문자열을 가진 html
파일을 리턴할 수 있는지를 검사해야한다.
tests.py
를 열어 다음과 같이 추가한다.
from django.core.urlresolvers import resolve
from django.test import TestCase
from django.http import HttpRequest
from lists.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() #1
response = home_page(request) #2
self.assertTrue(response.content.startswith(b'<html>')) #3
self.assertIn(b'<title>To-Do lists</title>', response.content) #4
self.assertTrue(response.content.endswith(b'</html>')) #5
#1: request = HttpRequest()
HttpRequest
객체는 사용자의 브라우저가 특정 URL로 접속하였을 때 장고가 받게되는 것이다. 유닛 테스트에서는 가상의 HttpRequest
를 발생시켜 request
라는 변수에 저장한 다음 이어지는 테스트에 사용한다.
#2: response = home_page(request)
뷰는 HttpRequest
객체를 받아서 처리한 다음 HttpResponse
객체를 리턴한다. home_page
뷰가 리턴한 HttpResponse
를 response
라는 변수에 담아서 이후의 테스트에 사용한다.
#3: self.assertTrue(response.content.startswith(b’<html>’))
response.content
는 뷰가 리턴한 HttpResponse
의 내용을 호출한다. 그리고 .startswith(b'<html>')
메서드를 통해 그 내용이 <html>
으로 시작하는지를 확인한다. 이 때, response.content
는 바이트 자료로 불러와지므로 이것과 <html>
문자열을 비교하기 위해서 문자열 앞에 b
를 붙여 바이트 자료로 변환시켜 주어야한다.
#4: self.assertIn(b’To-Do lists ’, response.content)
이 명령은 response.content
가 <title>To-Do lists</title>
라는 내용을 포함하고 있는지를 검사한다. 기능 테스트에서 사용자가 페이지의 타이틀에서 To-Do
라는 글자를 확인한다고 했으므로, 이것이 가능하기 위해서는 내부적으로 html
파일의 title
태그안의 내용이 To-Do
라는 문자열을 포함해야한다. #3
과 마찬가지로 바이트 자료와의 비교를 위해, b
를 문자열 자료 앞에 붙여주었다.
#5: self.assertTrue(response.content.endswith(b’</html>’))
#3
과 마찬가지로 response.content
가 </html>
로 끝나는지를 검사한다.
종합하면 response
의 내용이 <html>
로 시작하고 </html>
로 끝이 난다면, 그것은 html
파일이므로, #3
과 #5
테스트를 통과한다면 뷰가 html
파일을 리턴한다는 것을 확인할 수 있다. 거기에 #4
테스트를 통해서 html
파일의 title
태그가 To-Do
라는 문자열을 포함하는지를 확인한다.
유닛 테스트/코드 사이클
TDD에서 유닛 테스트를 실행하여 에러를 확인한 다음 그것을 통과할 수 있는 최소한의 코딩을 하고, 다시 유닛 테스트를 실행하는 과정을 반복하는 것을 유닛 테스트/코드 사이클 (Unit-Test/Coding Cycle)
이라고 한다.
유닛 테스트/코드 사이클
-
터미널에서 유닛 테스트를 실행하여 에러를 확인한다.
-
발생한 에러를 해결할 수 있는 최소한의 코딩을 한다.
-
위 과정을 반복한다.
완벽한 코딩을 원할 수록 각 사이클에서의 코딩을 최소한으로 만들어야 한다. 코드의 변화가 아무리 사소하더라도 유닛 테스트의 주도하에 시행되어야 한다.
이제 유닛 테스트/코드 사이클을 직접 수행해보자.
유닛 테스트를 실행해보면 다음과 같은 에러 메세지를 받는다.
TypeError: home_page() takes 0 positional arguments but 1 was given
home_page
뷰는 0개의 위치인자를 받는데 하나의 위치인자가 전달되었다는 내용이다.
이제 에러를 확인했으니 이를 해결할 수 있는 최소한의 코드를 작성해준다.
# views.py
def home_page(request):
pass
이제 home_page
는 하나의 인자를 받을 수 있게 되었다. 다시 유닛 테스트를 실행해본다.
AttributeError: 'NoneType' object has no attribute 'content'
home_page
뷰가 NoneType
객체를 리턴하기 때문에 content
속성을 찾을 수 없다고 한다. 이를 해결하기 위한 최소한의 코딩을 한다.
# views.py
from django.http import HttpResponse
def home_page(request):
return HttpResponse()
이제 home_page
뷰가 HttpResponse
객체를 리턴한다. 다시 유닛 테스트를 실행한다.
...
self.assertTrue(response.content.startswith(b'<html>'))
AssertionError: False is not true
HttpResponse
객체의 내용이 <html>
로 시작하지 않는다고 한다. 최소한의 코딩으로 해결한다.
# views.py
def home_page(request):
return HttpResponse('<html>')
이제 HttpResponse
의 내용에 <html>
이 추가되었다. 다시 유닛 테스트를 실행한다.
AssertionError: b'<title>To-Do lists</title>' not found in b'<html>'
HttpResponse
의 내용에 <title>To-Do lists</title>
가 없다고 한다. 추가 해주자.
def home_page(request):
return HttpResponse('<html><title>To-Do lists</title>')
다시 유닛 테스트를 실행해보자.
self.assertTrue(response.content.endswith(b'</html>'))
AssertionError: False is not true
이번엔 HttpResponse
의 내용이 </html>
로 끝나지 않는다고 한다. 최소한의 코딩으로 해결한다.
def home_page(request):
return HttpResponse('<html><title>To-Do lists</title></html>')
...
Ran 2 tests in 0.001s
OK
드디어 유닛 테스트를 모두 통과했다. 이제 기능 테스트를 실행할 차례이다.
AssertionError: Finish the test!
----------------------------------------------------------------------
Ran 1 test in 29.420s
FAILED (failures=1)
에러가 발생한 것 같지만 저 에러는 기능 테스트를 멈추기 위해 일부러 발생시킨 에러이다. 즉, 지금까지 설정해놓은 기능테스트는 모두 통과했다는 것을 의미한다.
이제 다음 유저 스토리를 구현할 기능 테스트를 작성하고 그 기능 테스트를 통과할 수 있는 코드를 짜도록 유닛 테스트를 작성한다.
Reference
Test-Driven Development with Python, Harry Percival: http://chimera.labs.oreilly.com/books/1234000000754/ch03.html