python-tdd-book/textbook/chap5.md

12 KiB

Chapter 5. Saving User Input: Testing the Database

Benefit of TDD: iterative style of development.

Our target: take to-do item input from user and sent it to server

5.1 Wiring Up Our Form to Send a POST Request

To let browser send a standard HTML POST request, steps needed:

  1. Give <input> element a name= attribute
  2. Wrap it in a <form> tag with method="POST"

several tricks used to debug unexpected failure in functional test:

  • Add print statements, to show, for example, what the current page text is.
  • Improve the error message to show more info about the current state.
  • Manually visit the site yourself.
  • Use time.sleep to pause the test during execution (Chosen to be used)

error

  • Reason of failure: CSRF token missed.
  • What's CSRF token:
    • a unique, secret, unpredictable value that is generated by the server-side application and transmitted to the client in such a way that it is included in a subsequent HTTP request made by the client.

  • One of Django's CSRF protection methods: placing a little auto-generated token into each generated form, so the form can be identified as POST request coming from original site.

Embed CSRF token using `{% csrf_token %}

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

Result of running FT, page is layed with content but failed test, as server haven't wired to deal with POST request yet.

5.2 Processing a POST Request on the Server

Current home.html don't have action= attribute in <form>. Hence, it is submitting back to the / page (the same URL it was rendered from). It means the request is dealt by home_page function.

Add a test case into 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())

Test it will return error

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 name="item_text" id="id_new_item" placeholder="Enter a to-do item" />\n            <input type=\'hidden\' name=\'csrfmiddlewaretoken\' value=\'U3BKUu5Xl50FQMi8JNz5ZCCIkHKISJJ2QtVuLuZ3zdRmOWXh7hvwZfXIh76CQiN2\' />\n        </form>\n\n        <table id="id_list_table"></table>\n    </body>\n</html>'

We will pass variables from Python view code into HTML templates

5.3 Passing Python Variables to Be Rendered in the Template

  • Django's template syntax lets us include a python object in template using notation {{ ... }}
    • renderer will display the object as a string
    <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>
  • new_item_text is the variable name for the user input

Modify test case in lists/tests.py, assertTemplateUsed will check whether we are still using template

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

return error

    self.assertTemplateUsed(response, 'home.html')
  File "/home/jason/miniconda3/envs/python-tdd-book/lib/python3.6/site-packages/django/test/testcases.py", line 578, in assertTemplateUsed
    self.fail(msg_prefix + "No templates used to render the response")
AssertionError: No templates used to render the response

QUES: Why fail?

ANS: In lists/views.py, home_page function no longer return rendered page. When facing POST request, it return a HttpResponse

    if request.method == 'POST':
        return HttpResponse(request.POST['item_text'])
    return render(request, 'home.html')

QUES: How to fix it?

ANS: Modify home_page method to return rendered page.

QUES: How to use render function?

ANS: render function takes, (3rd arguments), a dictionary which maps template variable names to their values.

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

5.3.1 An Unexpected Failure

Running test as shown above will return error

ERROR: test_uses_home_template (lists.tests.HomePageTest)
...
  File "/home/jason/miniconda3/envs/python-tdd-book/lib/python3.6/site-packages/django/utils/datastructures.py", line 85, in __getitem__
    raise MultiValueDictKeyError(repr(key))
django.utils.datastructures.MultiValueDictKeyError: "'item_text'"

Error is from test_uses_home_template test case. We broke the code path where there is no POST request; i.e. explain below

  • test_can_save_a_POST_request get POST request with '/', data={'item_text': 'A new list item'}
  • test_uses_home_template get POST request with only '/', while request.POST['item_text'] cannot find it.

QUES: How to fix it?

ANS: type of request.POST is 'django.http.request.QueryDict' is a dictionary. So dictionary method dict.get can be used to supply a default value ''

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

...

5.4 Three Strikes and Refactor

Principle: Don't Repeat Yourself (DRY), or three strikes and refactor (事不过三); 同一个代码段重复三次以后就应该 remove duplication

  • QUES: What we do?
  • ANS: 将重复的 table = ... rows = ... self.assertIn ... 独立为 helper function (as shown below)
    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])

5.5 The Django ORM and Our First Model

  • Object-Relational Mapper (ORM) is a layer of abstraction of data stored in database (tables, rows, and columns)
  • ORM let use work with database using OOP.
    • Classes map to database tables
    • Attributes map to columns
    • Individual instance of the class is mapped to a row of data in database

Django comes with ORM. Using which, creating a new record in data is easy:

  1. creating an object
  2. assigning some attributes
  3. calling .save() function.

Django also provide API for:

  • querying the database using class attributes of object .object
  • use simplest possible query .all() to retrieves all records for the table
  • returned object is a list-like object called 'QuerySet'
    • Using 'QuerySet', we can extract individual objects

All these can be used in test case

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

5.5.1 Our First Database Migration

  • In Django, ORM's job is to model the database,
  • migration is a system that build the database. It has ability to add/remove tables and columns, based changes on models.py file. Like a version control system for the database.

Steps to update database:

  1. python manage.py makemigrations to create migrations/000x_initials.py
  2. Make migration via python manage.py migrate

5.5.2 The Test Gets Surprisingly Far

To pass the test, add class Item:

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

Then migrate the database again, and then we can pass the test

5.6 Saving the POST to the Database

Modify the POST request test case that view will save a new item to the database instead of just passing to response.

    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) # check that one new Item has been saved to the database
        new_item = Item.objects.first() # same as doing objects.all()[0]
        self.assertEqual(new_item.text, 'A new list item') # check that the item's text is correct
        
        self.assertIn('A new list item', response.content.decode())
        self.assertTemplateUsed(response, 'home.html')

First step to pass the test: modify home_page function to save request (including empty request just to '/')

def home_page(request):
    item = Item()
    item.text = request.POST.get('item_text', '')
    item.save()

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

To-Do List to modify code (sometimes make sure code pass is ok, modify later):

  • Don't save blank items for every request (empty request)
  • Code small: POST test is too long?
  • Display multiple items in the table
  • Support more than one list

Solve the first todo list by creating a UT:

    def test_only_saves_items_when_necessary(self):
        self.client.get('/')
        self.assertEqual(Item.objects.count(), 0)

Running test will result AssertionError: 1 != 0

We can pass this UT via modifying home_page:

def home_page(request):
    if request.method == 'POST':
        new_item_text = request.POST['item_text'] # use new_item_text to either hold POST contents or empty string
        Item.objects.create(text=new_item_text) # .object.create is shortcut for creating a new Item, without needing to call .save()
    else:
        new_item_text = ''

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

5.7 Redirect After a POST

A view func has 2 jobs:

  1. Processing user input (save to database)
  2. Returning an appropriate response

2nd one is our target in this section

"Always redirect after a POST", change test case to save a POST request. So redirect back to home page, instead of rendering a response with item in it

    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'], '/')
  • QUES: What mean redirect?
  • ANS: After sending POST, the returned response does not contain .content that rendered by a template, instead it's HTTP redirect that point to another URL

Modify views.py to pass this UT

def home_page(request):
    if request.method == 'POST':
        Item.objects.create(text=request.POST['item_text']) # Processing user input
        return redirect('/') # Redirect back to home.html

    return render(request=request, template_name='home.html')

5.7.1 Better Unit Testing Practice: Each Test Should Test One Thing

Note: good UT practice is that each test should only test one thing.

So, separate the test code into two:

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