첫 번째 Django 앱과 단위 테스트
lists라는 이름의 Django 앱을 만든다.
$ python manage.py startapp lists
지금까지 작업한 내용에 따라 파일/디렉토리 구조는 아래와 같다.
superlists/ db.sqlite3 function_tests.py geckodriver.log lists/ migrations/ __init__.py admin.py apps.py __init__.py models.py tests.py views.py manage.py superlists/ __init__.py settings.py urls.py wsgi.py
단위 테스트와 기능 테스트의 차이
기능 테스트는 사용자 관점에서 기능을 올바르게 구현하고 동작하는지 확인하는 것이다.
단위 테스트는 기능 테스트 통과를 위해 작성하는 작은 조각 코드이다.
이 책에서 따르는 TDD 작업 흐름은 아래와 같다.
- 사용자 관점에서 새로운 기능에 대한 설명을 주석으로 달아 기능 테스트를 작성한다.
- 실패하는 기능 테스트를 작성했으면 어떻게 이 테스트를 통과시킬지 생각해본다. 이를 위해 단위 테스트를 하나씩 작성한다.
- 실패하는 단위 테스트를 작성했으면 단위 테스트를 통과시킬 수 있는 애플리케이션의 작은 조각 코드를 작성한다.
- 기능 테스트/단위 테스트를 재실행해서 필요에 따라 단위 테스트를 수정 또는 추가하고 기능 테스트를 개선해나간다. 2, 3, 4번 과정을 계속 반복적으로 수행한다.
Python 테스트 시작하기에서 단위 테스트와 기능 테스트를 아래와 같이 비교하고 있다.
단위 테스트(Unit Test)기능 테스트(Function Test)
개발자 관점 | 사용자 관점 |
함수 단위 | 요구사항 단위 |
Mock 사용 | Fixture 사용 |
빠름 | 느림 |
더 좋은 코드에 기여 | 퇴근에 기여 |
Django에서 단위 테스트
Django 앱을 만들면 이미 lists/tests.py 파일이 제공된다.
lists/tests.py 파일
from django.test import TestCase # Create your tests here.
위 파일을 아래와 같이 수정하여 SmokeTest를 추가한다.
lists/tests.py 파일
from django.test import TestCase class SmokeTest(TestCase): def test_bad_maths(self): self.assertEqual(1 + 1, 3)
아래와 같이 테스트를 실시하고 실패하는지 확인한다.
$ 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 "/home/mango/Projects/tdd/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은 2인데 3인지 확인하고 있으니 당연히 테스트는 실패한다.
이제 단위 테스트를 추가하고 실행하는 방법을 알았다.
이렇게 일부러 실패하는 테스트를 추가했으면 지금까지 작업한 내용을 git 로컬 저장소에 커밋한다.
현재까지 수정된 파일과 추가하지 않은 파일의 상태를 확인한다.
$ git status On branch master Changes not staged for commit: (use "git add <file>..." to update what will be committed) (use "git checkout -- <file>..." to discard changes in working directory) modified: function_tests.py Untracked files: (use "git add <file>..." to include in what will be committed) lists/ no changes added to commit (use "git add" and/or "git commit -a")
lists 디렉토리를 추가한다.
$ git add lists
커밋하기 직전에 변경사항을 확인한다.
$ git status On branch master Changes to be committed: (use "git reset HEAD <file>..." to unstage) new file: lists/__init__.py new file: lists/admin.py new file: lists/apps.py new file: lists/migrations/__init__.py new file: lists/models.py new file: lists/tests.py new file: lists/views.py
실제 파일 내용의 차이점을 확인할 수도 있다.
$ git diff --staged diff --git a/lists/__init__.py b/lists/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lists/admin.py b/lists/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/lists/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. [... 생략 ...]
Add for lists, with deliberately failing unit test 메시지로 커밋한다.
$ git commit -m "Add for lists, with deliberately failing unit test" [master 7819b9a] Add for lists, with deliberately failing unit test 7 files changed, 20 insertions(+) create mode 100644 lists/__init__.py create mode 100644 lists/admin.py create mode 100644 lists/apps.py create mode 100644 lists/migrations/__init__.py create mode 100644 lists/models.py create mode 100644 lists/tests.py create mode 100644 lists/views.py
Django와 MVC, URL, View 함수
Django는 전형적인 MVC(Model, View, Controller) 디자인 패턴을 따르지만 용어는 조금 다르다.
MVCDjango MTV
Model | Model |
View | Template |
Controller | View |
위와 같이 뷰의 정의가 서로 다르기 때문에 혼동할 수 있으니 유의한다.
Django는 사용자의 요청을 처리하는 순서는 다음과 같다.
- 특정 URL을 통해 HTTP 요청(request)이 들어온다.
- Django는 그 요청을 URL 매핑 규칙에 따라 어떤 뷰 함수에서 처리할 지 결정(resolving)한다.
- 뷰 함수는 사용자의 요청을 처리하고 HTTP 응답(response)을 반환한다.
따라서 아래 두 질문을 던질 수 있다.
- 사이트 루트(/) URL을 특정 뷰 함수에 어떻게 연결하는가. 답: urls.py에 urlpatterns 선언
- 기능 테스트를 통과할 수 있도록 HTML을 반환 뷰 함수를 어떻게 만드는가. 답: HttpResponse 인스턴스 반환
lists/tests.py 파일에서 SmokeTest 단위 테스트는 이제 지우고 아래 내용으로 작성한다.
lists/tests.py 파일
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)
위 테스트는 홈페이지 시작 페이지 주소 / 경로를 찾을 수 있는지 테스트하는 것이다. 그런데 이 테스트를 실시하면 아직 home_page 뷰가 없으므로 역시 예상한대로 에러가 발생하여 테스트를 실패한다.
$ python manage.py test Creating test database for alias 'default'... 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 "/usr/lib/python3.5/unittest/loader.py", line 428, in _find_test_path module = self._get_module_from_name(name) File "/usr/lib/python3.5/unittest/loader.py", line 369, in _get_module_from_name __import__(name) File "/home/mango/Projects/tdd/superlists/lists/tests.py", line 3, in <module> from .views import home_page ImportError: cannot import name 'home_page' ---------------------------------------------------------------------- Ran 1 test in 0.000s FAILED (errors=1) Destroying test database for alias 'default'...
뷰 만들기
이제 본격적인 Django 코딩을 시작한다.
뷰 함수를 만들어야 하는데 진짜 뷰 함수를 만드는 게 아니라 아까 이름이 존재하지 않는다는 에러였으므로 그냥 변수 이름만 선언해본다.
lists/views.py 파일
from django.shortcuts import render # Create your views here. home_page = None
다시 테스트를 진행하면 이젠 home_page 이름을 찾지 못한다는 에러는 발생하지 않는다. 사실상 이름 찾지 못한다는 에러만 피해보자는 속임수에 지나지 않았다. 그래서 결국 URL 패턴을 결정(resolve)할 수 없다는 에러가 발생하고 테스트를 실패했다.
$ 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 "/home/mango/Projects/tdd/superlists/lists/tests.py", line 8, in test_root_url_resolves_to_home_page_view found = resolve('/') File "/home/mango/Projects/tdd/venv/lib/python3.5/site-packages/django/urls/base.py", line 27, in resolve return get_resolver(urlconf).resolve(path) File "/home/mango/Projects/tdd/venv/lib/python3.5/site-packages/django/urls/resolvers.py", line 392, in resolve raise Resolver404({'tried': tried, 'path': new_path}) django.urls.exceptions.Resolver404: {'tried': [[<RegexURLResolver <RegexURLPattern list> (admin:admin) ^admin/>]], 'path': ''} ---------------------------------------------------------------------- Ran 1 test in 0.006s FAILED (errors=1) Destroying test database for alias 'default'...
URL 패턴과 뷰 함수 매핑
앞서 URL 패턴과 뷰 함수 매핑이 되어 있지 않다는 에러가 발생했으므로 이 문제를 해결해야 한다.
Django에서 URL 패턴과 뷰 함수 매핑은 urls.py 모듈 파일에 정의한다.
superlists/urls.py 파일
from django.conf.urls import url from django.contrib import admin from lists.views import home_page urlpatterns = [ url(r'^admin/', admin.site.urls), url(r'^$', home_page, name='home'), # <- 추가 ]
url(r'^$', views.home_page, name='home'), 줄을 추가하고 다시 테스트를 실행하면 이제 아래와 같이 에러가 발생한다.
$ python manage.py test Creating test database for alias 'default'... Traceback (most recent call last): .. 생략 ... TypeError: view must be a callable or a list/tuple in the case of include().
앞서 만든 뷰는 home_page 이름만 있는 변수이므로 호출할 수도 없고 리스트나 튜플을 반환하는 것도 아니기 때문에 에러가 발생했다.
이제 다시 뷰 파일을 함수 형태로 수정하면 아래와 같다.
lists/views.py 파일
from django.shortcuts import render # Create your views here. def home_page(): pass
여전히 함수는 아무 일도 하지 않지만 적어도 함수 형태로 선언해서 호출 가능하기 때문에 다시 테스트를 실시하면 아래와 같이 통과하는 것을 확인할 수 있다.
$ python manage.py test Creating test database for alias 'default'... System check identified no issues (0 silenced). . ---------------------------------------------------------------------- Ran 1 test in 0.000s OK Destroying test database for alias 'default'...
이제 지금까지 작업한 내용을 커밋한다.
$ git diff $ git commit -am "First unit test and url mapping, dummy view"
-am 옵션으로 편리하게 메시지를 저장하고 파일을 추가하는 동시에 커밋할 수 있다. 편리하지만 반드시 커밋하기 전에 git diff와 git status로 반드시 변경사항을 확인한다.
뷰 함수 단위 테스트 작성
뷰 함수를 위한 단위 테스트 메소드로 test_home_page_returns_correct_html를 lists/tests.py 파일에 추가한다.
test_home_page_returns_correct_html 테스트 메소드는 시작 페이지 처리를 하는 home_page 뷰 함수가 올바른 내용의 HTML 문서를 반환하는지 확인한다.
from django.urls 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() response = home_page(request) html = response.content.decode('utf8') self.assertTrue(html.startswith('<html>')) self.assertIn('<title>일정관리</title>', html) self.assertTrue(html.endswith('</html>'))
그런데 테스트를 실시하면 home_page() 함수의 아규먼트 시그니처가 맞지 않다는 에러가 발생한다. 호출하는 쪽에서는 home_page() 함수에 request 변수를 전달하지만 막상 home_page() 함수에는 request 아규먼트가 선언되어 있지 않기 때문에 에러가 발생한다.
$ 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) TypeError: home_page() takes 0 positional arguments but 1 was given ---------------------------------------------------------------------- Ran 2 tests in 0.001s FAILED (errors=1) Destroying test database for alias 'default'...
단위 테스트 수정 및 반복 작업 수행
창을 두 개 띄워서 작업한다.
- 터미널에서는 테스트를 실시하고 어떻게 테스트가 실패했는지 확인한다.
- 에디터에서는 실패한 테스트를 통과시키기 위해 코드를 조금씩 수정해나간다.
home_page() 함수에 request 아규먼트 추가
앞서 home_page() 함수에 request 아규먼트를 갖고 있지 않았던 문제가 있었으므로 이 문제를 해결하기 위해 선언에 추가한다.
lists/views.py 파일 수정
from django.shortcuts import render # Create your views here. def home_page(request): pass
다시 테스트 실시해보면 content 속성을 반환하지 않는 것을 확인할 수 있다. 이는 home_page 함수로서 requeset 아규먼트도 갖고 있지만 막상 아무런 값도 반환하지 않기 때문에 발생하는 오류이다.
$ 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 16, in test_home_page_returns_correct_html html = response.content.decode('utf8') AttributeError: 'NoneType' object has no attribute 'content' ---------------------------------------------------------------------- Ran 2 tests in 0.001s FAILED (errors=1) Destroying test database for alias 'default'...
home_page() 함수 HTTP 응답 반환 처리
아무 값도 반환하지 않던 home_page() 함수가 응답 객체를 반환하도록 수정한다.
lists/views.py 파일
from django.http import HttpResponse from django.shortcuts import render # Create your views here. def home_page(request): return HttpResponse()
응답 객체를 반환하도록 코드를 수정했지만 테스트를 실시해보면 여전히 오류가 발생한다. 그 이유는 반환한 HTTP 응답이 <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 17, in test_home_page_returns_correct_html self.assertTrue(html.startswith('<html>')) AssertionError: False is not true ---------------------------------------------------------------------- Ran 2 tests in 0.001s FAILED (failures=1) Destroying test database for alias 'default'...
home_page() 함수 HTML 반환
원서에서는 <html>, <title>일정관리</title>, </html> 문자열 테스트를 하나씩 추가하면서 테스트를 실시한다. 여기서는 공간 절약을 위해 그냥 <html><title>일정관리</title></html> 문자열을 반환하도록 처리한다.
lists/views.py 파일
from django.http import HttpResponse from django.shortcuts import render # Create your views here. def home_page(request): return HttpResponse('<html><title>일정관리</title></html>')
다시 테스트를 실시하면 이제 아무런 문제 없이 두 개의 단위 테스트 모두 통과한다.
$ python manage.py test 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'...
이제 기능 테스트를 실시하면 브라우저 제목에 일정관리 문자열이 들어 있어서 기능 테스트의 첫 단계를 무사히 통과한 것을 확인할 수 있다.
$ 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.546s FAILED (failures=1)
지금까지 작업한 내역을 커밋한다.
$ git diff $ git commit -am "Basic view now returns minimal HTML"
요약 및 의견
단위 테스트를 추가하고 실행하는 방법을 알 수 있다.
기능 테스트에서 큰 틀을 잡고 이에 따라 단위 테스트를 만드는데 그 과정에서 실제 애플리케이션 코드를 조금씩 살을 붙여나간다. 그리고 계속 테스트를 실행하면서 반복적으로 작업한다.
메소드/함수의 단위 테스트에서 가장 기본이 되는 것은 함수의 시그니처, 파라미터 검증과 리턴값을 검증하는 것이다.
'Development > Python' 카테고리의 다른 글
5. 사용자 입력 저장 (데이터베이스 기반 테스트) (0) | 2019.07.28 |
---|---|
04. 테스트와 리팩토링 (0) | 2019.07.28 |
02. unittest 모듈과 기능 테스트 (0) | 2019.07.28 |
01. Django 그리고 기능 테스트 (0) | 2019.07.28 |
00. 준비하기 (0) | 2019.07.28 |