วันพฤหัสบดีที่ 5 กุมภาพันธ์ พ.ศ. 2558

Test-Driven Development with Python Chapter 5

Saving User Input


Wiring Up Our Form to Send a POST Request

-ทดลองใช้ standard HTML POST request โดยสามารถใช้ HTML5 และ JavaScript
 การส่ง Post request ให้ browser ทำได้โดยการให้ <form method ="POST"> ครอบคลุม <input> element a name= attribute
 ปรับปรุง template at lists/templates/home.html ดังนี้

ทอลองรันจะขึ้น error ดังนี้
$ python3 functional_tests.py
[...]
Traceback (most recent call last):
  File "functional_tests.py", line 39, in
test_can_start_a_list_and_retrieve_it_later
    table = self.browser.find_element_by_id('id_list_table')
[...]
selenium.common.exceptions.NoSuchElementException: Message: 'Unable to locate
element: {"method":"id","selector":"id_list_table"}' ; Stacktrace [...]


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

เพิ่มคำสั่ง time.sleep ที่ไฟล์ functional_tests.py เพื่อหยุดการทดสอบระหว่างการประมวลผล
ทดลองรันไฟล์ functional_tests.py อีกครั้งจะทำการเรียกหน้าต่าง browser ขึ้นมาแสดงผลดังนี้
จะเห็นว่า browser ที่เราเรียกขึ้นมาจะมีการแสดงข้อมูลการแก้ไขจุดบกพร้องของ Django

-Django  จำเป็นต้องสร้าง token เพื่อป้องกันการโจมตีจาก CSRF เพื่อที่จะเพิ่ม CSRF token จำเป็นจะต้องใช้ template tag {% … %}
-CSRF คือ การปลอมแปลงคำขอระหว่างเว็บไซต์ (Cross Site Request Forgery)  คือ ภัยคุกคามประเภทหนึ่งทางเว็บไซต์ที่เกิดจากการที่ผู้ประสงค์ร้ายลักลอบปลอม คำสั่งข้อมูลให้เสมือนเป็นคำสั่งจากเจ้าของบัญชีจริงเพื่อติดต่อกับระบบ ทำให้ระบบเชื่อและเข้าใจว่าเจ้าของบัญชีนั้น
-แก้ไขไฟล์ lists/templates/home.html. 

  


 Django จะ render <input type="hidden"> ที่ประกอบด้วย CSRF_token เป็นการสร้าง token เพื่อป้องกันการโจมตีจาก CSRF ทำให้เรารันโปรแกรมได้อย่างปกติ
ทดลองรันโปรแกรม  จะเห็นว่า web page ที่เราเรียกนั้นไม่มีปัญหาแล้ว


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

Processing a POST Request on the Server

 แก้ไขไฟล์ lists/tests.py โดยการเพิ่ม method to HomePage Tast ดังนี้


ทดลองรัน
แก้ไขไฟล์ที่ lists/views.py


Passing Python Variables to Be Rendered in the Template

which is to pass variables from our Python view code into HTML templates
 การแสดงค่าตัวแปรจากโค้ด python บน template html  Django Template Tag จะใช้สัญลักษณ์ {{ชื่อตัวแปร}}
แก้ไขไฟล์ lists/templates/home.html



แก้ไขไฟล์ lists/tests.py เพิ่มcode ส่วนล่างเข้าไป
โดยการส่งค่ามายัง template จากตัว Unit Test นั้นจะใช้คำสั่ง render_to_string

ตัว parameter แรกคือ template ที่จะส่งไป ตัวหลังคือการ map ระหว่างชื่อของตัวแรก ('new_item_text') กับค่าของตัวแปร ('A new list item')
ทดลองรัน
 แก้ไขไฟล์ lists/views.py

ทดลองรัน  unit test จะเกิด Key Error ขึ้น
ให้แก้ไขไฟล์ lists/views.py


ทอลองรัน unit test อีกครั้งก็จะผ่านแบบไม่มี error แต่เมื่อรัน functional_tests จะขึ้นerror
เราต้องการข้อมูลมากกว่านี้จึงใช้เทคนิคการ debugging FT คือ การแก้ให้ error message แสดงรายละเอียดมากขึ้น เช่น print สิ่งที่มีอยู่ใน 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,
        )

    )

[...]
ยังมี error อยู่ให้แก้ไขโค้ดจากตัวอย่างด้านบน ให้เหลือเพียง
self.assertIn('1: Buy peacock feathers', [row.text for row in rows]) 


แต่ยังเกิด error เนื่องจากว่า


เนื่องจากค่าตัวแปลที่เราจะส่งให้ template จาก functional_tests นั้นไม่มี '1:' แต่ในfunctional_tests ของเรานั้นมี '1:' ให้ไปที่lists/templates/home.html เพิ่ม'1:' เข้าไป เพราะ FT ต้องการให้เราระบุ '1:' ที่จุดเริ่มต้นของรายการ
 <tr><td>1: {{ new_item_text }}</td></tr>
เมื่อลองรัน functional test ก็จะผ่าน
ทดลองเพิ่มการส่งค่าให้ template จาก functional test โดยการ copy&paste แต่เปลี่ยนจาก '1: Buy peacock feathers' เป็น '2: Use peacock feathers to make a fly'
 # 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.

เมื่อทดลองรัน
จะ error เนื่องจาก template ยังไม่รองรับเลข2

Three Strikes and Refactor

หลักการของ DRY (Don't Repeat Yourself (DRY)) คือ เมื่อมีการใช้คำสั่งเดิมๆ ซ้ำๆไม่ว่าจะอยู่ในไฟล์เดียวกันหรืออยู่คนละไฟล์ก็ไม่ควรใช้วิธีการ copy&paste เพราะหากโค้ดมีการผิดพลาดจำเป็นที่จะต้องไปแก้หลายที่จึงควรใช้การเรียกใช้ ฟังก์ชัน(หากอยู่ในไฟล์เดียวกัน) หรือการ import หากอยู่คนละไฟล์
 แก้ไขไฟล์ functional_tests.py เพื่อทำการ Refactor เพิ่มcode ในส่วนนี้เข้าไป
[...]
    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
   [...]
        # 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!')
[...]

      ทดลองรัน functional_tests ก็ยังขึ้น Error เหมือนเดิม

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

The Django ORM and Our First Model

 การสร้าง Model ของ Database ด้วย Django ORM 
แก้ไขไฟล์  lists/tests.py โดยการเพิ่มcode ด้านล้าง

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

ทดลองรัน

แต่เมื่อรันแล้วจะ error ว่า import item ไม่ได้ เนื่องจากเรายังไม่มี item ดังนั้นเราจะต้องไปสร้าง item ซึ่งเป็น model ของ database

เริ่มต้นการสร้าง model ไปที่ไฟล์ lists/models.py ใส่code ตามนี้
 

from django.db import models

class Item(object):
    pass

ทำการรัน unit test 

เมื่อทำการรันจะได้ error ว่า Item ไม่มี attribute ชื่อว่า save จึงต้องสืบทอดมาจาก class model ของ Django โดยแก้เป็นโค้ดดังนี้   lists/models.py

from django.db import models

class Item(models.Model):
    pass

ทดลองรัน

เนื่องจากการสร้าง Django ORM เป็นเพียงแค่การ model Database แต่ยังไม่ได้เป็น Database จริงๆจึงมีอีกขั้นตอนนึงในการสร้าง Database เรียกว่า Migrations 

Our First Database Migration

Migrations   ซึ่งจะเป็นตัวช่วยในการจัดการกับ Database ทั้งการเพิ่มข้อมูลเข้า การดึงข้อมูลออก แม้แต่การทำตัวเป็นเหมือน Version Control สำหรับ database สามารถเรียกคือข้อมูลเดิมได้
โดยการสร้าง Migrations ใช้คำสั่ง
python3 manage.py makemigrations
  โดยจะสร้างไฟล์ 0001_initial.py เป็น Migrations เริ่มต้น สำหรับ model Item

The Test Gets Surprisingly Far

เมื่อลอง test app 'list' ใช้คำสั่ง python3 manage.py test lists


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

ไปที่ไฟล์ lists/models.py เพิ่ม code
class Item(models.Model):
    text = models.TextField()

ทดลองรันก็จะเกิด error ขึ้นว่า
django.db.utils.OperationalError: no such column: lists_item.text
 เพราะว่าเรามี field ใหม่(text field) ใน database เราจึงต้องสร้าง Migrates อันใหม่สำหรับ field อันใหม่ด้วย โดยใช้คำสั่ง python3 manage.py makemigrations
 โดยให้เลือกตัวเลือก 2 คือ ให้เรา add ค่าเริ่มต้นของ field
ไปในไฟล์ lists/models.py

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

ใช้คำสั่งสร้าง Migrations อีกครั้ง 


คราวนี้สามารถสร้างได้เป็นไฟล์ 0002_item_text.py

ทดลอง test app 'list' ใช้คำสั่ง python3 manage.py test lists จะรันผ่าน คือสามารถส่งข้อมูลไปยัง database ได้

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

Saving the POST to the Database

ก่อนอื่นสร้างตัว Unit Test สำหรับทดสอบการบันทึกข้อมูลก่อน ใน test.py โดยการเพิ่ม 3 บรรทัดใหม่เข้าไป



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 เช็คว่าค่าที่รับมานั้นถูกต้องหรือไม่
ทดลองรัน 
 จะเห็นว่าขึ้น error ตามภาพที่ได้ ไฮไลท์เอาไว้
ปรับ views.py ให้เข้ากับตัว test
from django.http import HttpResponse
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',''),
        })

 ทดลองรัน
เมื่อ test Unit Test ก็จะผ่าน 
ดังนั้นก็จะเริ่ม refactoring ต่อไป
แก้โค้ด views.py ดังนี้
   return render(request, 'home.html', {
          'new_item_text': item.text
        })
 
แก้ไขโค๊ด 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)
เมื่อลองรัน Unit Test จะฟ้อง 1 != 0 failure 
ตามหนังสือให้แก้ใน 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()


 เมื่อรัน Unit Test ก็จะผ่าน


Redirect After a POST
เมื่อเรารับค่ามาจาก POST แล้ว แทนที่จะ render ผลการตอบสนองกลับไป มันควรจะเปลี่ยนเส้นทาง(Redirect)ส่งค่ากลับ ไปยัง home page
เริ่มต้นด้วยการเขียน Unit Test
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'], '/')
 ทดลองรัน 
การเปลี่ยนเส้นทางควรมี HTTP Status 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'], '/')
 [...]

แยกเป็น test สำหรับการรับข้อมูลเข้ามา และการ test Redirect เมื่อทดลองรันก็จะผ่าน
Rendering Items in the Template 
ต่อไปหนังสือต้องการทำให้ app lists นี้สามารถแสดงข้อมูลทั้งหมดที่อยู่ใน database
ดังนั้นเริ่มจากการสร้าง Unit Test ก่อน
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())
  
เมื่อรันก็จะ error

AssertionError: 'itemey 1' not found in '<html>\n    <head>\n [...]
แก้ template ให้สามารถแสดงผลได้หลายๆแถว ในตารางโดยการใช้ Template Tag for loop เพื่อสร้างตารางที่แสดงข้อมูลใน lists ทั้งหมด คำสั่ง {% for .. in .. %}
 lists/templates/home.html
<table id="id_list_table">
    {% for item in items %}
        <tr><td>1: {{ item.text }}</td></tr>
    {% endfor %}
</table>
เมื่อรันก็ยังไม่ผ่านจึงจะไปทำการปรับใน 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 ก็จะผ่าน แต่เมื่อลองรัน Functional Test จะเกิด error

 




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


Creating Our Production Database with migrate

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

 เมื่อลองรัน Functional Test จะยัง error
AssertionError: '2: Use peacock feathers to make a fly' not found in ['1: Buy
peacock feathers', '1: Use peacock feathers to make a fly']

 เนื่องจาก Templates ของเรายังไม่เรารับการเปลี่ยนค่าตัวเลข '1: ', '2: ',...

ดังนั้นเราจะใช้ Template Tag ในการช่วยดังนี้
lists/templates/home.html


    {% for item in items %}
        <tr><td>{{ forloop.counter }}: {{ item.text }}</td></tr>
    {% endfor %}
เมื่อรับ Functional Test ก็จะผ่าน
 
หากต้องการลบ database ใช้คำสั่ง
rm db.sqlite3
ถ้าจะสร้าง database เปล่าใหม่ใช้คำสั่ง
python3 manage.py migrate --noinput
 
 

ไม่มีความคิดเห็น:

แสดงความคิดเห็น