TDD with Python: 염소책01
- 참고
- TDD?
- with Pycharm & Git
- TDD 기초와 Django
- ✔책과 다르게 - 정규식 처리
- 4.4. On Refactoring
- 4.5. 프론트 페이지 추가
- 4.6. 복습 TDD
- 5. Testing the Database
- 5.1. Post 요청을 위한 Form
- 5.2. 서버에서 Post 요청 처리
- 5.3. 파이썬 변수를 템플릿에 전달하기
- ✔ Red/Green/Refactor and Triangulation
- 5.4. Refactor- DRY, 삼진아웃
- 5.5. Django ORM과 Model
- ✓ Unit Tests vs Integrated Tests, DB
- 5.6. POST를 DB로
- 5.7. Redirect After a POST
- 5.8. Rendering Items in the Template
- ✔ 테스트 구조화
- 5.9. DB생성과 migrate
- ✔ DB 초기화
- 5.10. 복습
- TDD 유용한 개념들
- 6. FT 개선 - Isolation 개선, time.sleep 제거
- 7. 점진적으로 작업하기
- TDD Philosophy
참고
1. 블로그, 책
-
Obey the Testing Goat! : Test-Driven Development with Python - 클린 코드를 위한 테스트 주도 개발(파이썬을 이용한)
- 이 포스트는 위 글을 주로 요약 정리한 내용입니다.
- 위키백과-테스트 주도 개발
- 이한영 블로그- 클린 코드를 위한 테스트 주도 개발 보조 자료
-
임재곤의 개발 블로그 - 이해하기 쉬운 테스트 개발 방법론(TDD) 설명!
- TDD 설명은 위 블로그의 내용을 요약 정리하였습니다. 이 포스트보다 더 좋습니다.
2. 관련 코드 Git
TDD?
- 린(Lean) 소프트웨어 개발론의 핵심 철학 중 하나는 “결함은 발견 즉시 해결”이다. 린 개발은 이것의 실천법으로 테스트 주도 개발(Test-Driven Development, TDD)을 제시한다.
- TDD - TDD는 반복 테스트을 이용한 소프트웨어 개발법이다. 작은 단위의 테스트 케이스를 작성하고 이를 통과하는 코드를 추가하는 단계를 반복하여 소프트웨어를 구현한다. TDD의 목표는 작동하는 깔끔한 코드 “Clean code that works” 이다.
-
TDD의 방법론 - TDD는 테스트 케이스를 생성한다. 테스트 케이스는 자동화된 테스트 도구로 이용되어, 코드 변경시 기존 기능이 제대로 동작하는지 쉽게 확인할 수 있고 정상 동작을 보장한다. 또한 TDD는 리펙토링을 개발 프로세스에 포함시켜 ‘변경’이라는 소프트웨어의 특성을 반영한다. ❶ 오직 자동화된 테스트가 실패할 경우에만 새로운 코드를 작성한다. ❷ 중복을 제거한다.
- TDD의 장점은 ❶ 개발자의 방향을 잃지 않게 유지시키며, ❷ 소프트웨어의 품질을 일정 이상 보장하고, ❸ 자동화 단위 케이스를 가지게 되어 즉시 검사가 가능하다. ❹ TDD를 염두하고 설계하므로 개발 의도와 목적이 명확해진다. ❺ 코드 실패가 두렵지 않고 리팩토링을 마음 편히 할 수 있으며, ❻ 개발 속도가 느려지지 않는다.
- TDD의 단점은 ➀ 동시성(Concurrency)과 보안 등 비기능적 요소에 접근하기 어렵고, ➁ MVC 패턴에서 의존성 모듈이 이루어지는 경우 TDD가 이루어지기 어렵다. ➂ 테스트 코드 작성이 어렵고, 나쁜 코드를 잘 찾을 수 있는 것은 아니다. 여전히 코딩 경험과 지식이 필요하다.
- 그럼에도 불구하고, 기존의 개발방식( 요구사항 분석 ➞ 설계 ➞ 개발 ➞ 테스트 ➞ 배포)에서 소비자의 요구사항이 처음부터 명확할 수 없으므로 처음부터 완벽한 설계는 어렵다는 한계를 TDD는 극복하려고 한다.
with Pycharm & Git
- Jetbrain 공식문서 : Test-Driven Development with Twisted
-
Git과 연계하여 TDD를 할 경우, commit 전에 새로운 코드에 대한 test가 마무리 되어야 한다.
- 관련 코드
TDD 기초와 Django
1. Getting Django Set Up Using a Functional Test
가. 기본환경 설정
0) 기본 환경
- OS환경은 Ubuntu16.04LTS이고, python 3.6.2버전, pycharm community 2016.3을 기준으로 하였다.
1) .gitignore
는 gitignore.io를 이용한다.
- 입력창에
Git
,Django
,Python
,Pycharm
을 입력한다.
- Create탭을 누르면, .gitignore에 들어갈 내용들을 찾아준다.
- 다음 내용들을 추가하여 위 내용을 .gitignore에 담는다.
# Custom
.idea/
.config_secret/
# Created by https://www.gitignore.io/api/git,django,python,pycharm
...
2) git init
➜ git init
/home/learn/projects/django/tdd01/.git/ 안의 빈 깃 저장소를 다시 초기화했습니다.
➜ git:(master) ✗
3) pyenv 가상환경
➜ pyenv virtualenv 3.6.2 TDD
➜ pyenv local TDD
(TDD) ➜ git:(master) ✗
4) pycharm 설정
-
interpreter 설정
[File] 탭 ➔ [Settings]탭 ➔ [Project] : [Project Interpreter] ➔ ⚙ ➔ [add local]
화살표 순으로 탭을 클릭하면 입력창이 나타난다.
- 입력창에 위 그림과 같이
/<path-to>/.pyenv/versions/<가상환경 이름>/bin/python
을 입력한다.
5) 현재까지 폴더의 구조
< project Container>
├─ .git/
├─ .gitignore
├─ .idea/
├─ .python-version
└─ requirements.txt
나. Selenium 설정과 FT 코드 작성
-
FT ( Functional Test )
-
설치 - selenium
➜ pip install selenium
Successfully installed selenium-3.8.1
- webdriver 다운로드 - ❶ Chrome ❷ PhantomJS
➜ sudo apt-get install phantomjs
# 시스템에 한글 폰트가 설치되어 있지 않는 경우
➜ sudo apt-get install fonts-unfonts-core
➜ sudo apt-get install fonts-unfonts-extra
- functional_tests.py
from selenium import webdriver
browser = webdriver.Chrome("path-to/크롬드라이버")
# 또는,
# browser = webdriver.PhantomJS("path-to/PhantomJS드라이버")
browser.get('http://localhost:8000')
assert 'Django' in browser.title
- 실행
➜ python functional_tests.py
Traceback (most recent call last):
File "functional_tests.py", line 6, in <module>
assert 'Django' in browser.title
AssertionError
__ 새로운 창(localhost:8000
)이 "Chrome이 자동화된 테스트 소프트웨어에 의해 제어되고 있습니다"라는 표시와 함께 뜨지만 아래 그림처럼 "사이트에 연결할 수 없음 (Unable to connect)" 표시가 뜬다. 나중에 코드를 고쳐서 위 error 메세지를 없앨 것이고, 여기에서는 functional_tests.py에 의해 selenium.webdriver가 작동하는지 확인할 뿐이다.
다. django 설치 및 확인
1) Django 설치
➜ pip install django
Installing collected packages: pytz, django
Successfully installed django-2.0.1 pytz-2017.3
2) 프로젝트 시작
- 여기에서는 책의 폴더 구조와 다르게 설정하였다.
- 책에서는
django-admin.py startproject superlists .
특히 마지막에.
을 찍었으나 여기에서는 찍지 않았다. .git
,.gitignore
등 프로젝트 외 설정 등을 프로젝트 폴더(여기,superlists/
) 밖에 위치시키고 싶었다. 책의 경우 프로젝트와 프로젝트 외 설정들이 컨테이너 폴더 안에서 혼잡해지는 느낌이 있다. 각자의 취향대로 폴더 구조를 설정하면 된다.
- 책에서는
➜ django-admin.py startproject superlists
< project Container> # 프로젝트 컨테이너 폴더
├─ .git/
├─ .gitignore
├─ .idea/
├─ .python-version
├─ requirements.txt
├─ functional_tests.py
└─ superlists/ # 프로젝트 폴더 ➝ source root로 만들어 준다.
├─ manage.py
└─ superlists # 같은 이름의 설정 폴더 ➝ config로 rename한다.
├─ __init__.py
├─ settings.py
├─ urls.py
└─ wsgi.py
이하 내용은 pycharm에서의 설정이다.
- source root - 프로젝트 폴더 superlists/를 source root로 만들기 - 현재 pycharm을 실행한 컨테이너 폴더가 source root로 설정되어 있는데, config/setting.py에서 인식해야 하는 root는 컨테이너 폴더가 아니라 프로젝트 폴더이기 때문에 source root를 새로 설정해야 한다.
- rename : 프로젝트 폴더명과 같은 설정 폴더
superlists
를config
로 리팩토링한다. 위 설정 폴더 이름을 참조하는 소스 코드 내용을 고려하여 리팩토링을 한다.- 해당 폴더에 오른쪽 마우스를 클릭하여 Refactor탭을 거쳐 Rename을 누르고,
- 입력창에 config라고 입력한후 Refactor탭을 누른다.
- 아래 창에서 Do Refactor탭을 누른다.
3) Django 프로젝트 실행 확인
- migrate 메시지는 현 단계에서는 무시한다.
➜ cd superlists
➜ ./manage.py runserver
Performing system checks...
System check identified no issues (0 silenced).
You have 14 unapplied migration(s). Your project may not work properly until you apply the migrations for app(s): admin, auth, contenttypes, sessions.
Run 'python manage.py migrate' to apply them.
January 27, 2018 - 17:10:08
Django version 2.0.1, using settings 'config.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
라. runserver 후 FT
- 별도의 command shell에서 다시 FT 테스트를 실행한다.
➜ python functional_tests.py
- 성공하면 아래와 같이 selenimum이 작동한다.
2. Extending Our Functional Test Using the unittest Module(예제)
- To-do lists 만들기
2.1. 기능 테스트(FT)로 실행 가능한 최소 To-Do app 범위 지정
-
Using a Functional Test to Scope Out a Minimum Viable App 참고
-
FT 테스트는 실제 웹 브라우저에서 application의 기능과 작동을 경험하는 사용자 관점의 테스트이다. 이는 acceptance test 또는 End-to-End test라고 불리기도 한다. 애플리케이션의 작동 범위를 상상하는 기능 테스트(FT- functional Test-
functional_tests.py
)의 이야기를 작성해보자.
from selenium import webdriver
browser = webdriver.Chrome('/path-to/chromedriver')
# 수지는 Web online에 꽤 쓸만한 To-do app이 있다는 얘기를 들었다. 그녀는 그 app의 홈페이지를 열어보았다.
browser.get('http://localhost:8000')
# 수지는 홈페이지 상단 title이 'To-do'임을 확인하였다.
assert 'To-Do' in browser.title
# 수지는 이 사이트에서 '해야할 일'을 바로 기입할 수 있었다.
# 그녀는 입력 텍스트 창에 "Buy peacock feathers"(공작깃털구입)을 입력하였다. 그녀의 취미는 루어낚시이다.
# 수지가 엔터를 누르자 페이지가 갱신되면서, "1: Buy peacock feathers"라는 항목이 to-do list에 나타난다.
# 페이지가 갱신되더라도 텍스트 입력창에 다른 할 일 항목을 기입할 수 있으므로, 수지는 "Use peacock feathers to make a fly"라고 입력하고 엔터를 친다.
# 그러자 페이지가 다시 갱신되고, to-do 목록에 위 2개의 항목이 입력되다.
# 수지는 이 사이트가 그녀가 입력한 to-do 목록을 기억하고 있는지 궁금하였다. 이 사이트는 그녀의 to-do목록을 위한 고유 URL을 생성하였고 이에 대한 설명문이 있다.
# 수지는 위 고유 URL을 방문하여 그녀의 to-do목록을 본다.
# 수지는 이에 만족하며 잠에 들었다.
browser.quit()
- 주석 달기는 매우 중요하지만 코드 변경에 맞춰 주석을 변경하지 않으므로, 주석 내용이 잘못되는 경우가 많다. 이상적인 코드는 읽기 쉽고, 좋은 변수 이름과 함수 이름을 사용하여, 더 이상 주석이 필요하지 않도록 잘 구조화한 것이다.
- 주석 달기의 유용성 - 코드 자체의 변경은 기능의 범위 내에서 이루어져야 하므로, 기능테스트에서 사용자 경험에 관한 주석을 다는 것은 일관된 스토리를 만들어 내어 사용자 관점에서 테스트하게 합니다. 이에 관하여 BDD(Behavior-Driven Development)가 있다.
- 위 코드 확인
➜ python functional_tests.py
Traceback (most recent call last):
File "functional_tests.py", line 9, in <module>
assert 'To-Do' in browser.title
AssertionError
__예상된 Error가 발생한다. browser.title이 현재 Django: the Web framework for perfectionists with deadlines. 인데 FT에서는 To-Do인지 여부를 묻고 있기 때문이다.
2.2. The Python Standard Library’s unittest Module
- 2.2 unittest Module -참조
- unittest module 공식문서
- FT테스트에서 아쉬웠던 browser.title의 내용을 알려주는 코드를 작성하자.
# functional_tests.py
# ...
assert 'To-Do' in browser.title, "Browser title was " + browser.title
- unittest module을 사용하여 try~finally 구문의 반복적 사용을 피해서 테스트 해보자.
from selenium import webdriver
import unittest
class NewVisitorTest(unittest.TestCase):
def setUp(self):
self.browser = webdriver.Chrome('/path-to/chromedriver')
def tearDown(self):
self.browser.quit()
def test_can_start_a_list_and_retrieve_it_later(self):
# 수지는 Web online에 꽤 쓸만한 To-do app이 있다는 얘기를 들었다. 그녀는 그 app의 홈페이지를 열어보았다.
self.browser.get('http://localhost:8000')
# 수지는 홈페이지 상단 title이 'To-do'임을 확인하였다.
self.assertIn('To-Do', self.browser.title)
self.fail('Finish the test!')
# 수지는 이 사이트에서 '해야할 일'을 바로 기입할 수 있었다.
#[...rest of comments as before]
if __name__ == '__main__':
unittest.main(warnings='ignore')
- Django에서는
LiveServerTestCase
로 테스트 하지만, 이것은 너무 복잡하므로 나중에 다루기로 한다. - 위 FT를 실행하면, 아래와 같이 FAIL이 정상적으로 나타난다.
➜ python functional_tests.py
F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "functional_tests.py", line 17, in test_can_start_a_list_and_retrieve_it_later
self.assertIn('To-Do', self.browser.title)
AssertionError: 'To-Do' not found in 'Django: the Web framework for perfectionists with deadlines.'
----------------------------------------------------------------------
Ran 1 test in 2.570s
FAILED (failures=1)
2.3. Commit & Useful TDD Concepts
-
git
➜ git status
➜ git add -A
➜ git diff
➜ git commit -m "~~~~~~~"
- Useful TDD Concepts
- User story - 응용 프로그램이 사용자의 관점에서 어떻게 작동하는지에 대한 설명. 기능 테스트를 구조화하는 데 사용됩니다.
- Expected failure - 우리가 예상했던대로 테스트가 실패했을 때.
3. 간단한 Unit Tests
-
지금부터 테스트가 좀 더 현실적이다.
3.1. first Django App, first Unit Test
-
Django App 만들기 - lists
➜ python manage.py startapp lists
.
├── .git/
├── .gitignore
├── .idea/
├── .python-version
├── requirements.txt
├── functional_tests.py
└── superlists
├── config
│ ├── __init__.py
│ ├── __pycache__
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── db.sqlite3
├── lists
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── migrations
│ ├── models.py
│ ├── tests.py
│ └── views.py
└── manage.py
3.2. Unit Tests - FT와 다른 점
__Unit test는 프로그래머의 관점에서 이루어지는 테스트이고, FT는 사용자 관점의 테스트이다. TDD 접근방법은 다음과 같습니다.
- functional test 작성하기 - 사용자 관점에서 새로운 기능들을 서술할 것(예: 수지의 앱 방문)
- 일단 functional test에서 실패하면 위 테스트를 통과할 만한 코드를 작성하고, 이 코드가 작동하는 법에 대한 unit test를 시도한다.
- unit test에서 실패하면 최소한의 application code를 작성하여 unit test를 통과하도록 한다. 위 2.단계와 3.단계를 반복하면서 functional test를 조금씩 진행한다.
3.3. Unit Testing in Django
Django는 standard unittest.TestCase
의 증강된 버전을 사용하지만 다음 단원에서 사용하기로 할 것이다. 이 단원은 TDD cycle을 보여주기 위해 일부러 실패한 코드를 테스트하여 unit test의 작동을 테스트할 것이다.
- 실패해야하는 lists/tests.py
from django.test import TestCase
class SmokeTest(TestCase):
def test_bad_maths(self):
self.assertEqual(1 + 1, 3)
- test - F가 뜬다. 잘 작동하고 있다.
➜ 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/learn/projects/django/tdd01/superlists/lists/tests.py", line 9, in test_bad_maths
self.assertEqual(1 + 1, 3)
AssertionError: 2 != 3
----------------------------------------------------------------------
Ran 1 test in 0.002s
FAILED (failures=1)
Destroying test database for alias 'default'...
- git commit
➜ git status
➜ git add superlists/lists
➜ git diff --staged
➜ git commit -m "Add app for lists, with deliberately failing unit test"
3.4. Django의 MVC, URLs, 그리고 View functions
Django는 Model-View-Controller (MVC) 패턴으로 구조화되었는데 이에 따른 Django의 workflow는 다음과 같다.
- HTTP request(특정 URL) ⟶ Django URL resolver ⟶ 특정 URL 대응 view function
여기서는 루트("/")에 대응하는 view function의 home_page를 테스트하기로 한다.
- lists/tests.py
from django.urls import resolve
from django.test import TestCase
from lists.views import home_page
class HomePageTest(TestCase):
def test_root_url_resolves_to_home_page_view(self):
found = resolove('/')
self.assertEqual( found.func, home_page )
# django.urls.resolve~ URL을 view function에 연결하는 역할
- test - ImportError는 당연한 것이다. views.py에 home_page가 없기 때문이다.
➜ python manage.py test
ImportError: cannot import name 'home_page'
3.5. views.home_page
작성, TraceBack 읽는 법
-
lists/views.py
from django.shortcuts import render
# Create your views here.
home_page = None
- test
➜ python manage.py test
E
======================================================================
➁ ERROR: test_root_url_resolves_to_home_page_view (lists.tests.HomePageTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/learn/projects/django/tdd01/superlists/lists/tests.py", line 10, in test_root_url_resolves_to_home_page_view
➂ found = resolve('/')
File "/home/learn/.pyenv/versions/3.6.2/envs/TDD/lib/python3.6/site-packages/django/urls/base.py", line 24, in resolve
return get_resolver(urlconf).resolve(path)
File "/home/learn/.pyenv/versions/3.6.2/envs/TDD/lib/python3.6/site-packages/django/urls/resolvers.py", line 523, in resolve
raise Resolver404({'tried': tried, 'path': new_path})
django.urls.exceptions.Resolver404: {'tried':
➀ [[<URLResolver <URLPattern list> (admin:admin) 'admin/'>]], 'path': ''}
---------------------------------------------------------------------
❶ Error 이름 자체가 뜨는 곳 - 가장 먼저 살펴보아야 할 부분이다. (ex) ImportError
❷ 어떤 test가 실패하였는지 알려준다.
❸ 실패한 test code를 알려준다.
3.6. urls.py
-
config/urls.py
from django.conf.urls import url
from lists import views
urlpatterns = [
url(r'^$', views.home_page, name='home'),
]
- test- 위와 다르게 Type Error가 나온다. 잘 진행되었다.
➜ python manage.py test
TypeError: view must be a callable or a list/tuple in the case of include().
- lists/views.py
from django.shortcuts import render
# Create your views here.
def home_page():
pass
- test
➜ python manage.py test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.002s
OK
Destroying test database for alias 'default'...
- git
➜ git diff
➜ git commit -am "First unit test and url mapping, dummy view"
3.7. Unit Testing a View
-
lists/tests.py
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_return_correct_html(self):
request = HttpRequest()
response = home_page(request)
html = response.content.decode('utf8')
self.assertTrue(html.startswith('<html>'))
self.assertIn('<title>To-Do lists</title>')
self.assertTrue(html.endswith('</html>'))
- test
➜ python manage.py test
======================================================================
ERROR: test_home_page_return_correct_html (lists.tests.HomePageTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/learn/projects/django/tdd01/superlists/lists/tests.py", line 16, in test_home_page_return_correct_html
response = home_page(request)
TypeError: home_page() takes 0 positional arguments but 1 was given
----------------------------------------------------------------------
-
- Minimal code change: lists/views.py
def home_page(request): pass
- Tests - home_page 내용이 없으므로 당연히 content가 없다.
➜ python manage.py test ====================================================================== ERROR: test_home_page_return_correct_html (lists.tests.HomePageTest) ---------------------------------------------------------------------- Traceback (most recent call last): File "/home/learn/projects/django/tdd01/superlists/lists/tests.py", line 17, in test_home_page_return_correct_html html = response.content.decode('utf8') AttributeError: 'NoneType' object has no attribute 'content' ----------------------------------------------------------------------
- Code: lists/views.py
from django.http import HttpResponse # Create your views here. def home_page(request): return HttpResponse()
- Tests again - HttpResponse 객체가 있으므로 content가 있으나 content에
<html>
로 시작되지 않음
➜ python manage.py test ====================================================================== FAIL: test_home_page_return_correct_html (lists.tests.HomePageTest) ---------------------------------------------------------------------- Traceback (most recent call last): File "/home/learn/projects/django/tdd01/superlists/lists/tests.py", line 18, in test_home_page_return_correct_html self.assertTrue(html.startswith('<html>')) AssertionError: False is not true ----------------------------------------------------------------------
- Code again: lists/views.py
def home_page(request): return HttpResponse('<html>')
- Tests
➜ python manage.py test ====================================================================== FAIL: test_home_page_returns_correct_html (lists.tests.HomePageTest) ---------------------------------------------------------------------- Traceback (most recent call last): File "/home/learn/projects/django/tdd01/superlists/lists/tests.py", line 19, in test_home_page_returns_correct_html self.assertIn('<title>To-Do lists</title>', html) AssertionError: '<title>To-Do lists</title>' not found in '<html>' ----------------------------------------------------------------------
- Code: lists/views.py
def home_page(request): return HttpResponse('<html><title>To-Do lists</title></html>')
- test 마지막
➜ 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'...
-
git
➜ git diff
➜ git commit -am "Basic view now returns minimal HTML"
➜ git log --oneline
4. 테스트를 하는 이유 (리팩토링)
4.1. Programming은 우물에서 물을 깃는 것
TDD는 훈련이고 자연스럽게 익힐 수 있는 것이 아닙니다. 이제까지의 테스트가 과도할 수 있으나 TDD 과정과 결과는 좋습니다.
4.2. 유저 관점 FT
-
functional_tests.py 실행해보기 - 에러 발생은 당연하다. localhost가 없다.
➜ python functional_tests.py
F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "functional_tests.py", line 17, in test_can_start_a_list_and_retrieve_it_later
self.assertIn('To-Do', self.browser.title)
AssertionError: 'To-Do' not found in 'localhost'
----------------------------------------------------------------------
- functional_tests.py 코드 추가 및 runserver
from selenium import webdriver
from selenium.webdriver.common.keys import Keys
import time
import unittest
class NewVisitorTest(unittest.TestCase):
def setUp(self):
self.browser = webdriver.Chrome('/home/learn/projects/crawler/driver/chromedriver')
def tearDown(self):
self.browser.quit()
def test_can_start_a_list_and_retrieve_it_later(self):
# 수지는 Web online에 꽤 쓸만한 To-do app이 있다는 얘기를 들었다. 그녀는 그 app의 홈페이지를 열어보았다.
self.browser.get('http://localhost:8000')
# 수지는 홈페이지 상단 title이 'To-do lists'임을 확인하였다.
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'), 'Enter a to-do item'
)
# 그녀는 입력 텍스트 창에 "Buy peacock feathers"(공작깃털구입)을 입력하였다. 그녀의 취미는 루어낚시이다.
inputbox.send_keys('Buy peacock feathers')
# 수지가 엔터를 누르자 페이지가 갱신되면서, "1: Buy peacock feathers"라는 항목이 to-do list에 나타난다.
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: Buy peacock feathers' for row in rows)
)
# 페이지가 갱신되더라도 텍스트 입력창에 다른 할 일 항목을 기입할 수 있으므로, 수지는 "Use peacock feathers to make a fly"라고 입력하고 엔터를 친다.
self.fail('Finish the test!')
# ...
- git
➜ git diff
➜ git commit -am "Functional test now checks we can input a to-do item"
4.3. The “Don’t Test Constants” Rule, and Templates to the Rescue
- 상수는 테스트 하지 않는다.
- **lists/templates/home.html **
<html>
<title>To-Do lists</title>
</html>
- lists/views.py
from django.shortcuts import render
def home_page(request):
return render(request, 'home.html')
- unit test -
lists/templates/
폴더를 못찾고 있다.
➜ python manage.py test
======================================================================
ERROR: test_home_page_returns_correct_html (lists.tests.HomePageTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/learn/projects/django/tdd01/superlists/lists/tests.py", line 16, in test_home_page_returns_correct_html
response = home_page(request)
File "/home/learn/projects/django/tdd01/superlists/lists/views.py", line 4, in home_page
return render(request, 'home.html')
...
django.template.exceptions.TemplateDoesNotExist: home.html
----------------------------------------------------------------------
- config/settings.py
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# 추가
'lists',
]
- unit test - 우리 눈에 잘 드러나지 않는 개행문자 (
\n
) 때문에 error가 발생할 수 있다. 이 경우self.assertTrue(html.strip().endswith('</html>'))
로 개행문자를 없애고 unit test를 실시해야 한다. pycharm에서 실행하였더니 개행문자 문제는 발생하지 않았다.
➜ python manage.py test
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.005s
OK
- 수동으로 텍스트를 직접 템플릿에 랜더링하여 unit test하기 - lists/tests.py
from django.template.loader import render_to_string
[...]
def test_home_page_return_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)
✔책과 다르게 - 정규식 처리
- 다음 에러는
lists/template/home.html
에{% csrf_token %}
코드를 집어 넣은 후 unit test(python manage.py test
)한 결과이다.
➜ python manage.py test
======================================================================
ERROR: test_home_page_returns_correct_html (lists.tests.HomePageTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/learn/projects/django/tdd01/superlists/lists/tests.py", line 21, in test_home_page_returns_correct_html
self.assertTemplateUsed(response, 'wrong.html')
ValueError: assertTemplateUsed() and assertTemplateNotUsed() are only usable on responses fetched using the Django test Client.
----------------------------------------------------------------------
test_home_page_returns_correct_html()
에서AssertionError
가 발생하였고,{% csrf_token %}
에 의해 생성된input
요소의 값이 매번 달라지기 때문에 발생한다.- lists/tests.py 해당 부분을 다음과 정규식으로 삭제처리해주면 정상작동한다.
# lists/tests.py
import re
class HomePageTest(TestCase):
pattern_input_csrf = re.compile(r'<input[^>]*csrfmiddlewaretoken[^>]*>')
...
def test_home_page_returns_correct_html(self):
request = HttpRequest()
response = home_page(request)
expected_html = render_to_string('home.html')
self.assertEqual(
re.sub(self.pattern_input_csrf, '', response.content.decode()),
re.sub(self.pattern_input_csrf, '', expected_html)
)
- 정상작동
➜ python manage.py test
..
----------------------------------------------------------------------
Ran 2 tests in 0.008s
OK
- 고의로 실패한 케이스를 테스트하기- lists/tests.py
self.assertTemplateUsed( response, 'wrong.html')
➜ python manage.py test
======================================================================
FAIL: test_uses_home_template (lists.tests.HomePageTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/learn/projects/django/tdd01/superlists/lists/tests.py", line 36, in test_uses_home_template
self.assertTemplateUsed(response, 'wrong.html')
AssertionError: False is not true : Template 'wrong.html' was not a template used to render the response. Actual template(s) used: home.html
----------------------------------------------------------------------
4.4. On Refactoring
- 리팩토링 하는 도중에 기능변경을 하여서는 안된다.
- 리팩토링 후 바로
git commit
은 좋은 방법이다.
➜ git status
➜ git add .
➜ git diff --staged
➜ git commit -m "Refactor home page view to use a template"
4.5. 프론트 페이지 추가
-
lists/templates/home.html
<html>
<head>
<title>To-Do lists</title>
</head>
<body>
<h1>Your To-Do list</h1>
<input id="id_new_item" placeholder="Enter a to-do item" />
</body>
</html>
- FT 실행 결과
➜ python functional_tests.py
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "functional_tests.py", line 36, in test_can_start_a_list_and_retrieve_it_later
any(row.text == '1: Buy peacock feathers' for row in rows)
File "functional_tests.py", line 36, in test_can_start_a_list_and_retrieve_it_later
any(row.text == '1: Buy peacock feathers' for row in rows)
AssertionError: False is not true
----------------------------------------------------------------------
- git
➜ git diff
➜ git commit -am "Front page HTML now generated from a template"
4.6. 복습 TDD
- 현재까지 이루어진 TDD 프로세스:
Functional tests
⟶Unit tests
⟶The unit-test/code cycle
⟶Refactoring
- The TDD process with functional and unit tests
5. Testing the Database
5.1. Post 요청을 위한 Form
-
lists/templates/home.html
<h1>Your To-Do list</h1>
<form method="POST">
<input name="item_text" id="id_new_item" placeholder="Enter a to-do item"
</form>
<table id="id_list_talble">
- FT 실행결과 - CSRF(Cross-Site Request Forgery exploit) - Django의 CSRF 보호는 POST 요청을 원본 사이트에서 온 것으로 식별 할 수 있도록 약간의 자동 생성 토큰을 생성 된 각 폼에 배치하는 작업을 포함합니다.
Ross Anderson - Security Engineering 무료 온라인- CSRF 설명
➜ python functional_tests.py
selenium.common.exceptions.NoSuchElementException: Message:no such element:
Unable to locate element:{"method":"id","selector":"id_list_table"}
- lists/templates/home.html
<form method="POST">
<input name="item_text" id="id_new_item" placeholder="Enter a to-do item"
{% csrf_token %}
</form>
- FT 다시 시작
➜ python functional_tests.py
AssertionError: False is not true : New to-do item did not appear in table
5.2. 서버에서 Post 요청 처리
-
lists/tests.py
➜ python manage.py test
======================================================================
FAIL: test_can_save_a_POST_request (lists.tests.HomePageTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/learn/projects/django/tdd01/superlists/lists/tests.py", line 32, in test_can_save_a_POST_request
self.assertIn('A new list item', response.content.decode())
AssertionError: 'A new list item' not found in '<html>\n <head>\n <title>To-Do lists</title>\n </head>\n <body>\n <h1>Your To-Do list</h1>\n <form method="POST">\n <input id="id_new_item" placeholder="Enter a to-do item" />\n <input type=\'hidden\' name=\'csrfmiddlewaretoken\' value=\'h6YR03ItmOiRmQQ6QjjgpqAnkT4WAKAzbXzzMFQ6vyD2mCDcAdGSrKgKC3pT5SoC\' />\n </form>\n <table id="id_list_table"></table>\n </body>\n</html>'
----------------------------------------------------------------------
- lists/views.py
from django.http import HttpResponse
from django.shortcuts import render
def home_page(request):
if request.method == 'POST':
return HttpResponse(request.POST['item_text'])
return render(request, 'home.html')
- unit test를 통과한다.
5.3. 파이썬 변수를 템플릿에 전달하기
-
lists/templates/home.html
<body>
<h1>Your To-Do list</h1>
<form method="POST">
<input name="item_text" id="id_new_item"
placeholder="Enter a to-do item" />
{% csrf_token %}
</form>
<table id="id_list_table">
<tr><td></td></tr>
</table>
</body>
- lists/tests.py
def test_can_save_a_POST_request(self):
response = self.client.post('/', data={'item_text':'A new list item'})
self.assertIn('A new list item', response.content.decode())
self.assertTemplateUsed(response, 'home.html')
- lists/views.py
def home_page(request):
context = {'new_item_text': request.POST['item_text']}
return render(request, 'home.html', context)
- unit test - request.POST 딕셔너리 key 'item_text'의 value가 없기 때문에 MultiValueDictKeyError가 발생한다.
➜ python manage.py test
django.utils.datastructures.MultiValueDictKeyError: 'item_text'
- lists/views.py 수정 - 위 에러를 고치기 위해
dictionary.get('key', 'defaultvalue')
구문을 사용한다. 이 구문은 key값이 없다면 defaultvalue를 리턴하는 의미를 가진다.
def home_page(request):
context = {'new_item_text': request.POST.get('item_text', '') }
return render(request, 'home.html', context)
- test - 이제 unit test는 통과하지만 FT는 그렇지 않다. 그러나 왜 FT에서 에러가 났는지 분명하게 알 수 있게 되었다.
➜ python manage.py test
..
----------------------------------------------------------------------
Ran 2 tests in 0.016s
OK
➜ python functional_tests.py
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "functional_tests.py", line 38, in test_can_start_a_list_and_retrieve_it_later
f"New to-do item did not appear in table. Contents were:\n{table.text}"
AssertionError: False is not true : New to-do item did not appear in table. Contents were:
Buy peacock feathers
----------------------------------------------------------------------
- functional_tests.py 수정
# 전
- self.assertTrue(
- any(row.text == '1: Buy peacock feathers' for row in rows),
- f"New to-do item did not appear in table. Contents were:\n{table.text}"
- )
#---------------------------------------------------------------------
+ self.assertIn('1: Buy peacock feathers', [row.text for row in rows])
- 수정 후 FT
➜ python functional_tests.py
AssertionError: '1: Buy peacock feathers' not found in ['Buy peacock feathers']
----------------------------------------------------------------------
- 오류가 난 곳을 알았으니 오류의 원인을 고친다. lists/templates/home.html
<table id="id_list_table">
<tr><td>1: </td></tr>
</table>
- 오류 고침 확인
➜ python functional_tests.py
AssertionError: Finish the test!
----------------------------------------------------------------------
✔ Red/Green/Refactor and Triangulation
- unit-test/code cycle은 때때로 Red, Green, Refactor로 가르친다.
- Red: unit test를 작성하고 실행하여 실패를 확인한다.
- Green: 성공할 수 있는 가장 간단한 코드를 짠다.
- Refactor: 합리적으로 더 나은 코드를 만든다.
- Refactor단계에서 합리적으로 더 나은 코드가 무엇일까?
- Eliminate duplication(중복 제거하기) - 갑자기 툭 집어놓은 상수(책에서는 magic constant이라고 표현한다. Green을s 위한 속임수이기 때문이다. )등을 없애고 코드가 잘 작동하도록 한다.
- 이어서 functional_tests.py 추가
# 페이지가 갱신되더라도 텍스트 입력창에 다른 할 일 항목을 기입할 수 있으므로, 수지는 "Use peacock feathers to make a fly"라고 입력하고 엔터를 친다.
inputbox = self.browser.find_element_by_id('id_new_item')
inputbox.send_keys('Use peacock feathers to make a fly')
inputbox.send_keys(Keys.ENTER)
time.sleep(1)
# 그러자 페이지가 다시 갱신되고, to-do 목록에 위 2개의 항목이 입력되다.
table = self.browser.find_element_by_id('id_list_table')
rows = table.find_elements_by_tag_name('tr')
self.assertIn('1: Buy peacock feathers', [row.text for row in rows])
self.assertIn('2: Use peacock feathers to make a fly',
[row.text for row in rows])
# 수지는 이 사이트가 그녀가 입력한 to-do 목록을 기억하고 있는지 궁금하였다. 이 사이트는 그녀의 to-do목록을 위한 고유 URL을 생성하였고 이에 대한 설명문이 있다.
self.fail('Finish the test!')
- FT - 예상되는 에러는 다음과 같다.
➜ python functional_tests.py
AssertionError: '1: Buy peacock feathers' not found in ['1: Use peacock feathers to make a fly']
----------------------------------------------------------------------
- git
➜ git diff
➜ git commit -am "commit before refactor"
5.4. Refactor- DRY, 삼진아웃
- DRY(Don't Repeat Yourself) ➜ Three strikes and refactor
- functional_tests.py에서 반복되는 부분
table = self.browser.find_element_by_id('id_list_table')
rows = table.find_elements_by_tag_name('tr')
self.assertIn('1: Buy peacock feathers', [row.text for row in rows])
self.assertIn('2: Use peacock feathers to make a fly',
[row.text for row in rows])
- 위 부분을 별도의 함수로 구현하여 refactor하자.
tests.py
에서test_
로 시작하는 메서드만 테스트로 실행되므로 그 외 함수는 자신의 목적에 따라 쓸 수 있다.
...
def check_for_row_in_list_table(self, row_text):
table = self.browser.find_element_by_id('id_list_table')
rows = table.find_elements_by_tag_name('tr')
self.assertIn(row_text, [row.text for row in rows])
...
- refactoring: 위 함수를 이용하여 반복을 피해보자
# functional_tests.py
## def test_can_start_a_list_and_retrieve_it_later(self):
### 수지가 엔터를 누르자 페이지가 갱신되면서, "1: Buy peacock feathers"라는 항목이 to-do list에 나타난다.
inputbox.send_keys(Keys.ENTER)
time.sleep(1)
self.check_for_row_in_list_table('1: Buy peacock feathers')
### 페이지가 갱신되더라도 텍스트 입력창에 다른 할 일 항목을 기입할 수 있으므로, 수지는 "Use peacock feathers to make a fly"라고 입력하고 엔터를 친다.
inputbox = self.browser.find_element_by_id('id_new_item')
inputbox.send_keys('Use peacock feathers to make a fly')
inputbox.send_keys(Keys.ENTER)
time.sleep(1)
### 그러자 페이지가 다시 갱신되고, to-do 목록에 위 2개의 항목이 입력되다.
self.check_for_row_in_list_table('1: Buy peacock feathers')
self.check_for_row_in_list_table('2: Use peacock feathers to make a fly')
time.sleep(1)
### 수지는 이 사이트가 그녀가 입력한 to-do 목록을 기억하고 있는지 궁금하였다. 이 사이트는 그녀의 to-do목록을 위한 고유 URL을 생성하였고 이에 대한 설명문이 있다.
self.fail('Finish the test!')
- refactoring 결과는 앞의 결과와 같다.
➜ python functional_tests.py
AssertionError: '1: Buy peacock feathers' not found in ['1: Use peacock feathers to make a fly']
----------------------------------------------------------------------
5.5. Django ORM과 Model
-
ORM ( Object-Relational Mapper ) 이전에 TDD 절차에 따라 unit test를 먼저 작성한다.
# lists/tests.py
from lists.models import Item
class ItemModelTest(TestCase):
def test_saving_and_retrieving_items(self):
first_item = Item()
first_item.text = 'The first (ever) list item'
first_item.save()
second_item = Item()
second_item.text = 'Item the second'
second_item.save()
saved_items = Item.objects.all()
self.assertEqual(saved_items.count(), 2)
first_saved_item = saved_items[0]
second_saved_item = saved_items[1]
self.assertEqual(first_saved_item.text,'The first (ever) list item')
self.assertEqual(second_saved_item.text, 'Item the second')
✓ Unit Tests vs Integrated Tests, DB
- unit test는 소스 코드만 테스트해야 할 뿐 DB를 건드려서는 안된다는 주장 - 나중에 판단하자.
- lists/models.py
from django.db import models
class Item(models.Model):
pass
- 첫번째 DB Migration
➜ python manage.py makemigrations
Migrations for 'lists':
lists/migrations/0001_initial.py
- Create model Item
➜ ls lists/migrations
0001_initial.py __init__.py __pycache__
- The Test Gets Surprisingly Far - test 절차가 상당히 길다.
➜ python manage.py test lists
self.assertEqual(first_saved_item.text, 'The first list item')
AttributeError: 'Item' object has no attribute 'text'
----------------------------------------------------------------------
- lists/models.py
class Item(models.Model):
text = models.TextField()
- unit test - A New Field Means a New Migration - 위 TextField()를 추가하였음에도 마이그레이션 없이 unit test를 실행하면 아래와 같은 에러가 발생한다.
➜ python manage.py test lists
...
django.db.utils.OperationalError: no such column: lists_item.text
- makemigrations - 마이그레이션을 하더라도 null 처리에 대한 수정이 필요하다.
➜ python manage.py makemigrations
You are trying to add a non-nullable field 'text' to item without a default; we can't do that (the database needs something to populate existing rows).
Please select a fix:
1) Provide a one-off default now (will be set on all existing rows with a null value for this column)
2) Quit, and let me add a default in models.py
Select an option: 2
- lists/models.py
class Item(models.Model):
text = models.TextField(default='')
- makemigrations
➜ python manage.py makemigrations
Migrations for 'lists':
lists/migrations/0002_item_text.py
- Add field text to item
- git
➜ git status
➜ git diff
➜ git add lists
➜ git commit -m "Model for list Items and associated migration"
5.6. POST를 DB로
- lists/tests.py
def test_can_save_a_POST_request(self):
response = self.client.post('/', data={'item_text':'A new list item'})
self.assertEqual(Item.objects.count(), 1)
new_item = Item.objects.first()
self.assertEqual(new_item.text, 'A new list item')
self.assertIn('A new list item', response.content.decode())
self.assertTemplateUsed(response, 'home.html')
# objects.count() ~ objects.all().count() 축약 표현
- unit test
➜ python manage.py test lists
======================================================================
FAIL: test_can_save_a_POST_request (lists.tests.ItemModelTest)
----------------------------------------------------------------------
self.assertEqual(Item.objects.count(), 1)
AssertionError: 0 != 1
----------------------------------------------------------------------
- lists/views.py
from django.shortcuts import render
from lists.models import Item
def home_page(request):
item = Item()
item.text = request.POST.get('item_text', '')
item.save()
context = {'new_item_text':request.POST.get('item_text', '')}
return render(request, 'home.html', context)
- 메모 - 필요한 기능들
- Don't save blank items for every request - 모든 요청에 대해 빈 항목으로 저장하지 않기
- Code smell: POST test is too long? - 리팩토링
- Display multiple items in the table - 여러 아이템들을 보여줘야 함
- Support more than one list! - 하나 이상의 리스트
- unit test 추가 - lists/tests.py
class HomePageTest(TestCase):
[...]
def test_only_saves_items_when_necessary(self):
self.client.get('/')
self.assertEqual(Item.objects.count(), 0)
- unit test - 여전히 같은 에러가 발생한다.
➜ python manage.py test lists
---------------------------------------------------------------------
AssertionError: 1 != 0
======================================================================
- lists/views.py
def home_page(request):
if request.method == 'POST':
new_item_text = request.POST['item_text']
Item.objects.create(text=new_item_text)
else:
new_item_text = ''
context = {'new_item_text': new_item_text}
return render(request, 'home.html', context)
- unit test - 성공
➜ python manage.py test lists
.......
----------------------------------------------------------------------
Ran 7 tests in 0.038s
5.7. Redirect After a POST
-
Always redirect after a POST - lists/tests.py
# lists/tests.py
def test_can_save_a_POST_request(self):
response = self.client.post('/', data={'item_text':'A new list item'})
self.assertEqual(Item.objects.count(), 1)
new_item = Item.objects.first()
self.assertEqual(new_item.text, 'A new list item')
self.assertEqual(response.status_code, 302)
self.assertEqual(response['location'], '/')
- unit test
➜ python manage.py test lists
AssertionError: 200 != 302
----------------------------------------------------------------------
- lists/views.py
from django.shortcuts import redirect, render
from lists.models import Item
def home_page(request):
if request.method == 'POST':
Item.objects.create(text=request.POST['item_text'])
return redirect('/')
return render(request, 'home.html')
- unit test
➜ python manage.py test lists
......
----------------------------------------------------------------------
Ran 6 tests in 0.032s
- 리팩토링 - Better Unit Testing Practice: Each Test Should Test One Thing
# lists/tests.py
def test_can_save_a_POST_request(self):
self.client.post('/', data={'item_text':'A new list item'})
self.assertEqual(Item.objects.count(), 1)
new_item = Item.objects.first()
self.assertEqual(new_item.text, 'A new list item')
def test_redirects_after_POST(self) :
response = self.client.post('/', data={'item_text':'A new list item'})
self.assertEqual(response.status_code, 302)
self.assertEqual(response['location'], '/')
- unit test는 통과한다.
5.8. Rendering Items in the Template
-
메모 - 필요한 기능들
Don't save blank items for every request - 모든 요청에 대해 빈 항목으로 저장하지 않기Code smell: POST test is too long? - 리팩토링- Display multiple items in the table - 여러 아이템들을 보여줘야 함
- Support more than one list ! - 하나 이상의 리스트
✔ 테스트 구조화
- Setup/ Exercise/ Assert
- lists/tests.py
class HomePageTest(TestCase):
[...]
def test_displays_all_list_items(self):
# Setup
Item.objects.create(text='itemey 1')
Item.objects.create(text='itemey 2')
# Exercise
response = self.client.get('/')
# Assert
self.assertIn('itemey 1', response.content.decode())
self.assertIn('itemey 2', response.content.decode())
- unit test- 다음과 같은 에러가 발생한다.
➜ python manage.py test lists
FAIL: test_displays_all_list_items (lists.tests.ItemModelTest)
----------------------------------------------------------------------
AssertionError: 'itemey 1' not found in '<html>\n <head>\n <title>To-Do lists</title>\n </head>\n <body>\n <h1>Your To-Do list</h1>\n <form method="POST">\n <input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />\n <input type=\'hidden\' name=\'csrfmiddlewaretoken\' value=\'VsbQNFR6adeXHvOS38RgddnoQRu3U2eEEb4jJikHJTtGSZeCagUQNLUKJsIPHBg1\' />\n </form>\n <table id="id_list_table">\n <tr><td>1: </td></tr>\n </table>\n </body>\n</html>'
----------------------------------------------------------------------
- lists/templates/home.html
<table id="id_list_table">
{% for item in items %}
<tr><td>1: {{ item.text }}</td></tr>
{% endfor %}
</table>
- lists/views.py
def home_page(request):
if request.method == 'POST':
Item.objects.create(text=request.POST['item_text'])
return redirect('/')
items = Item.objects.all()
context = { 'items': items }
return render(request, 'home.html', context)
- unit test를 통과한다. 이제 FT를 보자. POST로 생성한 데이터에 대해서 마이그레이션을 하지 않았기 때문이다
➜ python functional_tests.py
AssertionError: 'To-Do' not found in 'OperationalError at /'
----------------------------------------------------------------------
5.9. DB생성과 migrate
-
migrate
➜ python manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, lists, sessions
Running migrations:
Applying contenttypes.0001_initial... OK
...
Applying sessions.0001_initial... OK
- FT: 에러의 원인이 드러난다. 템플릿을 수정하자.
➜ python functional_tests.py
AssertionError: '2: Use peacock feathers to make a fly' not found in ['1: Buy peacock feathers', '1: Use peacock feathers to make a fly']
----------------------------------------------------------------------
- lists/templates/home.html
- FT - 성공 !
➜ python functional_tests.py
AssertionError: Finish the test!
----------------------------------------------------------------------
✔ DB 초기화
- DB 초기화를 하는 이유는 위 그림처럼 같은 스케쥴(Buy peacock feathers, Use peacock feathers to make a fly)이 반복되기 때문이다.
➜ rm db.sqlite3
➜ python manage.py migrate --noinput
Operations to perform:
Apply all migrations: admin, auth, contenttypes, lists, sessions
Running migrations:
Applying contenttypes.0001_initial... OK
...
Applying lists.0002_item_text... OK
Applying sessions.0001_initial... OK
- git
➜ git add lists
➜ git commit -m "Redirect after POST, and show all items in template"
5.10. 복습
-
복습 내용 : ❶ POST 양식 만들기 ➝ ❷ DB설정 ➝ ❸ test DB vs 실제 DB, 마이그레이션 ➝ ❹ Django template tags {% csrf_token %} 과 {% for … endfor %} ➝❺ FT 디버깅 기술: in-line print statements / time.sleeps / 에러 메세지 개선
-
메모 - 필요한 기능들
Don't save blank items for every request - 모든 요청에 대해 빈 항목으로 저장하지 않기Code smell: POST test is too long? - 리팩토링Display multiple items in the table - 여러 아이템들을 보여줘야 함- Clean up after FT runs
- Support more than one list !
TDD 유용한 개념들
- Regression - When new code breaks some aspect of the application which used to work (새로운 코드가 작동하는 데 사용되는 응용 프로그램의 일부 측면을 망칠 때)
- Unexpected failure - When a test fails in a way we weren’t expecting. This either means that we’ve made a mistake in our tests, or that the tests have helped us find a regression, and we need to fix something in our code ( 예상하지 못한 실패 - 테스트를 통해 찾아내었다면 매우 바람직한 것임.)
- **Red / Green / Refactor ** - TDD process
- Tri-angulation
- Three strikes and refactor : 중복 제거 규칙
- The scratchpad to-do list : 코딩 할 때 해야할 일
6. FT 개선 - Isolation 개선, time.sleep 제거
- The scratchpad to-do list
- Clean up after FT runs
- Remove time.sleeps
6.1. FT의 독립성 보장
Django unit test에서는 Django가 알아서 테스트용 DB를 생성하므로 각각의 테스트가 끝나면 DB가 자동으로 데이터를 삭제해준다.
그러나 FT는 실제 DB를 사용하므로 각각의 테스트에서 데이터 가 쌓인다. 이렇게 되면 각각의 FT는 이전 FT들의 결과에 영향을 받게 된다.
따라서 FT의 독립성을 보장하기 위한 방법으로 테스트 이후를 테스트 이전으로 감는 롤업이 필요한데 functional_tests.py의 setUp과 tearDown 메서드가 이를 수행한다.
Django의 LiveServerTestCase
가 이러한 역할을 잘 수행하지만 한계가 있다. LiveServerTestCase는 manage.py로 Django test runner를 작동시킨다. 그리고 이 test runner는 test로 시작하는 명칭의 파일을 자동으로 찾아 테스트를 수행한다.
- FT 패키지 만들기 - manage.py가 위치한 곳으로 옮긴다. Django의 LiveServerTestCase를 사용하기 위해서다. 그러나 lists App밖에 있어야 한다. 사용자 관점의 test와 프로그램 관점의 test를 분리하는게 나아 보인다.
➜ cd superlists
➜ mkdir functional_tests
➜ touch functional_tests/__init__.py
➜ git mv functional_tests.py functional_tests/tests.py
➜ git status
- 폴더 구조 확인
.
├── config
│ ├── __init__.py
│ ├── __pycache__
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── db.sqlite3
├── functional_tests
│ ├── __init__.py
│ └── tests.py
├── lists
│ ├── __init__.py
│ ├── __pycache__
│ ├── admin.py
│ ├── apps.py
│ ├── migrations
│ ├── models.py
│ ├── templates
│ ├── tests.py
│ └── views.py
└── manage.py
- LiveServerTestCase를 위한 functional_tests/tests.py 코드 수정 - 이 코드의 마지막
삭제한다. 이제 Django test runner가 FT를 작동시킬 것이기 때문이다.if __name__ == '__main__'
from django.test import LiveServerTestCase
from selenium import webdriver
from selenium.webdriver.common.keys import Keys
import time
class NewVisitorTest(LiveServerTestCase):
[...]
def test_can_start_a_list_and_retrieve_it_later(self):
# 수지는 Web online에 꽤 쓸만한 To-do app이 있다는 얘기를 들었다. 그녀는 그 app의 홈페이지를 열어보았다.
## 홈페이지 확인
self.browser.get(self.live_server_url)
- FT 시범 작동 - 이제 test database가 생성되어 test를 수행한 후 다시 사라진다.
➜ python manage.py test functional_tests
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later (functional_tests.tests.NewVisitorTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File ".../superlists/functional_tests/tests.py",
line 52, in test_can_start_a_list_and_retrieve_it_later
self.fail('Finish the test!')
AssertionError: Finish the test!
----------------------------------------------------------------------
Ran 1 test in 8.561s
FAILED (failures=1)
Destroying test database for alias 'default'...
- git
➜ git status
➜ git add functional_tests
➜ git diff --staged -M
➜ git commit -m "make functional_tests an app, use LiveServerTestCase"
- Running Just the Unit Tests - Unit test만 실행하도록 하기 - 이제
./manage.py test
를 하면 Django는 FT와 unit test 모두 수행한다. 따라서 Unit Test만 수행하려면./manage.py test lists
라고 해야 한다.
➜ python manage.py test lists
........
----------------------------------------------------------------------
Ran 8 tests in 0.042s
OK
Destroying test database for alias 'default'...
➜ python manage.py test lists
........F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later (functional_tests.tests.NewVisitorTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/learn/projects/django/tdd01/superlists/functional_tests/tests.py", line 52, in test_can_start_a_list_and_retrieve_it_later
self.fail('Finish the test!')
AssertionError: Finish the test!
----------------------------------------------------------------------
Ran 9 tests in 8.853s
FAILED (failures=1)
Destroying test database for alias 'default'...
6.2. 첨언: Update Selenium과 Chromedriver
- 먼저 selenium을 upgrade 설치한다.
➜ pip install --upgrade selenium
- chromedriver를 download gksek.
- path를 설정한다.
6.3. time.sleeps 제거 등
-
explicit wait 예 - functional_tests/tests.py - Selenium 3가 추천하는 명시적 대기는 어느 시간 동안 대기하는 것이 효율적인지에 대해 대처할 수 없다는 단점이 있다.
# 수지가 엔터를 누르자 페이지가 갱신되면서, "1: Buy peacock feathers"라는 항목이 to-do list에 나타난다.
inputbox.send_keys(Keys.ENTER)
time.sleep(1)
self.ckeck_for_row_in_list_table('1: Buy peacock feathers')
-
implicit wait - 페이지 로딩 과정이나 관련 모듈을 사용하여 암시적 대기를 할 수 있다. 그러나 Selenium 팀의 일반적인 견해는 암시적 대기가 단점이 많으므로 그 사용을 피하길 권고한다.
-
수정 functional_tests/tests.py
from selenium.common.exceptions import WebDriverException
MAX_WAIT = 10
[...]
class NewVisitorTest(LiveServerTestCase):
[...]
# def check_for_row_in_list_table(self, row_text):
def wait_for_row_in_list_table(self, row_text):
start_time = time.time()
while True:
try:
table = self.browser.find_element_by_id('id_list_table')
rows = table.find_elements_by_tag_name('tr')
self.assertIn(row_text, [row.text for row in rows])
return
except (AssertionError, WebDriverException) as e:
if time.time() - start_time > MAX_WAIT:
raise e
time.sleep(0.5)
[...]
def test_can_start_a_list and retrieve_it_later(self):
[...]
# 수지가 엔터를 누르자 페이지가 갱신되면서, "1: Buy peacock feathers"라는 항목이 to-do list에 나타난다.
inputbox.send_keys(Keys.ENTER)
time.sleep(1)
self.wait_for_row_in_list_table('1: Buy peacock feathers')
[...]
# 그러자 페이지가 다시 갱신되고, to-do 목록에 위 2개의 항목이 입력되다.
self.wait_for_row_in_list_table('2: Use peacock feathers to make a fly')
self.wait_for_row_in_list_table('1: Buy peacock feathers')
[...]
- tests - 수정 이후 실행시간이 반으로 줄었다. ( 8.881s ➝ 4.285s )
# 수정 이전
➜ python manage.py test
AssertionError: Finish the test!
----------------------------------------------------------------------
Ran 9 tests in 8.881s
# 수정 이후
➜ python manage.py test
AssertionError: Finish the test!
----------------------------------------------------------------------
Ran 9 tests in 4.285s
- 복습
- FT 개선 - LiveServerTestCase
- time.sleep 없애기 - ad hoc
- Selenium의 implicit wait에 의존하지 않을 것
7. 점진적으로 작업하기
7.1. 필요할 때, 작은 디자인
- Not Big Design Up Front - TDD는 agile과 관련되어 있음로 전통적인 Big Design Up Front 소프트웨어 개발 방법에 반대한다.
- YAGNI - You Ain't Gonna Need It
- REST (ish) - MVC ( Model - View - Controller ) 패턴으로 웹개발이 이루어지므로 REST (REpresentational State Transfer)에 따라 URL 구성이 이루어져야 한다.
# GET
/lists/<list identifier>/
# POST
/lists/new
# 기존 리스트에 아이템 추가 POST
/lists/<list identifier>/add_item
- 앞으로 할 일 메모 (scratchpad)
- Adjust model so that items are associated with different lists
- Add unique URLs for each list
- Add a URL for creating a new list via POST
- Add URLs for adding a new item to an existing list via POST
7.2. TDD에서 디자인 추가하기
- 새 디자인을 하면 TDD에서 기존 test를 수정해야 한다. - 새 기능을 추가하고 리팩토링을 한다.
- 기존 테스트를 깨트리는 것에 주저해서는 안된다. 물론 기존 테스트는 새 테스트를 작성하는데 차용할 수 있다.
7.3. Regression Test 보장하기
-
보장 - Regression Test - 수지 이외 다른 사람의 to-do list를 만들어보자. 이 경우 각 to-do list마다 고유의 URL을 보장해야 한다.
- assertRegex는 unittest 모듈의 helper function인데 REST하게 디자인 되었다.
# functional_tests/tests.py
def test_can_start_a_list_for_one_user(self):
# 수지는 ...
[...]
def test_multiple_users_can_start_lists_at_different_urls(self):
# 수지는 새로운 to-do list를 시작한다.
self.browser.get(self.live_server_url)
inputbox = self.browser.find_element_by_id('id_new_item')
inputbox.send_keys('Buy peacock feathers')
inputbox.send_keys(Keys.ENTER)
self.wait_for_row_in_list_table('1: Buy peacock feathers')
# 수지는 그녀의 리스트에 고유한 URL을 확인한다.
soosie_list_url = self.browser.current_url
self.assertRegex(soosie_list_url, '/lists/.+')
- 새로운 사용자가 수지의 to-do list를 보지 못하게 막고 고유의 URL을 부여하기
# functional_tests/tests.py
[...]
self.assertRegex(susie_list_url, '/lists/.+')
# 새로운 사용자 길동이 이 사이트에 들어왔다.
## 우리는 수지의 정보가 cookies로 전달되지 않도록 새로운 session을 사용한다.
self.browser.quit()
self.browser = webdriver.Chrome(path-to/크롬드라이버)
# 길동은 홈페이지를 방문하자 수지의 리스트는 보이지 않는다.
self.browser.get(self.live_server_url)
page_next = self.browser.find_element_by_tag_name('body').text
self.assertNotIn('Buy peacock feathers', page_text)
self.assertNotIn('make a fly', page_text)
# 길동은 새로운 항목을 기입하면서 새로운 to-do list를 시작한다.
# 그는 수지보다 관심이 없다.
inputbox = self.browser.find_element_by_id('id_new_item')
inputbox.send_keys('Buy milk')
inputbox.send_keys(Keys.ENTER)
self.wait_for_row_in_list_table('1: Buy milk')
# 길동은 자신의 리스트에 대한 고유의 URL을 확인한다.
gilDong_list_url = self.browser.current_url
self.assertRegex(gilDong_list_url, '/lists/.+')
self.assertNotEqual(gilDong_list_url, soosie_list_url)
# 길동은 수지의 리스트를 볼 수 없다.
page_text = self.browser.find_element_by_tag_name('body').text
self.assertNotIn('Buy peacock feathers', page_text)
self.assertIn('Buy milk', page_text)
# 만족하고, 수지와 길동은 잠에 든다.
- FT - 첫번째 테스트는 통과했지만 두번째 테스트는 예상대로 실패한다.
➜ python manage.py test functional_tests
[...]
======================================================================
FAIL: test_multiple_users_can_start_lists_at_different_urls (functional_tests.tests.NewVisitorTest)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/home/learn/projects/django/tdd01/superlists/functional_tests/tests.py", line 79, in test_multiple_users_can_start_lists_at_different_urls
self.assertRegex(soosie_list_url, '/lists/.+')
AssertionError: Regex didn't match: '/lists/.+' not found in 'http://localhost:42101/'
----------------------------------------------------------------------
Ran 2 tests in 8.196s
7.4. 새 디자인을 향한 반복
-
새 디자인을 위해 TDD는 기존 테스트를 반복한다. 그 과정에서 테스트 코드를 수정하고 리팩토링을 한다.
- Obey the Testing Goat, not Refactoring Cat !
Regexp did't match
인 FT에서 고유 URL을 식별할 수 있게 한다. - URL은 POST 후 리다이렉션에서 오기 때문에 lists/tests.py의test_redirects_after_POST
함수에서 리다이렉션 위치를 바꾼다. URL은 나중에 RESTful하게 바꿀 예정이다.
self.assertEqual(response.status_code, 302)
self.assertEqual(response['location'], '/lists/the-only-list-in-the-world/')
- unit test
➜ python manage.py test lists
======================================================================
[...]
AssertionError: '/' != '/lists/the-only-list-in-the-world/'
- lists/views.py
def home_page(request):
if request.method == 'POST':
Item.objects.create(text=request.POST['item_text'])
return redirect('/lists/the-only-list-in-the-world/')
items = Item.objects.all()
context = {'items': items}
return render(request, 'home.html', context)
- FT는 실패할 것이다. 이 사이트에 그러한 URL이 없기 때문이다.
➜ python manage.py test functional_tests
[...]
File "/home/learn/projects/django/tdd01/superlists/functional_tests/tests.py", line 51, in test_can_start_a_list_for_one_user
self.wait_for_row_in_list_table('1: Buy peacock feathers')
[...]
selenium.common.exceptions.NoSuchElementException: Message: no such element: Unable to locate element: {"method":"id","selector":"id_list_table"}
7.5. 단계1- One New URL
-
lists/tests.py - 새 클래스 ListViewTest - HomePageTest의 메서드
test_displays_all_list_items
를 복사하여 여기에 붙인다.
class ListViewTest(TestCase):
def test_displays_all_items(self):
Item.objects.create(text='itemey 1')
Item.objects.create(text='itemdy 2')
response = self.client.get('/lists/the-only-list-in-the-world/')
self.assertContains(response, 'itemey 1')
self.assertContains(response, 'itemey 2')
- unit test - assertContains는 새 URL이 없으므로 404로 리턴하였다는 내용을 알려주는 장점이 있습니다.
➜ python manage.py test lists
self.assertContains(response, 'itemey 1')
AssertionError: 404 != 200 : Couldn't retrieve content: Response code was 404 (expected 200)
----------------------------------------------------------------------
- A New URL - superlists / urls.py
urlpatterns = [
url(r'^$', views.home_page, name='home'),
url(r'^lists/the-only-list-in-the-world/$', views.view_list, name='view_list')
]
- unit test
➜ python manage.py test lists
AttributeError: module 'lists.views' has no attribute 'view_list'
- A New View Function - lists/views.py
def view_list(request):
items = Item.objects.all()
context = {'items':items}
return render(request, 'home.html', context)
- FT- 2번째 아이템을 추가할 때 실패한다. 작동하는 코드를 만들어보자. home page가 잘 작동( urls - views - template - POST request )하므로, POST request를 URL로 잘 연결해야 한다.
➜ python manage.py test functional_tests
FAIL: test_can_start_a_list_for_one_user
AssertionError: '2: Use peacock feathers to make a fly' not found in ['1: Buy peacock feathers']
[...]
======================================================================
FAIL: test_multiple_users_can_start_lists_at_different_urls
AssertionError: 'Buy peacock feathers' unexpectedly found in 'Your To-Do list\n1: Buy peacock feathers'
[...]
7.6. 단계2- Green? Refactor
-
unit test를 통과하여 Green단계가 되면 리팩토링을 한다. 현재 2개의 view (home page, individual list)가 있지만 같은 템플릿(home.html)을 사용하여 DB에 저장된 list 항목들을 드러내고 있다. 리팩토링을 할 단계이다.
- 삭제
HomePageTest - def test_displays_all_list_items
- 필요 없게됨 - unit test 결과: 테스트 개수가 9 ⟶ 8개
- 삭제
➜ grep -E "class|def" lists/tests.py
class HomePageTest(TestCase):
def test_root_url_resolves_to_home_page_view(self):
def test_home_page_returns_correct_html(self):
def test_uses_home_template(self):
# def test_displays_all_list_items(self):
def test_can_save_a_POST_request(self):
def test_redirects_after_POST(self):
def test_only_saves_items_when_necessary(self):
class ListViewTest(TestCase):
def test_displays_all_items(self):
class ItemModelTest(TestCase):
def test_saving_and_retrieving_items(self):
➜ python manage.py test lists
----------------------------------------------------------------------
Ran 8 tests in 0.046s
OK
7.7. 단계3- 템플릿 분리
-
views.py에 맞춰 템플릿을 분리하자
-
lists/tests.py
class ListViewTest(TestCase):
def test_uses_list_template(self):
response = self.client.get('/lists/the-only-list-in-the-world/')
self.assertTemplateUsed(response, 'list.html')
def test_displays_all_items(self):
[...]
- unit test의 결과는 당연히 실패이다. 아직 list.html이 없다.
➜ python manage.py test lists
AssertionError: False is not true : Template 'list.html' was not a template used to render the response. Actual template(s) used: home.html
- views.py 수정 :
home.html
⟶list.html
def view_list(request):
items = Item.objects.all()
context = { 'items' : items }
return render(request, 'list.html', context)
- unit test 결과
➜ python manage.py test lists
django.template.exceptions.TemplateDoesNotExist: list.html
- list.html 만들기 01
➜ touch lists/templates/list.html
- unit test-
list.html
은 비어있으므로 아래와 같이 에러가 난다.
➜ python manage.py test lists
AssertionError: False is not true : Couldn't find 'itemey 1' in response
- list.html 만들기 02
➜ cp lists/templates/home.html lists/templates/list.html
/* lists/templates/home.html */
<body>
<h1>Start a new To-Do list</h1>
<form method="POST">
<input name='item_text' id='id_new_item' placeholder="Enter a to-do item"/>
{% csrf_token %}
</form>
</body>
- lists/views.py 수정 - 이제 모든 to-do list항목을 home.html에 나타낼 필요가 없다. 간단히 하자.
def home_page(request):
if request.method == 'POST':
Item.objects.create(text=request.POST['item_text'])
return redirect('/lists/the-only-list-in-the-world/')
return render(request, 'home.html')
- unit test는 통과하고, FT를 살펴보면 길동은 자신의 리스트 페이지가 없어서 다음과 같은 에러가 난다.
➜ python manage.py test functional_tests
AssertionError: '1: Buy milk' not found in ['1: Buy peacock feathers', '2: Buy milk']
-
git
➜ git status ➜ git add lists/templates/list.html ➜ git diff ➜ git commit -am "new URL, view and template to display lists"
7.8. 단계4- A URL for Adding List Items
- 메모
- Adjust model so that items are associated with different lists
- Add unique URLs for each list..
- Add a URL for creating a new list via POST
- Add URLs for adding a new item to an existing list via POST
- 새 리스트 생성을 위한 클라스 테스트 - lists/tests.py
- 메서드
test_can_save_a_POST_reqeust
,test_redirects_after_POST
를 새 클라스NewListTest(TestCase):
로 옮긴다. assertRedirects
를 써서 /new에 접근한다.
- 메서드
class NewListTest(TestCase):
def test_can_save_a_POST_request(self):
self.client.post('/lists/new', data={'item_text':'A new list item'})
[...]
def test_redirects_after_POST(self):
response = self.client.post('/lists/new', data={'item_text':'A new list item'})
self.assertRedirects(response, '/lists/the-only-list-in-the-world/')
- unit test
➜ python manage.py test lists
FAIL: test_can_save_a_POST_request (lists.tests.NewListTest)
[...]
AssertionError: 0 != 1
[...]
----------------------------------------------------------------
FAIL: test_redirects_after_POST (lists.tests.NewListTest)
[...]
AssertionError: 404 != 302 : Response didn't redirect as expected: Response code was 404 (expected 302)
- urls.py
# superlists/urls.py
urlpatterns = [
url(r'^$', views.home_page, name='home'),
url(r'^lists/new$', views.new_list, name='new_list'),
url(r'^lists/the-only-list-in-the-world/$', views.view_list, name='view_list'),
]
- views.py
def new_list(request):
return redirect('/lists/the-only-list-in-the-world/')
- unit test
➜ python manage.py test lists
self.assertEqual(Item.objects.count(), 1)
AssertionError: 0 != 1
- views.py
def new_list(request):
Item.objects.create(text=request.POST['item_text'])
return redirect('/lists/the-only-list-in-the-world/')
- unit test는 통과한다. FT는,
➜ python manage.py test functional_tests
AssertionError: '1: Buy milk' not found in ['1: Buy peacock feathers', '2: Buy milk']
-
불필요한 코드와 테스트 삭제
- lists.views.home_page :
if request.method == 'POST' ~ return redirect('/lists/the-only-list-in-the-world/')
# lists/views.py def home_page(request): return render(request, 'home.html')
- lists.tests.py에서 class HomePageTest의 메서드
def test_only_saves_items_when_necessary - unit test
➜ python manage.py test lists Ran 6 tests in 0.057s OK
- lists.views.home_page :
-
FT - A Regression! Pointing Our Forms at the New URL
➜ python manage.py test functional_tests
FAIL: test_can_start_a_list_for_one_user
FAIL: test_multiple_users_can_start_lists_at_different_urls
----------------------------------------------------------------------
Ran 2 tests in 31.842s
FAILED (failures=2)
lists/templates/home.html
,lists/templates/list.html
<form method='POST' action="/lists/new">
- FT: 위 코드 수정이 잘 작동한다.
➜ python manage.py test functional_tests
AssertionError: '1: Buy milk' not found in ['1: Buy peacock feathers', '2: Buy milk']
----------------------------------------------------------------------
Ran 2 tests in 21.198s
FAILED (failures=1)
- git
➜ git status
➜ git diff
➜ git commit -am "코드 수정 "
- 메모 확인 - 현 단계가 한 일과 앞으로 할 일
- Adjust model so that items are associated with different lists
- Add unique URLs for each list
Add a URL for creating a new list via POST- Add URLs for adding a new item to an existing list via POST
7.9. 메모2- model 수정
-
lists/tests.py -
git diff
결과
@@ -1,5 +1,5 @@
from django.test import TestCase
-from lists.models import Item
+from lists.models import Item, List
class HomePageTest(TestCase):
@@ -44,22 +44,32 @@ class ListViewTest(TestCase):
-class ItemModelTest(TestCase):
+class ListAndItemModelsTest(TestCase):
def test_saving_and_retrieving_items(self):
+ list_ = List()
+ list_.save()
+
first_item = Item()
first_item.text = 'The first (ever) list item'
+ first_item.list = list_
first_item.save()
second_item = Item()
second_item.text = 'Item the second'
+ second_item.list = list_
second_item.save()
+ saved_list = List.objects.first()
+ self.assertEqual(saved_list, list_)
+
saved_items = Item.objects.all()
self.assertEqual(saved_items.count(), 2)
first_saved_item = saved_items[0]
second_saved_item = saved_items[1]
self.assertEqual(first_saved_item.text, 'The first (ever) list item')
+ self.assertEqual(first_saved_item.list, list_)
self.assertEqual(second_saved_item.text, 'Item the second')
+ self.assertEqual(second_saved_item.list, list_)
- unit test - 앞으로 벌어질 에러들은 다음과 같다.
# 현재
ImportError: cannot import name 'List'
# lists/model.py에서 List클래스를 만든 경우
AttributeError: 'List' object has no attribute 'save'
# save하면,
django.db.utils.OperationalError: no such table: lists_list
# makemigrations 하면,
self.assertEqual(first_saved_item.list, list_)
AttributeError: 'Item' object has no attribute 'list'
- lists/models.py
from django.db import models
class List(models.Model):
pass
class Item(models.Model):
text = models.TextField(default='')
list = models.TextField(default='')
➜ python manage.py test lists
[...]
django.db.utils.OperationalError: no such column: lists_item.list
➜ python manage.py makemigrations
Migrations for 'lists':
lists/migrations/0003_list.py
- Create model List
- Add field list to item
➜ python manage.py test lists
[...]
AssertionError: 'List object (1)' != <List: List object (1)>
- lists/models.py - A Foreign Key Relationship
from django.db import models
class List(models.Model):
pass
class Item(models.Model) :
text = models.TextField(default='')
list = models.ForeignKey(List, default=None, on_delete=True)
- model coloumn 수정
- migrations 삭제는 위험하다. 처음부터 맞는 코드를 작성할 수 없으므로 migrations 삭제가 필요하다. 좋은 원칙(A good rule of thumb)은 이미 커밋한 것은 지우거나 수정하지 않는 것입니다.
➜ rm lists/migrations/0003_list.py lists/migrations/0004_item_list.py
➜ python manage.py makemigrations
Migrations for 'lists':
lists/migrations/0001_initial.py
- Create model Item
- Create model List
- Add field list to item
➜ python manage.py test lists
...
ERROR: test_displays_all_items (lists.tests.ListViewTest)
django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id
...
ERROR: test_can_save_a_POST_request (lists.tests.NewListTest)
django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id
...
ERROR: test_redirects_after_POST (lists.tests.NewListTest)
django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id
- lists/tests.py
class ListViewTest(TestCase):
def test_displays_all_items(self):
list_ = List.objects.create()
Item.objects.create(text='itemey 1', list=list_)
Item.objects.create(text='itemey 2', list=list_)
- lists/views.py
from lists.models import Item, List
...
def new_list(request):
list_ = List.objects.create()
Item.objects.create(text=request.POST['item_text'], list=list_)
return redirect('/lists/the-only-list-in-the-world/')
- unit test 통과, FT는,
➜ python manage.py test functional_tests
AssertionError: '1: Buy milk' not found in ['1: Buy peacock feathers', '2: Buy milk']
- git
➜ git status
➜ git add lists
➜ git diff --staged
➜ git commit -am "Adjust model so that items are associated with different lists"
- 메모 확인 - 현 단계가 한 일과 앞으로 할 일
Adjust model so that items are associated with different lists- Add unique URLs for each list
Add a URL for creating a new list via POST- Add URLs for adding a new item to an existing list via POST
7.10. 메모3- 각 list의 고유 URL
-
lists/tests.py
class ListViewTest(TestCase):
def test_uses_list_template(self):
list_ = List.objects.create()
response = self.client.get(f'/lists/{list_.id}/')
self.assertTemplateUsed(response, 'list.html')
def test_displays_only_items_for_that_list(self):
correct_list = List.objects.create()
Item.objects.create(text='itemey 1', list=correct_list)
Item.objects.create(text='itemey 2', list=correct_list)
other_list = List.objects.create()
Item.objects.create(text='other list item 1', list=other_list)
Item.objects.create(text='other list item 2', list=other_list)
response = self.client.get(f'/lists/{correct_list.id}/')
self.assertContains(response, 'itemey 1')
self.assertContains(response, 'itemey 2')
self.assertNotContains(response, 'other list item 1')
self.assertNotContains(response, 'other list item 2')
- FT
➜ python manage.py test lists
FAIL: test_displays_only_items_for_that_list (lists.tests.ListViewTest)
AssertionError: 404 != 200 : Couldn't retrieve content: Response code was 404 (expected 200)
======================================================================
FAIL: test_uses_list_template (lists.tests.ListViewTest)
AssertionError: No templates used to render the response
- superlists / urls.py
urlpatterns = [
url(r'^$', views.home_page, name='home'),
url(r'^lists/new$', views.new_list, name='new_list'),
url(r'^lists/(.+)/$', views.view_list, name='view_list')
]
- unit test
➜ python manage.py test lists
ERROR: test_displays_only_items_for_that_list (lists.tests.ListViewTest)
...
TypeError: view_list() takes 1 positional argument but 2 were given
====================================================================
ERROR: test_uses_list_template (lists.tests.ListViewTest)
...
TypeError: view_list() takes 1 positional argument but 2 were given
======================================================================
ERROR: test_redirects_after_POST (lists.tests.NewListTest
TypeError: view_list() takes 1 positional argument but 2 were given
----------------------------------------------------------------------
FAILED (errors=3)
- lists/views.py
def view_list(request, list_id):
list_ = List.objects.get(id=list_id)
items = Items.objects.filter(list=list_)
context = {'items' : items }
return render(request, 'list.html', context)
- unit test
➜ python manage.py test lists
ValueError: invalid literal for int() with base 10: 'the-only-list-in-the-world'
- lists/tests.py
class NewListTest(TestCase):
...
def test_redirects_after_POST(self):
response = self.client.post('/lists/new', data={'item_text':'A new list item'})
new_list = List.objects.first()
self.assertRedirects(response, f'/lists/{new_list.id}')
- lists/views.py
def new_list(reqeust):
list_ = List.objects.create()
Item.objects.create(text=request.POST['item_text'], list=list_)
return redirect(f'/lists/{list_.id}/')
- unit test 통과, FT는,
➜ python manage.py test functional_tests
FAIL: test_can_start_a_list_for_one_user (functional_tests.tests.NewVisitorTest)
----------------------------------------------------------------------
AssertionError: '1: Buy peacock feathers' not found in ['1: Use peacock feathers to make a fly']
- 메모 확인 - 현 단계가 한 일과 앞으로 할 일
Adjust model so that items are associated with different listsAdd unique URLs for each listAdd a URL for creating a new list via POST- Add URLs for adding a new item to an existing list via POST
7.11. 메모4 - 기존 list에 새 항목을 추가하는 URLs
-
lists/tests.py
class NewItemTest(TestCase):
def test_can_save_a_POST_request_to_an_existing_list(self):
other_list = List.objects.create()
correct_list = List.objects.create()
self.client.post(f'/lists/{correct_list.id}/add_item',
data={'item_text' : 'A new item for an existing list'})
self.assertEqual(Item.objects.count(), 1)
new_item = Item.objects.first()
self.assertEqual(new_item.text, 'A new item for an existing list')
self.assertEqual(new_item.list, correct_list)
def test_redirects_to_list_view(self):
other_list = List.objects.create()
correct_list = List.objects.create()
response = self.client.post(
f'/lists/{correct_list.id}/add_item',
data={'item_text': 'A new item for an existing list'}
)
self.assertRedirects(response, f'/lists/{correct_list.id}/')
- unit test
AssertionError: 0 != 1
[...]
AssertionError: 301 != 302 : Response didn't redirect as expected: Response
code was 301 (expected 302)
- superlists / urls.py
urlpatterns = [
url(r'^$', views.home_page, name='home'),
url(r'^lists/new$', views.new_list, name='new_list'),
url(r'^lists/(\d+)/$', views.view_list, name='view_list'),
url(r'^lists/(\d+)/add_item$', views.add_item, name='add_item'),
]
- unit test
url(r'^lists/(\d+)/add_item$', views.add_item, name='add_item'),
AttributeError: module 'lists.views' has no attribute 'add_item'
- The Last New View - lists / views.py
def add_item(request, list_id):
list_ = List.objects.get(id=list_id)
Item.objects.create(text=request.POST['item_text'], list=list_)
return redirect(f'/lists/{list_.id}/')
-
unit test 통과한다.
-
lists/templates/list.html
<form method='POST' action="/lists//add_item">
- lists / tests.py의 ListViewTest
def test_passes_correct_list_to_template(self):
other_list = List.objects.create()
correct_list = List.objects.create()
response = self.client.get(f'/lists/{correct_list.id}/')
self.assertEqual(response.context['list'], correct_list)
- unit test - 템플릿에 list를 전달하지 않았다.
ERROR: test_passes_correct_list_to_template (lists.tests.ListViewTest)
----------------------------------------------------------------------
self.assertEqual(response.context['list'], correct_list)
KeyError: 'list'
- lists / views.py
def view_list(request, list_id):
list_ = List.objects.get(id=list_id)
context = {'list':list_}
return render(request, 'list.html', context)
- unit test
FAIL: test_displays_only_items_for_that_list (lists.tests.ListViewTest)
self.assertContains(response, 'itemey 1')
AssertionError: False is not true : Couldn't find 'itemey 1' in response
- lists / templates / list.html
<form method="POST" action="/lists/{{ list.id }}/add_item"
...
{% for item in list.item_set.all %}
<tr><td>{{ forloop.counter }}: {{ item.text }}</td></tr>
{% endfor %}
- unit test
➜ python manage.py test lists
Ran 9 tests in 0.040s
OK
- FT
➜ python manage.py test functional_tests
Ran 2 tests in 12.366s
OK
- git
➜ git diff
➜ git commit -am "new URL + view for adding to existing lists. FT passes :-)"
7.12. 리팩토링 - URL includes
-
superlists / urls.py
from django.conf.urls import include, url
from lists import views, urls
urlpatterns = [
url(r'^$', views.home_page, name='home'),
url(r'^lists/', include(urls)),
]
- lists / urls.py
from django.conf.urls import url
from lists import views
urlpatterns = [
url(r'^new$', views.new_list, name='new_list'),
url(r'^(\d+)/$', views.view_list, name='view_list'),
url(r'^(\d+)/add_item$', views.add_item, name='add_item'),
]
- git
➜ git status
➜ git add lists/urls.py
➜ git add config/urls.py
➜ git diff --staged
➜ git commit -am "1부 끝"
TDD Philosophy
- Working State to Working State ( The Testing Goat vs Refactoring Cat )
- Split work out into small, achievable tasks
- YAGNI ( You Ain’t Gonna need It! ) - 그 당시에는 스스로를 제안했기 때문에 유용하다고 생각되는 코드를 작성하려는 유혹을 피하십시오.