วันอาทิตย์ที่ 22 กุมภาพันธ์ พ.ศ. 2558

Chapter 6. Getting to the Minimum Viable Site

Ensuring Test Isolation in Functional Tests

$ mkdir functional_tests
$ touch functional_tests/__init__.py 
 
สร้าง Folder functional_tests และข้างในมีไฟล์ __init__.py 
 
$ git mv functional_tests.py functional_tests/tests.py

ย้าย functional_tests.py ไปอยู่ใน functional_tests และเปลี่ยนชื่อเป็น test.py

├── db.sqlite3
├── functional_tests
│   ├── __init__.py
│   └── tests.py
├── lists
│   ├── admin.py
│   ├── __init__.py
│   ├── migrations
│   │   ├── 0001_initial.py
│   │   ├── 0002_item_text.py
│   │   ├── __init__.py
│   │   └── __pycache__
│   ├── models.py
│   ├── __pycache__
│   ├── templates
│   │   └── home.html
│   ├── tests.py
│   └── views.py
├── manage.py
└── superlists
    ├── __init__.py
    ├── __pycache__
    ├── settings.py
    ├── urls.py
    └── wsgi.py
 
functional_tests/tests.py

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

class NewVisitorTest(LiveServerTestCase):

    def setUp(self):
        [...]
 
แก้ตรง
 
    def test_can_start_a_list_and_retrieve_it_later(self):
        # Edith has heard about a cool new online to-do app. She goes
        # to check out its homepage
        self.browser.get(self.live_server_url) 
 

 
git status
git add functional_tests
git diff --staged -M
git commit
 

Running Just the Unit Tests

ตอนนี้ถ้าเรา run manage.py test Django จะ run ทั้ง functional และ unit tests ทั้งคู่เลย
 
 
 แต่ถ้าอยากรันแค่ unit tests ให้ใช้คำสั่ง python3 manage.py test lists
 
 
คำสั่งในการรัน FT จะเปลี่ยนเป็น
python3 manage.py test functional_tests
คำสั่งในการรัน Unit Test จะเปลี่ยนเป็น
python3 manage.py test lists

Small Design When Necessary 


เราต้องการสร้าง list ใหม่ เพื่อใส่itemเข้าไป1อย่าง และใช้ url สำหรับ list นี้
functional_tests/tests.py

inputbox.send_keys('Buy peacock feathers')
# When she hits enter, she is taken to a new URL, # and now the page lists "1: Buy peacock feathers" as an item in a # to-do list table inputbox.send_keys(Keys.ENTER) edith_list_url = self.browser.current_url self.assertRegex(edith_list_url, '/lists/.+') #1 self.check_for_row_in_list_table('1: Buy peacock feathers') # There is still a text box inviting her to add another item. She [...]


เมื่อมีผู้ใช้รายใหม่เข้ามาใช้เราต้องตรวจสอบว่า พวกเขาจะไม่เห็นรายการใดๆของ Edith’s items เมื่อเข้ามาแล้วต้องได้รับ url ที่เป็นเอกลักษณ์ของตนเอง

    [...]
    # The page updates again, and now shows both items on her list
    self.check_for_row_in_list_table('2: Use peacock feathers to make a fly')
    self.check_for_row_in_list_table('1: Buy peacock feathers')

    # Now a new user, Francis, comes along to the site.

    ## We use a new browser session to make sure that no information
    ## of Edith's is coming through from cookies etc #1
    self.browser.quit()
    self.browser = webdriver.Firefox()

    # Francis visits the home page.  There is no sign of Edith's
    # list
    self.browser.get(self.live_server_url)
    page_text = self.browser.find_element_by_tag_name('body').text
    self.assertNotIn('Buy peacock feathers', page_text)
    self.assertNotIn('make a fly', page_text)

    # Francis starts a new list by entering a new item. He
    # is less interesting than Edith...
    inputbox = self.browser.find_element_by_id('id_new_item')
    inputbox.send_keys('Buy milk')
    inputbox.send_keys(Keys.ENTER)

    # Francis gets his own unique URL
    francis_list_url = self.browser.current_url
    self.assertRegex(francis_list_url, '/lists/.+')
    self.assertNotEqual(francis_list_url, edith_list_url)

    # Again, there is no trace of Edith's list
    page_text = self.browser.find_element_by_tag_name('body').text
    self.assertNotIn('Buy peacock feathers', page_text)
    self.assertIn('Buy milk', page_text)

    # Satisfied, they both go back to sleep



Iterating Towards the New Design

สร้าง test ให้ redirect location หลังจากได้รับ POST


lists/tests.py

self.assertEqual(response.status_code, 302)
self.assertEqual(response['location'], '/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()
    return render(request, 'home.html', {'items': items})


จะ error ว่า เนื่องจากยังไม่มี list item ชื่อว่า the-only-list-in-the-world

Testing Views, Templates, and URLs Together with the Django Test Client

ในบทนี้เราจะใช้ unit tests เช็คความละเอียดของ URL และเช็ค views render templates ถูกต้องหรือไม่

A New Test Class

ไปที่ lists/tests.py เพิ่มคลาสใหม่ ListViewTest หลังจากนั้น copy method ที่ชื่อว่า test_home_page_displays_all_ list_items จาก HomePageTest มาใส่ไว้ในคลาสใหม่แทน 

lists/tests.py


class ListViewTest(TestCase):

    def test_displays_all_items(self):
        Item.objects.create(text='itemey 1')
        Item.objects.create(text='itemey 2')

        response = self.client.get('/lists/the-only-list-in-the-world/') #1

        self.assertContains(response, 'itemey 1') #2
        self.assertContains(response, 'itemey 2') #3

 
A New URL

URL ของเรายังไม่ได้สร้างจึงไปแก้ไขใน superlists/urls.py

superlists/urls.py

urlpatterns = patterns('',
    url(r'^$', 'lists.views.home_page', name='home'),
    url(r'^lists/the-only-list-in-the-world/$', 'lists.views.view_list',
        name='view_list'
    ),
    # url(r'^admin/', include(admin.site.urls)),
)


A New View Function

lists/views.py

def view_list(request):
    pass

 
lists/views.py

def view_list(request):
    items = Item.objects.all()
    return render(request, 'home.html', {'items': items})






Green? Refactor


เราสามารถลบ test_home_page_displays_all_list_items method เพราะมันไม่จำเป็น

A Separate Template for Viewing Lists

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):
        [...]


lists/views.py

def view_list(request):
    items = Item.objects.all()
    return render(request, 'list.html', {'items': items}) 



สร้างไฟล์ใหม่ที่ lists/templates/list.html ใช้คำสั่ง $ touch lists/templates/list.html



$ cp lists/templates/home.html lists/templates/list.html

home pageไม่จำเป็นต้องแสดงlist items มันเพียงต้องการ list inputใหม่ เพื่อให้เราสามารถลบบรรทัดจาก lists/templates/home.html และอาจปรับแต่งh1 ที่จะบอกว่า "Start a new To-Do list"
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

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



การรับ input item ตัวที่สองยังผิดอยู่ เพราะว่ารูปแบบใหม่ action= attribute หายไป เลยเป็นค่า default หมายความว่ามันจะส่งไปยัง url เดียวกัน

lists/templates/list.html

    <form method="POST" action="/">



$ git status
$ git add lists/templates/list.html
$ git diff
$ git commit -a


Another URL and View for Adding List Items

เปิดไฟล์ lists/tests.py ให้นำ methods  test_home_page_can_save_a_POST_request และ test_home_page_redirects_after_POST ไปใส่ในคลาสใหม่และเปลี่ยนชื่อตามข้างล่าง

A Test Class for New List Creation

lists/tests.py

class NewListTest(TestCase):

    def test_saving_a_POST_request(self):
        request = HttpRequest()
        request.method = 'POST'
        [...]

    def test_redirects_after_POST(self):
        [...]

แล้วเปลี่ยนcode ตามหนังสือ

class NewListTest(TestCase):

    def test_saving_a_POST_request(self):
        self.client.post(
            '/lists/new',
            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(
            '/lists/new',
            data={'item_text': 'A new list item'}
        )
        self.assertEqual(response.status_code, 302)
        self.assertEqual(response['location'], '/lists/the-only-list-in-the-world/')

 

A URL and View for New List Creation

urlpatterns = patterns('',
    url(r'^$', 'lists.views.home_page', name='home'),
    url(r'^lists/the-only-list-in-the-world/$', '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)),
)

lists/views.py

def new_list(request):
    pass


Then we get "The view lists.views.new_list didn’t return an HttpResponse object". (This is getting rather familiar!) We could return a raw HttpResponse, but since we know we’ll need a redirect, let’s borrow a line from home_page:

lists/views.py

def new_list(request):
    return redirect('/lists/the-only-list-in-the-world/')




lists/views.py

def new_list(request):
    Item.objects.create(text=request.POST['item_text'])
    return redirect('/lists/the-only-list-in-the-world/')


lists/tests.py

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


Removing Now-Redundant Code and Tests

viewของเราตอนนี้ส่วนใหญ่ทำงานที่ home_page เราควรจะที่จะลดความซับซ้อนของมัน ลองเอาส่วนของ if request.method == 'POST' ออกแล้วดูการเปลี่ยนแปลง

lists/views.py

def home_page(request):
    return render(request, 'home.html') 

 

เมื่อเราเอาการทดสอบ test_home_page_only_saves_ items_when_necessary ออกไปแล้วลองรัน UT
ผลคือ OK

Pointing Our Forms at the New URL

เรามี 2 urlที่จะต้องแก้ไขใน home.html และ lists.html
lists/templates/home.html, lists/templates/list.html.

    <form method="POST" action="/lists/new">

Adjusting Our Models

lists/tests.py เพิ่มลด ตาม code ด้านล่าง

[...]
 from django.template.loader import render_to_string
 from django.test import TestCase
-from lists.models import Item
+from lists.models import Item, List
 from lists.views import home_page

 class HomePageTest(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_)


เมื่อเราไปเพิ่ม lists/models.py
 
class List(object):
    pass


หลังจากนั้นแก้เป็น

class List(models.Model):
    pass




A Foreign Key Relationship

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='')

 






lists/models.py

from django.db import models

class List(models.Model):
    pass

class Item(models.Model):
    text = models.TextField(default='')
    list = models.ForeignKey(List, default=None)




Adjusting the Rest of the World to Our New Models

 

เมื่อลองรัน
$ python3 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_redirects_after_POST (lists.tests.NewListTest)
django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id
[...]
ERROR: test_saving_a_POST_request (lists.tests.NewListTest)
django.db.utils.IntegrityError: NOT NULL constraint failed: lists_item.list_id

Ran 7 tests in 0.021s
FAILED (errors=3)


แก้ไฟล์ 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/')


$ git status # 3 changed files, plus 2 migrations
$ git add lists
$ git diff --staged
$ git commit

Each List Should Have Its Own URL

 

lists/tests.py

class ListViewTest(TestCase):

    def test_uses_list_template(self):
        list_ = List.objects.create()
        response = self.client.get('/lists/%d/' % (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('/lists/%d/' % (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')


Capturing Parameters from URLs

superlists/urls.py

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

เมื่อลองรัน UT จะพบว่า
ERROR: test_displays_only_items_for_that_list (lists.tests.ListViewTest)
ERROR: test_uses_list_template (lists.tests.ListViewTest)
ERROR: test_redirects_after_POST (lists.tests.NewListTest)
[...]
TypeError: view_list() takes 1 positional argument but 2 were given

แก้ไข parameterใน lists/views.py

def view_list(request, list_id):
    [...]


lists/views.py

def view_list(request, list_id):
    list_ = List.objects.get(id=list_id)
    items = Item.objects.filter(list=list_)
    return render(request, 'list.html', {'items': items})

Adjusting new_list to the New World

เมื่อลองรัน UT
 ERROR: test_redirects_after_POST (lists.tests.NewListTest)
ValueError: invalid literal for int() with base 10:
'the-only-list-in-the-world'

lists/tests.py แก้ code ใน function ใหม่เป็น

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, '/lists/%d/' % (new_list.id,))

lists/views.py

def new_list(request):
    list_ = List.objects.create()
    Item.objects.create(text=request.POST['item_text'], list=list_)
    return redirect('/lists/%d/' % (list_.id,))



One More View to Handle Adding Items to an Existing List

 

lists/tests.py

class NewItemTest(TestCase):

    def test_can_save_a_POST_request_to_an_existing_list(self):
        other_list = List.objects.create()
        correct_list = List.objects.create()

        self.client.post(
            '/lists/%d/add_item' % (correct_list.id,),
            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(
            '/lists/%d/add_item' % (correct_list.id,),
            data={'item_text': 'A new item for an existing list'}
        )

        self.assertRedirects(response, '/lists/%d/' % (correct_list.id,))


Beware of Greedy Regular Expressions!

 

superlists/urls.py

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




The Last New URL

 

superlists/urls.py

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


The Last New View

 

lists/views.py

def add_item(request):
    pass

รัน UT

TypeError: add_item() takes 1 positional argument but 2 were given


lists/views.py

def add_item(request, list_id):
    pass

รัน UT

ValueError: The view lists.views.add_item didn't return an HttpResponse object.
It returned None instead.

 

lists/views.py

def add_item(request, list_id):
    list_ = List.objects.get(id=list_id)
    return redirect('/lists/%d/' % (list_.id,))


รัน UT

    self.assertEqual(Item.objects.count(), 1)
AssertionError: 0 != 1

 

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('/lists/%d/' % (list_.id,))


รัน UT

Ran 9 tests in 0.022s
OK

 

But How to Use That URL in the Form?

 lists/templates/list.html

 <form method="POST" action="/lists/{{ list.id }}/add_item">

 

For that to work, the view will have to pass the list to the template. Let’s create a new unit test in ListViewTest:

lists/tests.py


    def test_passes_correct_list_to_template(self):
        other_list = List.objects.create()
        correct_list = List.objects.create()
        response = self.client.get('/lists/%d/' % (correct_list.id,))
        self.assertEqual(response.context['list'], correct_list)

 

lists/views.py

def view_list(request, list_id):
    list_ = List.objects.get(id=list_id)
    return render(request, 'list.html', {'list': list_})

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


A Final Refactor Using URL includes

$ cp superlists/urls.py lists/

superlists/urls.py

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

lists/urls.py

from django.conf.urls import patterns, url

urlpatterns = patterns('',
    url(r'^(\d+)/$', 'lists.views.view_list', name='view_list'),
    url(r'^(\d+)/add_item$', 'lists.views.add_item', name='add_item'),
    url(r'^new$', 'lists.views.new_list', name='new_list'),
)


วันพุธที่ 4 กุมภาพันธ์ พ.ศ. 2558

Chapter 5. Saving User Input

Wiring Up Our Form to Send a POST Request

จากบทที่แล้วเราไม่สามารถบันทึกค่า input ของผู้ใช้ได้


ที่ lists/templates/home.html ให้เพิ่ม form เข้าไปครอบ input

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




เมื่อการTestล้มแหลวแบบนึกไม่ถึง มีหลายวิธ๊ที่จะแก้ไขดังนี้
     - ให้เพิ่ม print statements เพื่อดูข้อความerrorที่โปรแกรมบอก
     - ปรับปรุงข้อผิดพลาดนั้น
     - ดูเว็บไซต์ ด้วยตัวเอง
     - ใช้คำสั่ง time.sleep เพื่อหยุดการทดสอบระหว่างการทำงาน

functional_tests.py เพิ่ม 
[...]
    inputbox.send_keys(Keys.ENTER)

    import time
    time.sleep(10)

    table = self.browser.find_element_by_id('id_list_table')

[...]


เกิด Error เนื่องจากการตรวจสอบ CSRF ล้มเหลว


lists/templates/home.html

<form method="POST">
    <input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />
    {% csrf_token %}
</form>


การใส่ {% csrf_token %} ลงไปจะเปรียบเสมือนเรากำหนดค่า<input type="hidden"> ทำให้เราสามารถ render หน้าเว็บได้

เนื่องจากเรายังมีคำสั่ง time.sleep อยู่เราจะเห็นว่า web page ที่เราเรียกนั้นไม่มีปัญหาแล้ว



จากนั้นให้ลบคำสั่ง time.sleep ออกได้เลย

Processing a POST Request on the Server

ทดสอบดูว่า home.html จะสามารถบันทึกค่า POST Requestได้หรือไม่ lists/tests.py เพิ่ม code ข้างล่างเข้าไป

from django.template.loader import render_to_string
from django.core.urlresolvers import resolve
from django.test import TestCase
from django.http import HttpRequest
from lists.views import home_page

class HomePageTest(TestCase):

    def test_root_url_resolves_to_home_page_view(self):
        found = resolve('/')
        self.assertEqual(found.func, home_page)


    def test_home_page_returns_correct_html(self):
        request = HttpRequest()
        response = home_page(request)
        expected_html = render_to_string('home.html')
        self.assertEqual(response.content.decode(), expected_html)

    def test_home_page_can_save_a_POST_request(self):
        request = HttpRequest()
        request.method = 'POST'
        request.POST['item_text'] = 'A new list item'

        response = home_page(request)

        self.assertIn('A new list item', response.content.decode())



 


เราต้องไปสร้าง code path สำหรับ POST Request 

ที่ 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')


Passing Python Variables to Be Rendered in the Template

การแสดงชื่อตัวแปรใน template จะใช้สัญลักษณ์ {{ ... }}

lists/templates/home.html


[...]
<body>
    <h1>Your To-Do list</h1>
    <form method="POST">
        <input name="item_text" id="id_new_item" placeholder="Enter a to-do item" />
        {% csrf_token %}
    </form>

    <table id="id_list_table">
        <tr><td>
{{ new_item_text }}</td></tr>
    </table>
</body>

[...]


จากนั้นจะใช้ Function render_to_string โดย parameter ตัวแรกคือ template ส่วนตัวที่สองเป็นการ map กันระหว่างชื่อตัวแปรกับค่าของตัวแปร
lists/tests.py เพิ่ม code ส่วนล่างเข้าไป

[...]     
    self.assertIn('A new list item', response.content.decode())
    expected_html = render_to_string(
        'home.html',
        {'new_item_text':  'A new list item'}
    )
    self.assertEqual(response.content.decode(), expected_html)





จากนั้นไปแก้ lists/views.py

def home_page(request):
    return render(request, 'home.html', {
        'new_item_text': request.POST['item_text'],
    })




เกิด KeyError ขึ้น แก้ไขโดยใช้คำสั่ง .get() ไปแก้ที่ lists/views.py

def home_page(request):
    return render(request, 'home.html', {
        'new_item_text':
request.POST.get('item_text', ''),
    })



ยังไม่มี item ใน table

เราต้องการให้ error แสดงรายละเอียดมากกว่านี้ โดยใช้เทคนิค FT debugging โดยแสดงค่าที่มีอยู่ใน table functional_tests.py

[...]
    self.assertTrue(
        any(row.text == '1: Buy peacock feathers' for row in rows),
        "New to-do item did not appear in table -- its text was:\n%s" % (
            table.text,
        )
    )

[...]




ที่ functional_tests.py ใส่ code ด้านล่างแทน self.assertTrue อันเก่าที่มี 6 บรรทัด

self.assertIn('1: Buy peacock feathers', [row.text for row in rows]) 



เนื่องจากใน functional_tests นั้นเราใส่เลข 1 แต่ใน template ของเรานั้นไม่มีเลข 1
lists/templates/home.html เพิ่มเลข 1: เข้าไป เพราะ FT ต้องการให้เราระบุเลข 1 ที่จุดเริ่มต้นของรายการ

    <tr><td>1: {{ new_item_text }}</td></tr>


เมื่อลองรันดูก็จะผ่าน



ที่นี้ลองเพิ่มการส่งค่าให้ template โดยใช้การ copy and paste
functional_tests.py

from selenium import webdriver
from selenium.webdriver.common.keys import Keys
import unittest

class NewVisitorTest(unittest.TestCase):

    def setUp(self):
        self.browser = webdriver.Firefox()
        self.browser.implicitly_wait(3)

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

    def test_can_start_a_list_and_retrieve_it_later(self):
        # Edith has heard about a cool new online to-do app. She goes
        # to check out its homepage
        self.browser.get('http://localhost:8000')

        # She notices the page title and header mention 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)

        # She is invited to enter a to-do item straight away
        inputbox = self.browser.find_element_by_id('id_new_item')
        self.assertEqual(
                inputbox.get_attribute('placeholder'),
                'Enter a to-do item'
        )

        # She types "Buy peacock feathers" into a text box (Edith's hobby
        # is tying fly-fishing lures)
        inputbox.send_keys('Buy peacock feathers')

        # When she hits enter, the page updates, and now the page lists
        # "1: Buy peacock feathers" as an item in a to-do list table
        inputbox.send_keys(Keys.ENTER)
        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])

        # There is still a text box inviting her to add another item. She
        # enters "Use peacock feathers to make a fly" (Edith is very
        # methodical)
        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)
 
        # The page updates again, and now shows both items on her list
        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]
        )

        # Edith wonders whether the site will remember her list. Then she sees
        # that the site has generated a unique URL for her -- there is some
        # explanatory text to that effect.
        self.fail('Finish the test!')

        # She visits that URL - her to-do list is still there.

if __name__ == '__main__':
    unittest.main(warnings='ignore')




เนื่องจาก temaplate ยังไม่รองรับเลข 2


Three Strikes and Refactor

ไม่ควรใช้วิธีแก้ copy and paste แบบใน FT ควรใช้ function หากอยู่ในไฟล์เดียวกัน หรือใช้ import ในกรณีที่อยู่คนละไฟล์


ทำการ refactor functional_tests.py

from selenium import webdriver
from selenium.webdriver.common.keys import Keys
import unittest

class NewVisitorTest(unittest.TestCase):

    def setUp(self):
        self.browser = webdriver.Firefox()
        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 has heard about a cool new online to-do app. She goes
        # to check out its homepage
        self.browser.get('http://localhost:8000')

        # She notices the page title and header mention 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)

        # She is invited to enter a to-do item straight away
        inputbox = self.browser.find_element_by_id('id_new_item')
        self.assertEqual(
                inputbox.get_attribute('placeholder'),
                'Enter a to-do item'
        )

        # She types "Buy peacock feathers" into a text box (Edith's hobby
        # is tying fly-fishing lures)
        inputbox.send_keys('Buy peacock feathers')

        # When she hits enter, the page updates, and now the page lists
        # "1: Buy peacock feathers" as an item in a to-do list table
        inputbox.send_keys(Keys.ENTER)
        self.check_for_row_in_list_table('1: Buy peacock feathers')

        # There is still a text box inviting her to add another item. She
        # enters "Use peacock feathers to make a fly" (Edith is very
        # methodical)
        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)

        # The page updates again, and now shows both items on her list
        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')

        # Edith wonders whether the site will remember her list. Then she sees
        # that the site has generated a unique URL for her -- there is some
        # explanatory text to that effect.
        self.fail('Finish the test!')

        # She visits that URL - her to-do list is still there.
if __name__ == '__main__':
    unittest.main(warnings='ignore')

 

error ยังคงเหมือนเดิมคือ template ยังไม่รองรับเลข 2

 

The Django ORM and Our First Model

สร้าง database  ด้วย Django ORM

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

 

เนื่องจากเรายังไม่มี item จึงต้องไปสร้าง item ที่เป็น model

lists/models.py

from django.db import models

class Item(object):
    pass

 

ไม่มี attribute save เราจึงสืบทอดคลาสมาจาก Model

lists/models.py

from django.db import models

class Item(models.Model):
    pass 

 

Our First Database Migration

เมื่อลองรัน test ดู

เนื่องจากการสร้าง ORM เป็นแค่การ model Database ยังไม่เป็น Database จริงๆ



โดยคำสั่ง python3 manage.py makemigrations จะสร้างไฟล์ 0001_initial.py ขึ้นมา เป็น Migration เริ่มต้น

The Test Gets Surprisingly Far

ลองทดสอบดู

เนื่องจาก Django ไม่รู้ว่าเรามี table ที่เก็บ text ของเราไว้เราจึงต้องสร้าง text ใหม่ของเราขึ้นมาเอง


lists/models.py

class Item(models.Model):
    text = models.TextField()


A New Field Means a New Migration


เมื่อทดสอบรันดูก็จะเกิด Error ขึ้นว่า

django.db.utils.OperationalError: no such column: lists_item.text

เพราะว่าเราได้ไปเพิ่ม field ใหม่ใน database ของเราซึ่งหมายความว่าเราต้องสร้าง Migration ใหม่สำหรับ field ใหม่ด้วย

ให้เลือก 2 เพื่อเพิ่มค่าเริ่มต้นของ field ไปใน models.py

lists/models.py


class Item(models.Model):
    text = models.TextField(default='')

 










ที่นี้เราก็จะได้ไฟล์ 0002_item_text.py ขึ้นมา


$ git status
$ git diff
$ git add lists
$ git commit -m "Model for list Items and associated migration"


Saving the POST to the Database


ก่อนอื่นเรามาปรับปรุง code ของเราโดยการเพิ่ม 3 บรรทัดใหม่เข้าไป



lists/tests.py



def test_home_page_can_save_a_POST_request(self):

    request = HttpRequest()

    request.method = 'POST'

    request.POST['item_text'] = 'A new list item'



    response = home_page(request)



    self.assertEqual(Item.objects.count(), 1)  #1

    new_item = Item.objects.first()  #2

    self.assertEqual(new_item.text, 'A new list item')  #3


    self.assertIn('A new list item', response.content.decode())
    expected_html = render_to_string(
        'home.html',
        {'new_item_text':  'A new list item'}
    )
    self.assertEqual(response.content.decode(), expected_html)

1 เช็คว่า item ถูกบันทึกลง database.objects.count()ซึ่งเป็นตัวย่อของ objects.all().count()

2 objects.first() เป็นเหมือน objects.all()[0].

3 เช็คว่าค่าที่รับมานั้นถูกต้องหรือไม่


เมื่อลองรันดู



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

    return render(request, 'home.html', {
        'new_item_text': request.POST.get('item_text', ''),
    })



หลังจากนั้นทำการปรับปรุงแก้ไข code

lists/views.py. 

    return render(request, 'home.html', {
        'new_item_text': item.text
    })

Let’s have a little look at our scratchpad. I’ve added a couple of the other things that are on our mind:




Let’s start with the first one. We could tack on an assertion to an existing test, but it’s best to keep unit tests to testing one thing at a time, so let’s add a new one:

lists/tests.py

class HomePageTest(TestCase):
    [...]

    def test_home_page_only_saves_items_when_necessary(self):
        request = HttpRequest()
        home_page(request)
        self.assertEqual(Item.objects.count(), 0)


lists/views.py. 

def home_page(request):
    if request.method == 'POST':
        new_item_text = request.POST['item_text']  #1
        Item.objects.create(text=new_item_text)  #2
    else:
        new_item_text = ''  #3

    return render(request, 'home.html', {
        'new_item_text': new_item_text,  #4
    })
1 3 4 ใช้ตัวแปร new_item_text เพื่อเก็บค่า POST หรือ ให้เป็นคำว่าง

2 .objects.create ใช้สร้างรายการใหม่โดยไม่ต้องเรียกใช้ .save()




Redirect After a POST

หลังจากเรารับค่า POST มาแล้ว มันควรจะส่งค่ากลับไปยัง home page แทนที่จะ render ออกมา
lists/tests.py

    def test_home_page_can_save_a_POST_request(self):
        request = HttpRequest()
        request.method = 'POST'
        request.POST['item_text'] = 'A new list item'

        response = home_page(request)

        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'], '/')

การเปลี่ยนเส้นทางควรมี code 302 และชี้ browser ไปยังที่อยู่ใหม่

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

ลองทดสอบรันอีกครั้ง





Better Unit Testing Practice: Each Test Should Test One Thing

จากที่หนังสือได้บอกว่า Unit Test ควรสั้นและแยกเป็นส่วนๆ เพื่อให้ง่ายต่อการอ่านและการแก้ไข bug

lists/tests.py

    def test_home_page_can_save_a_POST_request(self):
        request = HttpRequest()
        request.method = 'POST'
        request.POST['item_text'] = 'A new list item'

        response = home_page(request)

        self.assertEqual(Item.objects.count(), 1)
        new_item = Item.objects.first()
        self.assertEqual(new_item.text, 'A new list item')


    def test_home_page_redirects_after_POST(self):
        request = HttpRequest()
        request.method = 'POST'
        request.POST['item_text'] = 'A new list item'

        response = home_page(request)

        self.assertEqual(response.status_code, 302)
        self.assertEqual(response['location'], '/')

เมื่อทดลองรันดู

 
Rendering Items in the Template

เราจะทำการ check ว่า template สามารถแสดงรายชื่อ item ได้หรือไม่


lists/tests.py

class HomePageTest(TestCase):
    [...]

    def test_home_page_displays_all_list_items(self):
        Item.objects.create(text='itemey 1')
        Item.objects.create(text='itemey 2')

        request = HttpRequest()
        response = home_page(request)

        self.assertIn('itemey 1', response.content.decode())
        self.assertIn('itemey 2', response.content.decode())


แก้ template ให้แสดงผลได้หลายๆแถว โดยใช้ คำสั่ง {% for .. in .. %}

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()
    return render(request, 'home.html', {'items': items})


เมื่อรัน Unit test ก็จะผ่าน แต่รัน FT ไม่ผ่าน


 
โดยในหนังสือจะมีเทคนิคการแก้ ด้วยการเข้าไปใน http://localhost:8000



Creating Our Production Database with migrate

เราต้องทำการสร้าง database ของจริงขึ้นมาเพราะ database ที่เราใช้นั้นเป็น database พิเศษ สำหรับใช้ test

superlists/settings.py

[...]
# Database
# https://docs.djangoproject.com/en/1.7/ref/settings/#databases

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
    }
}


We’ve told Django everything it needs to create the database, first via models.py and then when we created the migrations file. To actually apply it to creating a real database, we use another Django Swiss Army knife manage.py command, migrate:




Now we can refresh the page on localhost, see that our error is gone, and try running the functional tests again:[6]




ใกล้เคียงความถูกต้องแล้ว เหลือแค่กำหนดตัวเลขให้ถูกต้อง

lists/templates/home.html

    {% for item in items %}
        <tr><td>{{ forloop.counter }}: {{ item.text }}</td></tr>
    {% endfor %}

ลองทดสอบดูอีกครั้ง





ถ้าต้องการลบและสร้าง database ใหม่ให้ใช้คำสั่ง

$ rm db.sqlite3
$ python3 manage.py migrate --noinput

$ git add lists
$ git commit -m "Redirect after POST, and show all items in template"