diff --git a/textbook/chap5.md b/textbook/chap5.md new file mode 100644 index 0000000..e0f120f --- /dev/null +++ b/textbook/chap5.md @@ -0,0 +1,283 @@ +# 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 `` element a `name=` attribute +2. Wrap it in a `
` 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](img/5-1.jpg) + +* 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 %} + +```html + + + {% csrf_token %} +
+``` + +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 `
`. 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` + +```python + 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 '\n \n To-Do lists\n \n \n

Your To-Do list

\n \n \n \n
\n\n
\n \n' +``` + +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 + +```html + +

Your To-Do list

+
+ + {% csrf_token %} +
+ + + +
{{ new_item_text }}
+ +``` +* `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 + +```python + 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 + +```python + 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. + +```python +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 `''` + +```python +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) + +```python + 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 + +```python +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`: + +```python +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. + +```python + 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 '/') + +```python +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: + +```python + 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`: + +```python +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', ''), + }) +``` \ No newline at end of file diff --git a/textbook/img/5-1.jpg b/textbook/img/5-1.jpg new file mode 100644 index 0000000..dfdfe18 Binary files /dev/null and b/textbook/img/5-1.jpg differ