TDD with Python: 염소책01

게시: by Creative Commons Licence


참고


1. 블로그, 책

png


2. 관련 코드 Git




TDD?


  • 린(Lean) 소프트웨어 개발론의 핵심 철학 중 하나는 “결함은 발견 즉시 해결”이다. 린 개발은 이것의 실천법으로 테스트 주도 개발(Test-Driven Development, TDD)을 제시한다.
  • TDD - TDD는 반복 테스트을 이용한 소프트웨어 개발법이다. 작은 단위의 테스트 케이스를 작성하고 이를 통과하는 코드를 추가하는 단계를 반복하여 소프트웨어를 구현한다. TDD의 목표는 작동하는 깔끔한 코드 “Clean code that works” 이다.

TDD?

  • TDD의 방법론 - TDD는 테스트 케이스를 생성한다. 테스트 케이스는 자동화된 테스트 도구로 이용되어, 코드 변경시 기존 기능이 제대로 동작하는지 쉽게 확인할 수 있고 정상 동작을 보장한다. 또한 TDD는 리펙토링을 개발 프로세스에 포함시켜 ‘변경’이라는 소프트웨어의 특성을 반영한다. ❶ 오직 자동화된 테스트가 실패할 경우에만 새로운 코드를 작성한다. ❷ 중복을 제거한다.

  • TDD의 장점은 ❶ 개발자의 방향을 잃지 않게 유지시키며, ❷ 소프트웨어의 품질을 일정 이상 보장하고, ❸ 자동화 단위 케이스를 가지게 되어 즉시 검사가 가능하다. ❹ TDD를 염두하고 설계하므로 개발 의도와 목적이 명확해진다. ❺ 코드 실패가 두렵지 않고 리팩토링을 마음 편히 할 수 있으며, ❻ 개발 속도가 느려지지 않는다.
  • TDD의 단점은 ➀ 동시성(Concurrency)과 보안 등 비기능적 요소에 접근하기 어렵고, ➁ MVC 패턴에서 의존성 모듈이 이루어지는 경우 TDD가 이루어지기 어렵다. ➂ 테스트 코드 작성이 어렵고, 나쁜 코드를 잘 찾을 수 있는 것은 아니다. 여전히 코딩 경험과 지식이 필요하다.
  • 그럼에도 불구하고, 기존의 개발방식( 요구사항 분석 ➞ 설계 ➞ 개발 ➞ 테스트 ➞ 배포)에서 소비자의 요구사항이 처음부터 명확할 수 없으므로 처음부터 완벽한 설계는 어렵다는 한계를 TDD는 극복하려고 한다.




with Pycharm & Git




TDD 기초와 Django


1. Getting Django Set Up Using a Functional Test


가. 기본환경 설정

gitignore.io

관련 코드

0) 기본 환경

  • OS환경은 Ubuntu16.04LTS이고, python 3.6.2버전, pycharm community 2016.3을 기준으로 하였다.

1) .gitignoregitignore.io를 이용한다.

  • 입력창에 Git, Django, Python, Pycharm을 입력한다.

gitignore.io

  • Create탭을 누르면, .gitignore에 들어갈 내용들을 찾아준다.

gitignore.io

  • 다음 내용들을 추가하여 위 내용을 .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]

    화살표 순으로 탭을 클릭하면 입력창이 나타난다.

pycharm interpreter

  • 입력창에 위 그림과 같이 /<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

phantomjs 공식문서

Ubuntu에서 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가 작동하는지 확인할 뿐이다.

functional_tests.py - selenium


다. 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를 새로 설정해야 한다.

pycharm- set source root

  • rename : 프로젝트 폴더명과 같은 설정 폴더 superlistsconfig리팩토링한다. 위 설정 폴더 이름을 참조하는 소스 코드 내용을 고려하여 리팩토링을 한다.
    • 해당 폴더에 오른쪽 마우스를 클릭하여 Refactor탭을 거쳐 Rename을 누르고,
    • 입력창에 config라고 입력한후 Refactor탭을 누른다.
    • 아래 창에서 Do Refactor탭을 누른다.

pycharm- set source root

pycharm- set source root

pycharm- set source root

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이 작동한다.

django runserver success




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인지 여부를 묻고 있기 때문이다.

expected error


2.2. The Python Standard Library’s unittest Module

# 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 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

➜  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 접근방법은 다음과 같습니다.

  1. functional test 작성하기 - 사용자 관점에서 새로운 기능들을 서술할 것(예: 수지의 앱 방문)
  2. 일단 functional test에서 실패하면 위 테스트를 통과할 만한 코드를 작성하고, 이 코드가 작동하는 법에 대한 unit test를 시도한다.
  3. 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 읽는 법

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

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

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
----------------------------------------------------------------------
  • The Unit-Test/Code Cycle

    • 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"

Refactoring Cat(source:4GIF.com)


4.5. 프론트 페이지 추가

<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
----------------------------------------------------------------------

FT화면

  • git
➜  git diff
➜  git commit -am "Front page HTML now generated from a template"


4.6. 복습 TDD

  • 현재까지 이루어진 TDD 프로세스: Functional testsUnit testsThe unit-test/code cycleRefactoring

Overall TDD process

  • The TDD process with functional and unit tests

The TDD process with functional and unit tests




5. Testing the Database


5.1. Post 요청을 위한 Form

<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"}

csrf_token

  • 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 요청 처리

➜  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. 파이썬 변수를 템플릿에 전달하기

<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']
----------------------------------------------------------------------

FT-2: 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로

참조 - Saving the POST to the Database

관련 코드

  • 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

# 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 /'
----------------------------------------------------------------------

no such table: lists_item


5.9. DB생성과 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!
----------------------------------------------------------------------

FT success


✔ 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의 setUptearDown 메서드가 이를 수행한다.

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 코드 수정 - 이 코드의 마지막 if __name__ == '__main__' 삭제한다. 이제 Django test runner가 FT를 작동시킬 것이기 때문이다.
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.pytest_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-3FT_fail


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.htmllist.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
    
  • 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 수정

@@ -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)은 이미 커밋한 것은 지우거나 수정하지 않는 것입니다.

7-9migrations

➜  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

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 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.11. 메모4 - 기존 list에 새 항목을 추가하는 URLs

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

7-11FT

  • git
➜  git diff
➜  git commit -am "new URL + view for adding to existing lists. FT passes :-)"


7.12. 리팩토링 - URL includes

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! ) - 그 당시에는 스스로를 제안했기 때문에 유용하다고 생각되는 코드를 작성하려는 유혹을 피하십시오.