Requirements

# python 3.4
# git

# 크롬 웹 드라이버
$ brew install chromedriver

# 필요한 파이썬 패키지
$ pip install django==1.7

# 셀레늄이 제대로 작동하지 않는다면, 업그레이드로 해결되는 경우가 있다.
$ pip install --upgrade selenium

TDD 프로젝트 Repository

1부 TDD와 Django 개요

1장 기능 테스트를 이용한 Django 설치

2장 unittest 모듈을 이용한 기능 테스트 확장

  • 실제 웹 브라우저를 실행해서 애플리케이션이 어떻게 “동작(functions)”하는지 사용자 관점에서 확인할 수 있다. 이런 테스트를 기능 테스트(Functional test, FT)라고 한다.
  • 사용자 스토리(User story)라고 하는 방식을 따르는 경향이 있다. 특정 기능을 사용자가 어떻게 사용하며 이때 애플리케이션이 어떻게 반응해야 하는지 확인하는 방식이다. FT 구조화를 위해 사용한다.
  • FT는 사람이 이해할 수 있는 스토리를 가지고 있어야 한다. 이것을 분명하게 정의하기 위해 테스트 코드에 주석을 기록한다.
  • 코드를 작성한 것을 그대로 반복해서 글로 표현하는 것은 의미가 없다. 가장 이상적인 방법은 이해할 수 있는 변수명이나 함수명을 사용하고, 프로그램 구조를 잘 만들어서 코드 자체만 보고도 해석이 가능하도록 하는 것이다. 중요한 곳에만 코드 사용의 이유를 설명해두면 되는 것이다.
import unittest

from selenium import webdriver


# unittest.Testcase를 상속해서 테스트를 클래스 형태로 생성
class NewVisitorTest(unittest.TestCase):
    # setUp, tearDown 특수한 메소드
    # 테스트 시작 전과 후에 실행
    def setUp(self):
        self.browser = webdriver.Chrome()

      	# 암묵적 대기, 지정한 시간(초 단위)만큼 동작을 대기 상태로 두는 것
        self.browser.implicitly_wait(3)

    def tearDown(self):
        self.browser.quit()

    # test라는 명칭으로 시작하는 모든 메소드는 테스트 메소드이며,
    # 테스트 실행자에 의해 실행된다.
    def test_can_start_a_list_and_retrieve_it_later(self):
        self.browser.get('http://localhost:8000')

        self.assertIn('To-Do', self.browser.title)

        # 강제로 테스트 실패를 발생시켜 에러메시지 출력
        self.fail('Finish the test!')


# 파이썬 스크립트가 커맨드 라인을 통해 실행됐다는 것을 확인하는 코드
if __name__ == '__main__':
    # unittest.main() : 자동으로 파일 내 테스트 클래스와 메소드를 찾아 실행해주는 역할
    # warnings='ignore' : 리소스 경고를 제거
    unittest.main(warnings='ignore')

3장 단위 테스트를 이용한 간단한 홈페이지 테스트

첫 Django 애플리케이션과 첫 단위 테스트

  • Django는 코드를 앱(app) 형태로 구조화한다.
  • 하나의 프로젝트에 여러 앱을 가질 수 있으며, 다른 사람이 만든 외부 앱도 사용할 수 있다.

단위 테스트는 무엇이고, 기능 테스트와 어떤 차이가 있을까?

  • 기능 테스트는 사용자 관점에서 애플리케이션 외부를 테스트하는 것이고,
  • 단위 테스트는 프로그래머 관점에서 그 내부를 테스트 한다.
    • 기능 테스트를 작성해 사용자 관점의 새로운 기능을 정의한다.
    • 기능 테스트가 실패하면 어떻게 코드를 작성해야 테스트를 통과할지 생각한다. 이 시점에서 하나 또는 그 이상의 단위 테스트를 이용해서 어떻게 코드가 동작해야 하는지 정의 한다.
    • 단위 테스트가 실패하고 나면 단위 테스트를 통과할 수 있을 정도의 최소한의 코드만 작성한다. 기능 테스트가 완전해질 때까지 반복한다.
    • 기능 테스트를 재실행해서 통과하는지 확인한다.
  • 기능 테스트는 제대로 된 기능성을 갖춘 애플리케이션을 구축하도록 도우며, 그 기능성이 망가지지 않도록 보장한다.
  • 단위 테스트는 깔끔하고 버그없는 코드를 작성하도록 돕는다.

Django에서의 단위 테스트

# lists/tests.py
from django.test import TestCase


class SmokeTest(TestCase):
    def test_bad_maths(self):
        self.assertEqual(1 + 1, 3)
$ ./manage.py test
Creating test database for alias 'default'...
F
=========================================
FAIL: test_bad_maths (lists.tests.SmokeTest)
-----------------------------------------
Traceback (most recent call last):
  File "/tdd_hw/superlists/lists/tests.py", line 6, in test_bad_maths
    self.assertEqual(1 + 1, 3)
AssertionError: 2 != 3
-----------------------------------------
Ran 1 test in 0.004s

FAILED (failures=1)
Destroying test database for alias 'default'...

Django의 MVC, URL, 뷰 함수

사용자가 특정 URL을 요청했을 때, Django의 처리 흐름은 다음과 같다.

  1. 특정 URL에 대한 HTTP “요청(request)”을 받는다.
  2. Django는 특정 규칙을 이용해 해당 요청에 어떤 뷰 함수를 실행할지 결정한다.
  3. 실행된 뷰 함수가 요청을 처리해 HTTP “응답(response)“으로 반환한다.

마침내 실질적인 애플리케이션 코드를 작성

Traceback은 TDD에 있어 중요한 역할을 한다.

$ ./manage.py test
Creating test database for alias 'default'...
E
=========================================
ERROR: test_root_url_reslove_to_home_page_view (lists.tests.HomePageTest)
----------------------------------------
Traceback (most recent call last):
  File "/tdd_hw/superlists/lists/tests.py", line 9, in test_root_url_reslove_to_home_page_view
    found = resolve('/')
  File "/usr/local/var/pyenv/versions/tdd_hw/lib/python3.4/site-packages/django/core/urlresolvers.py", line 489, in resolve
    return get_resolver(urlconf).resolve(path)
  File "/usr/local/var/pyenv/versions/tdd_hw/lib/python3.4/site-packages/django/core/urlresolvers.py", line 353, in resolve
    raise Resolver404({'tried': tried, 'path': new_path})
django.core.urlresolvers.Resolver404: {'path': '', 'tried': [[<RegexURLResolver <RegexURLPattern list> (admin:admin) ^admin/>]]}
-----------------------------------------
Ran 1 test in 0.022s

FAILED (errors=1)
Destroying test database for alias 'default'...
  1. 에러“를 먼저 확인한다. django.core.urlresolvers.Resolver404: {'path': '', 'tried': [[<RegexURLResolver <RegexURLPattern list> (admin:admin) ^admin/>]]}
  2. 어떤 테스트가 실패하고 있는지 확인한다. ERROR: test_root_url_reslove_to_home_page_view (lists.tests.HomePageTest)
  3. 실패를 발생시키는 “테스트 코드“를 찾는다. 트레이스백 상단부터 아래로 내려가면서 테스트 파일 명을 찾는다. File /tdd_hw/superlists/lists/tests.py", line 9, in test_root_url_reslove_to_home_page_viewfound = resolve('/')

모든 정보를 취합해보면, “/”를 확인하려고 할 때 장고가 404 에러를 발생시키고 있다. 즉, 장고가 “/”에 해당하는 URL 맵핑을 찾을 수 없다.

urls.py

장고는 urls.py라는 파일을 이용해 어떻게 URL을 보뷰 함수에 맵핑할지 정의한다. url()은 정규표현식으로 시작하며, 어떤 URL을 해석해서 어떤 함수로 요청을 보낼지 정의한다. 대상 함수는 superlist.views.home처럼 마침표 조합형태이다.

뷰를 위한 단위 테스트

response.content는 byte형 데이터로, 파이썬 문자열이 아니다. 따라서 b'' 구문을 사용해야 한다.

HttpRequest and HttpResponse objects

  • 헤더는 항상 str 객체이고,
  • 입/출력 스트림은 항상 bytes 객체이다.
  • 테스트에서 str과 비교하면 문제가 될 수 있다. 선호하는 해결책은 assertContains()assertNotContains()에 의존하는 것이다. 이 메소드는 requestunicode string을 인자로 받는다.
    • Unicode : byte 앞에 문자열 b를 붙인다.
def test_home_page_returns_correct_html(self):
    request = HttpRequest()
    response = home_page(request)
    self.assertTrue(response.content.startswith(b'<html>'))
    self.assertIn(b'<title>To-Do lists</title>', response.content)
  	self.assertTrue(response.content.strip().endswith(b'</html>'))

단위 테스트-코드 주기

TDD 단위 테스트-코드주기에 대해 생각해야 한다.

  1. 터미널에서 단위 테스트를 실행해서 어떻게 실패하는지 확인한다.
  2. 편집기상에서 현재 실패 테스트를 수정하기 위한 **최소한의 **코드를 변경만한다.

그리고 이것을 반복한다. 코드 품질을 높이고 싶다면 코드 변경을 최소화해야 한다. 매우 고된 작업이라고 생각될 수 있지만, 한번 익숙해지기 시작하면 속도는 빨라진다. 따라서 아무리 자신 있는 분분이라도 작은 단위로 나누어 코드를 변경하도록 한다.

기억해두면 좋은 명령어

# 장고 개발 서버 실행
$ python manage.py runserver

# 기능 테스트 실행
$ python functional_tests.py

# 단위 테스트 실행
$ python manage.py test

4장 왜 테스트를 하는 것인가?

프로그래밍은 우물에서 물을 퍼 올리는 것과 같다

단기적 관점에선 간단한 함수나 상수를 위한 테스트 작성이 하찮게 여겨질 수도 있다. 물론 보다 수월한 규칙을 따라서 TDD를 “적당하게” 할 수도 있다.

간단한 함수를 위한 간단한 테스트에 대해 언급하고 싶은 것이 두 가지가 있다.

첫 번째는, 테스트가 정말 시시한 테스트라면, 테스트 작성 자체에 시간이 많이 걸리지 않는다는 것이다. 그러니 불평하지 말고 작성하자.

두 번째는, 틀을 사용하는 것이 도음이 된다는 것이다. 초반부터 틀을 갖추어 테스트를 했기 때문에 새로운 테스트를 추가하는 것이 자연스럽고 테스트도 수월하다. 테스트를 해야 할 정도로 복잡하다고 판단한 후 테스트를 작성하기 시작한다면, 틀이 없기 때문에 훨씬 많은 수고를 들여서 테스트를 만들고 수정해야 한다.

셀레늄을 이용한 사용자 반응 테스트

TDD가 훌륭한 이유 중 하나가 다음에 무엇을 해야 할지 잊어버릴 걱정이 없다는 것이다. 테스트를 다시 실행하기만 하면 다음 작업이 무엇인지 가르쳐 준다.

“상수는 테스트하지 마라”는 규칙과 탈출구로 사용할 템플릿

단위 테스트 시의 일반적인 규칙 중 하나는 “상수는 테스트하지 마라”다. 단위 테스트는 로직이나 흐름제어, 설정 등을 테스트하기 위한 것이다.

템플릿을 사용하기 위한 리팩토링

지금부터 할 작업은 뷰 함수가 이전과 같은 HTML을 반환하도록 하는 것이지만, 다른 프로세스를 적용하는 것이다. 이것을 리팩터링이라고 한다.

리팩토링(Refactoring)이란 “기능(결과물)은 바꾸지 않고” 코드 자체를 개선하는 작업을 일컫는다.

리팩토링에 관해

리팩토링 시에는 앱 코드와 테스트 코드를 한 번에 수정하는 것이 아니라 하나씩 수정해야 한다. 작은 단계로 나누어 착실히 작업하도록 하자.

정리: TDD 프로세스

  • 기능 테스트(Functional tests)
  • 단위 테스트(Unit tests)
  • 단위 테스트-코드 주기(Unit test-code cycle)
  • 리팩토링(Refactoring)

5장 사용자 입력 저장하기

POST 요청을 전송하기 위한 폼(Form) 연동

POST 요청을 이용해서 사용자 입력을 저장하도록 한다. 브라우저가 POST 요청을 보내기 위해 <input> 요소에 name= 속성을 지정하고 <form> 태그로 감싼다. <from> 태그에 method="POST"를 지정해서 전송 방식을 설정한다.

서버에서 POST 요청 처리

폼에 아직 action= 속성을 지정하지 않았기 때문에, 동일 URL을 전달해서 페이지를 다시 표시한다. 예제에서 사용되는 것이 home_page 함수인데, 이것을 수정해 POST 요청을 처리하도록 한다.

테스트 코드 작성 시, 줄 띄우기는 다음과 같이 하는 것이 좋다.

  • 설정(Setup) : 테스트 설정을 위한 코드
  • 처리(Exercise) : 실제 함수 호출
  • Assert : Assertion 코드

파이썬 변수를 전달해서 템플릿에 출력하기

템플릿 구문을 이용하면 파이썬 뷰 코드에 있는 변수를 HTML 템플릿에 전달할 수 있다.

def home_page(request):
    # dict.get(key, default=None)
    # key : dict에서 key를 검색
    # default : key가 없을 경우, default를 반환
    return render(request, 'lists/home.html', {
        'new_item_text': request.POST.get('item_text', ''),
    })

스트라이크 세 개면 리팩터

DRY(Don’t Repeat Yourself)라는 원리가 있는데, 스트라이크 세 개면 리팩터(Three strikes and refactor) 이론과 일맥상 통한다. 즉 한 번 정도는 복사-붙여 넣기를 해줄 수 있지만, 같은 코드가 세 번 등장하게 되면 중복을 제거해야 한다는 이론이다.

리팩토링 전에는 반드시 커밋진행해야 한다.

class NewVisitorTest(unittest.TestCase):
    # ...

    def tearDown(self):
        self.browser.quit()

    # 헬퍼 메서드를 이용
    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])

    # test_로 시작하는 메서드만 테스트로 실행
    def test_can_start_a_list_and_retrieve_it_later(self):
        # ...

Django ORM과 첫 모델

객체 관계형 맵핑(Object-Relational Mapper, ORM)은 데이터베이스의 테이블, 레코드, 칼럼 형태로 저장돼 있는 데이터를 추상화한 것이다. 이것을 이용하면 익숙한 객체 지향 코딩 방식을 이용해서 데이터베이스를 처리할 수 있다. 데이터베이스는 클래스를 표현하고, 칼럼은 속성, 레코드는 각 클래스의 인스턴스로 표현한다.

Django는 API도 함께 제공해서 클래스 속성인 .objects을 통해 데이터베이스를 쿼리할 수 있다. 결과는 QuerySet이라 불리는 리스트 형태의 객체로 반환되며, 개별 객체도 추출할 수 있다.

놀랍게 진전된 테스트

models.Model로 부터 상속한 클래스는 데이터베이스의 테이블 역할을 한다. 클래스(DB)가 생성될 때 키 역할을 하는 ID 속성이 기본으로 생성된다. 하지만 다른 칼럼들은 직접 정의해야 한다.

신규 필드는 신규 마이그레이션을 의미

$ python manage.py makemigrations
You are trying to add a non-nullable field 'text' to item without a default;
we cant 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)
 2) Quit, and let me add a default in models.py
Select an option: 2

필드를 추가한 후 다시 마이그레이션을 하면 다음과 같은 메시지가 나온다. 메시지를 보면 알 수 있듯이 초기값 없이 칼럼을 추가 할 수 없다. 2번을 선택해서 models.py를 수정한다.

POST를 데이터베이스에 저장하기

문제를 발견하면 하던 것을 멈추고 다시 할지 아니면 뒤로 미룰지 판단해야 한다. 문제를 바로 해결하는게 좋을 때가 있고 문제가 너무 크면 멈춰서 다른 방법을 생각해야 하는 경우가 있는 것이다.

메모
- 모든 요청에 대한 비어 있는 요청은 저장하지 않는다.
- 코드 냄새 : POST 테스트가 너무 긴가?
- 테이블에 아이템 여러개 표시하기
- 하나 이상의 목록 지원하기

POST 후에 리디렉션

“POST 후에는 항상 리디렉션 하라”는 말이 있으니 이를 따르도록한다. POST 요청을 저장해서 돌아온 응답을 렌더링하는 것이 아니라, 홈페이지로 다시 리디렉션하는 단위테스트를 진행한다.

# 더 이상 request.content가 템플릿에 렌더링되지 않으므로 assertion을 제거
# response이 HTTP 리디렉션을 하기 때문에 상태코드가 302가 되며,
# 브라우저는 새로운 위치를 가리킨다.
self.assertEqual(response.status_code, 302)
self.assertEqual(response['location'], '/')

더 나은 단위 테스트 구현

좋은 단위 테스트는 각 테스트가 한 가지만 테스트 하는 것을 의미한다. 테스트에 많은 assertion이 있는 경우, 앞에 있는 assertion이 실패하면 뒤에 있는 assertion 상태를 파악할 수 없다.

6장 최소 동작 사이트 구축

기능 테스트 내에서 테스트 격리

테스트와 테스트 사이에 발생하는 “격리” 문제와 관련 있다. 기능 테스트를 실행할 때마다 앞 테스트의 목록 아이템이 데이터베이스에 남아있었다. 이것은 다시 다음 테스트 결과 해석을 방해하게 된다.

LiveServerTestCase라는 클래스를 이용해서 이 문제를 해결할 수 있다. 이것은 자동으로 테스트용 데이터베이스를 생성하고(단위 테스트와 마찬가지로), 기능 테스트를 위한 개발 서버를 가동한다.

깔끔한 작업을 위해서 기능 테스트용 별도 폴더를 만든다. 이 폴더를 Django가 인식하게 하려면, 유효한 파이썬 패키지 디렉토리(디렉토리 내에 __init__.py 파일이 있는 것)로 만든다.

이제부터 테스트를 실행할 때, 아래와 같이 한다.

# 기능 테스트
$ ./manage.py test functional_tests

# 단위 테스트
$ ./manage.py test lists

# 기능, 단위 테스트 둘 다 실행
$ ./manage.py test

필요한 경우에는 최소한의 설계를

TDD는 애자일(Agile) 개발 방법과 밀접한 관련이 있다. 애자일은 이론보다는 실제 상황을 통해 문제를 해결하려는 것을 근간으로 하고 있다. 특히 가능한 빨리 사용자가 애플리케이션을 접할 수 있도록 하는 것이 특징이다. 긴 설계 과정 대신에, “동작하는 최소한의 애플리케이션”을 빠르게 만들고, 이를 이용해서 얻는 실제 사용자 의견을 설계에 점진적으로 반영해 가는 방식이다.

“최소한의 기능을 가진 작업 목록 앱”을 어떻게 설계할지 생각해보자.

  • 각 사용자가 개별 목록을 저장하도록 한다.(지금은 최소 하나의 목록 유지)
  • 하나의 목록은 여러개의 아이템으로 구성된다. (아이템은 작업내용을 설명하는 텍스트)
  • 다음 방문시에도 목록을 확인할 수 있도록 저장한다. (지금은 목록에 해당하는 개별 URL을 사용자에게 제공)

YAGNI!

설계에 대해 한번 생각하기 시작하면 생각을 멈추는 것이 쉽지 않다. 여러가지 생각이 떠오르기 시작한다. 하지만 애지알 복음인 YAGNI(야그니)에 순종해야 한다. “You ain’t gonna need it.”(그것을 사용할 일이 없을 것이다.)의 약자다. 아이디어가 정말 끝내주더라도 대게는 그걸 사용하지 않고 끝나는 경우가 많다.

REST

RES(Representational State Transfer)는 웹 설계 방법 중 하나다. 일반적으로 웹 기반 API를 이용해서 설계하도록 유도한다. REST는 데이터 구조를 URL 구조에 일치시키는 방식이다.

메모
- FT가 끝난 후에 결과물을 제거한다.
- 모델을 조정해서 아에팀들을 다른 목록과 연계되도록 한다.
- 각 목록별 고유 URL을 추가한다.
- POST를 이용해서 새로운 목록을 생성하는 URL을 추가한다.
- POST를 이용해서 새로운 아이템을 기존 목록에 추가하는 URL을 만든다.

TDD를 이용한 새로운 설계 반영하기

이중 샵(##)을 사용해서 메타(meta) 주석임을 가리킨다. FT에서 일반 주석은 사용자 스토리를 설명하지만, 메타 주석은 테스트가 어떻게 동작하는지 설명하기 위해 사용한다.

from django.test import LiveServerTestCase
from selenium import webdriver
from selenium.webdriver.common.keys import Keys


class NewVisitorTest(LiveServerTestCase):
    def setUp(self):
        self.browser = webdriver.Chrome()
        self.browser.implicitly_wait(3)

    def tearDown(self):
        self.browser.quit()

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

    def test_can_start_a_list_and_retrieve_it_later(self):
        # 에디스(Edith)는 멋진 작업 목록 온라인 앱이 나왔다는 소식을 듣고
        # 해당 웹 사이트를 확인하러 간다.
        self.browser.get(self.live_server_url)

        # 웹 페이지 타이틀과 헤더가 'To-Do'를 표시하고 있다.
        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'),
            '작업 아이템 입력'
        )

        # '공작깃털 사기'라고 텍스트 상자에 입력한다.
        # (에디스의 취미는 날치 잡이용 그물을 만드는 것이다.)
        inputbox.send_keys('공작깃털 사기')

        # 엔터키를 치면 페이지가 갱신되고 작업 목록에
        # '1: 공작깃털 사기' 아이템이 추가된다.
        inputbox.send_keys(Keys.ENTER)
        edith_list_url = self.browser.current_url
        ## unittest에 부속된 헬퍼 함수로, 지정한 정규표현식과 문자열이 일치하는지 확인
        self.assertRegex(edith_list_url, '/lists/.+')
        self.check_for_row_in_list_table('1: 공작깃털 사기')

        # 추가 아이템을 입력할 수 있는 여분의 텍스트 상자가 존재한다.
        # 다시 '공작깃털을 이용해서 그물 만들기'라고 입력한다. (에디스는 매우 체계적인 사람이다.)
        inputbox = self.browser.find_element_by_id('id_new_item')
        inputbox.send_keys('공작깃털을 이용해서 그물 만들기')
        inputbox.send_keys(Keys.ENTER)

        # 페이지는 다시 갱신되고, 두 개 아이템이 목록에 보인다.
        self.check_for_row_in_list_table('2: 공작깃털을 이용해서 그물 만들기')
        self.check_for_row_in_list_table('1: 공작깃털 사기')

        # 새로운 사용자인 프란시스가 사이트에 접속한다.

        ## 새로운 브라우저 세션을 이용해서 에디스의 정보가
        ## 쿠키를 통해 유입되는 것을 방지한다.
        self.browser.quit()
        self.browser = webdriver.Chrome()

        # 프란시스가 홈페이지에 접속한다.
        # 에디스의 리스트는 보이지 않는다.
        self.browser.get(self.live_server_url)
        page_text = self.browser.find_element_by_tag_name('body').text
        self.assertNotIn('공작깃털 사기', page_text)
        self.assertNotIn('그물 만들기', page_text)

        # 프란시스가 새로운 작업 아이템을 입력하기 시작한다.
        # 그는 에디스보다 재미가 없다.
        inputbox = self.browser.find_element_by_id('id_new_item')
        inputbox.send_keys('우유 사기')
        inputbox.send_keys(Keys.ENTER)

        # 프란시스가 전용 URL을 취득한다.
        francis_list_url = self.browser.current_url
        self.assertRegex(francis_list_url, '/lists/.+')
        self.assertNotEqual(francis_list_url, edith_list_url)

        # 에디스가 입력한 흔적이 없다는 것을 다시 확인한다.
        page_text = self.browser.find_element_by_tag_name('body').text
        self.assertNotIn('공작깃털 사기', page_text)
        self.assertIn('우유 사기', page_text)

        # 둘 다 만족하고 잠자리에 든다.
        self.fail('Finish the test!')

새로운 설계를 위한 반복

기존 기능 테스트를 거의 절반 정도 뜯어 고쳐야 한다. 또한 코드 한줄 한줄을 재구성해야 한다. TDD에선 항상 있는 일로, 이런 수정작업과의 끊임없는 싸움이 될 것이다. 각 단계별 설계내용을 바탕으로 조금씩 조심스럽게 변경해가면 된다.

현 시점에서 발생한 문제만 고민하면 된다. 최종적으로는 n개의 아이템을 처리해야 하지만, 우선은 한개 아이템 처리를 구현하는 것이 좋은 시작점이 될 수 있다.

Django 테스트 클라이언트를 이용한 뷰, 템플릿, URL 동시 테스트

Django는 세 가지 테스트를 한 번에 할 수 있는 작은 툴을 제공한다.

목록 아이템을 추가하기 위한 URL과 뷰

class NewListTest(TestCase):
    def test_saving_a_POST_request(self):
        # '/new' (O)
        # '/new/' (X)
        # 끝에 슬래시를 사용하지 않는 경우는
        # 데이터베이스에 변경을 가하는 "액션" URL인 경우다.
        self.client.post(
            '/lists/new',
            data={'item_text': '신규 작업 아이템'}
        )
        self.assertEqual(Item.objects.count(), 1)
        new_item = Item.objects.first()
        self.assertEqual(new_item.text, '신규 작업 아이템')

    def test_redirects_after_POST(self):
        response = self.client.post(
            '/lists/new',
            data={'item_text': '신규 작업 아이템'}
        )
        # response는 HTTP 리디렉션을 하기 때문에 상태코드가 302가 되며,
        # 브라우저는 새로운 위치를 가리킨다.
        self.assertEqual(response.status_code, 302)
        self.assertEqual(response['location'], '/lists/the-only-list-in-the-world/')

각 목록이 하나의 고유 URL을 가져야 한다.

URL에서 파라미터 취득하기

from django.conf.urls import patterns, url

urlpatterns = patterns(
    '',
    url(r'^$',
        'lists.views.home_page', name='home'),
    url(r'^lists/(.+)/$',
        'lists.views.view_list', name='view_list'),
    url(r'^lists/new$',
        'lists.views.new_list', name='new_list')
    # url(r'^admin/', include(admin.site.urls)),
)

캡쳐 그룹 (.+)/ 뒤에 나오는 모든 문자열에 일치된다. 취득한 텍스트는 인수 형태로 뷰에 전달된다.

즉 URL이 /lists/1/이면 일반 요청 인수 다음에 두번째 인수가 전달된다. /lists/foo/인 경우 view_list(request, 'foo')가 된다.

기존 목록에 아이템을 추가하기 위한 또 다른 뷰

=========================================
FAIL: test_redirects_to_list_view (lists.tests.NewItemTest)
----------------------------------------
Traceback (most recent call last):
  File "/tdd_hw/django_app/lists/tests.py", line 132, in test_redirects_to_list_view
    self.assertRedirects(response, '/lists/{}/'.format(correct_list.id, ))
  File "/usr/local/var/pyenv/versions/tdd_hw/lib/python3.4/site-packages/django/test/testcases.py", line 288, in assertRedirects
    (response.status_code, status_code))
AssertionError: 301 != 302 : Response didn't redirect as expected: Response code was 301 (expected 302)

욕심 많은 정규표현식을 조심하자!

아직 lists/1/add_item URL을 지정하지 않았으니 예상한 에러는 404!=302다. 왜 301인걸까? 정규표현식 때문이다.

url(r'^lists/(.+)/$',
        'lists.views.view_list', name='view_list'),

Django는 영구적 리디렉션(301)에 대한 내부적인 이슈가 있어서 슬래시가 누락되는 경우 문제가 발생한다. 이 경우에는 /lists/1/add_itemlists/(.+)/에 의해 매칭된다. (.+)1/add_item을 캡쳐하기 때문이다. Django는 우리가 마지막 꼬리 슬래시를 원한다고 추측한다.

이것을 해결하기 위해 URL에서 숫자만 추출하도록 수정한다. 이때 쓰이는 정규표현이 \d이다.

url(r'^lists/(\d+)/$',
        'lists.views.view_list', name='view_list'),

폼에서 URL을 사용하는 방법

  • 이제 URL을 list.html 템플릿에 이용하면 된다.
  • .item_set은 “reverse lookup“이라 불리는 것이다. Django가 제공하는 매우 유용한 ORM이다.